volleyballsimtypes 0.0.492 → 0.0.494

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as Sequelize from 'sequelize';
2
2
  import { Model } from 'sequelize';
3
- import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
3
+ import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, EventCompetitionAttributes, EventCompetitionId, EventCompetitionModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
4
4
  import { Status } from '../common';
5
5
  export interface CompetitionAttributes {
6
6
  competition_id: string;
@@ -15,6 +15,8 @@ export interface CompetitionAttributes {
15
15
  CompetitionTeams?: CompetitionTeamsAttributes[];
16
16
  RegionQualifier?: RegionQualifierAttributes;
17
17
  NationalCountry?: NationalCountryAttributes;
18
+ /** Set only on an EVENT competition: which event it belongs to, and which stage of it. */
19
+ EventCompetition?: EventCompetitionAttributes;
18
20
  UpperPromotionMatches?: PromotionMatchAttributes[];
19
21
  LowerPromotionMatches?: PromotionMatchAttributes[];
20
22
  }
@@ -72,6 +74,10 @@ export declare class CompetitionModel extends Model<CompetitionAttributes, Compe
72
74
  getNationalCountry: Sequelize.HasOneGetAssociationMixin<NationalCountryModel>;
73
75
  setNationalCountry: Sequelize.HasOneSetAssociationMixin<NationalCountryModel, NationalCountryId>;
74
76
  createNationalCountry: Sequelize.HasOneCreateAssociationMixin<NationalCountryModel>;
77
+ EventCompetition: EventCompetitionModel;
78
+ getEventCompetition: Sequelize.HasOneGetAssociationMixin<EventCompetitionModel>;
79
+ setEventCompetition: Sequelize.HasOneSetAssociationMixin<EventCompetitionModel, EventCompetitionId>;
80
+ createEventCompetition: Sequelize.HasOneCreateAssociationMixin<EventCompetitionModel>;
75
81
  UpperPromotionMatches: PromotionMatchModel[];
76
82
  getUpperPromotionMatches: Sequelize.HasManyGetAssociationsMixin<PromotionMatchModel>;
77
83
  setUpperPromotionMatches: Sequelize.HasManySetAssociationsMixin<PromotionMatchModel, PromotionMatchId>;
@@ -1,46 +1,55 @@
1
1
  import { Country } from '../country';
2
2
  import { Player, Rarity, Role } from '../player';
3
- /** A pack targets one role, or ANY for the single free pick at the end. */
4
- export type DraftRole = Role | 'ANY';
5
- /** How many players an opened pack offers. */
3
+ /**
4
+ * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
5
+ * read by the view to label it, so it is a named constant rather than a bare string in three repos.
6
+ */
7
+ export declare const FREE_PACK_ROLE = "ANY";
8
+ /** A pack targets one role, or the free pick. */
9
+ export type DraftRole = Role | typeof FREE_PACK_ROLE;
10
+ /** How many players a role-targeted pack offers. */
6
11
  export declare const PACK_SIZE = 4;
7
12
  /**
8
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
9
- * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
13
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
14
+ * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
15
+ * fifteenth free pick on top of it.
10
16
  *
11
17
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
12
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
18
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
19
+ * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
20
+ * slot the squad depends on.
13
21
  */
14
22
  export declare const DRAFT_PLAN: readonly DraftRole[];
15
23
  /** The size of a drafted squad: one pick per pack. */
16
24
  export declare const DRAFT_SQUAD_SIZE: number;
17
25
  /**
18
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
19
- *
20
- * The whole point of a generated draft is that the material is comparable: without this, one entrant opens a
21
- * pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
22
- * whose role score falls outside the band is rerolled.
26
+ * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
27
+ * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
28
+ * away from it.
23
29
  */
24
- export declare const PACK_QUALITY_TOLERANCE = 0.08;
25
- export interface PackQualityBand {
26
- readonly role: Role;
27
- readonly min: number;
28
- readonly max: number;
29
- }
30
+ export declare const TARGETED_PACK_COUNT: number;
31
+ /** How many players the free pack offers: one per position. */
32
+ export declare const FREE_PACK_SIZE: number;
30
33
  /**
31
- * The in-band range for a role at a rarity, measured from a sample of freshly generated players rather than
32
- * from hardcoded numbers, so it tracks the generator instead of drifting away from it whenever stats are
33
- * retuned. Deterministic enough in aggregate: the sample is large and the band is a fraction of its median.
34
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
35
+ *
36
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
37
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
38
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
39
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
40
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
41
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
42
+ * material is comparable in one draw.
34
43
  */
35
- export declare function packQualityBand(country: Country, rarity: Rarity, role: Role, sampleSize?: number): PackQualityBand;
44
+ export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
36
45
  /**
37
- * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity, every one of them inside
38
- * the fairness band.
46
+ * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
47
+ * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
39
48
  *
40
- * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
41
- * randomness (and a test can make it deterministic).
49
+ * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
50
+ * the best; legendaries are left uniform.
42
51
  */
43
- export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, band: (role: Role) => PackQualityBand, pickRole: (roles: readonly Role[]) => Role): Player[];
52
+ export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
44
53
  /** The role of the pack at `index`, or undefined once the draft is complete. */
45
54
  export declare function draftPackRole(index: number): DraftRole | undefined;
46
55
  export declare function isDraftComplete(picks: number): boolean;
@@ -1,80 +1,92 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PACK_QUALITY_TOLERANCE = exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = void 0;
4
- exports.packQualityBand = packQualityBand;
3
+ exports.EVENT_DRAFT_TOP_SKEW = exports.FREE_PACK_SIZE = exports.TARGETED_PACK_COUNT = exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = exports.FREE_PACK_ROLE = void 0;
5
4
  exports.generatePack = generatePack;
6
5
  exports.draftPackRole = draftPackRole;
7
6
  exports.isDraftComplete = isDraftComplete;
8
7
  const player_1 = require("../player");
9
- /** How many players an opened pack offers. */
8
+ // The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
9
+ // packs, keep one player from each, and the resulting team is thrown away when the event ends.
10
+ //
11
+ // This module is pure: the pack sequence and pack generation. Persistence (which packs a user has opened, what
12
+ // they picked) lives in the API, because the draft is user-driven rather than scheduled.
13
+ /**
14
+ * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
15
+ * read by the view to label it, so it is a named constant rather than a bare string in three repos.
16
+ */
17
+ exports.FREE_PACK_ROLE = 'ANY';
18
+ /** How many players a role-targeted pack offers. */
10
19
  exports.PACK_SIZE = 4;
11
20
  /**
12
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
13
- * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
21
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
22
+ * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
23
+ * fifteenth free pick on top of it.
14
24
  *
15
25
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
16
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
26
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
27
+ * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
28
+ * slot the squad depends on.
17
29
  */
18
30
  exports.DRAFT_PLAN = [
19
31
  player_1.RoleEnum.SETTER, player_1.RoleEnum.SETTER,
20
32
  player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER,
21
- player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
33
+ player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
22
34
  player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OPPOSITE_HITTER,
23
35
  player_1.RoleEnum.LIBERO, player_1.RoleEnum.LIBERO,
24
- 'ANY'
36
+ exports.FREE_PACK_ROLE
25
37
  ];
26
38
  /** The size of a drafted squad: one pick per pack. */
27
39
  exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN.length;
28
- /** The role a free pack rolls its choices at, so an ANY pack still offers a coherent set. */
40
+ /**
41
+ * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
42
+ * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
43
+ * away from it.
44
+ */
45
+ exports.TARGETED_PACK_COUNT = exports.DRAFT_PLAN.filter(role => role !== exports.FREE_PACK_ROLE).length;
46
+ /**
47
+ * The free pack offers one player per position, in this order, so the entrant chooses which position to
48
+ * strengthen rather than being handed a position at random.
49
+ *
50
+ * It used to roll ONE role at random and offer four of it, which meant a 1-in-5 lottery decided whether an
51
+ * entrant ended up with a third setter or a fifth outside. The squad is complete without this pick now, so
52
+ * offering the whole board is both fairer and the point of calling it free.
53
+ */
29
54
  const ANY_PACK_ROLES = [
30
55
  player_1.RoleEnum.SETTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.LIBERO
31
56
  ];
57
+ /** How many players the free pack offers: one per position. */
58
+ exports.FREE_PACK_SIZE = ANY_PACK_ROLES.length;
32
59
  /**
33
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
60
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
34
61
  *
35
- * The whole point of a generated draft is that the material is comparable: without this, one entrant opens a
36
- * pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
37
- * whose role score falls outside the band is rerolled.
62
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
63
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
64
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
65
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
66
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
67
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
68
+ * material is comparable in one draw.
38
69
  */
39
- exports.PACK_QUALITY_TOLERANCE = 0.08;
40
- /** Give up rerolling after this many attempts and take what we have, so generation can never spin forever. */
41
- const MAX_REROLLS = 40;
42
- /**
43
- * The in-band range for a role at a rarity, measured from a sample of freshly generated players rather than
44
- * from hardcoded numbers, so it tracks the generator instead of drifting away from it whenever stats are
45
- * retuned. Deterministic enough in aggregate: the sample is large and the band is a fraction of its median.
46
- */
47
- function packQualityBand(country, rarity, role, sampleSize = 60) {
48
- const scores = Array.from({ length: sampleSize }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1).stats, role)).sort((a, b) => a - b);
49
- const median = scores[Math.floor(scores.length / 2)];
50
- return {
51
- role,
52
- min: median * (1 - exports.PACK_QUALITY_TOLERANCE),
53
- max: median * (1 + exports.PACK_QUALITY_TOLERANCE)
54
- };
55
- }
70
+ exports.EVENT_DRAFT_TOP_SKEW = {
71
+ [player_1.RarityEnum.COMMON]: 2,
72
+ [player_1.RarityEnum.RARE]: 2,
73
+ [player_1.RarityEnum.LEGENDARY]: 0,
74
+ [player_1.RarityEnum.MYTHIC]: 0,
75
+ [player_1.RarityEnum.SPECIAL]: 0
76
+ };
56
77
  /**
57
- * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity, every one of them inside
58
- * the fairness band.
78
+ * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
79
+ * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
59
80
  *
60
- * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
61
- * randomness (and a test can make it deterministic).
81
+ * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
82
+ * the best; legendaries are left uniform.
62
83
  */
63
- function generatePack(country, rarity, packRole, band, pickRole) {
64
- const role = packRole === 'ANY' ? pickRole(ANY_PACK_ROLES) : packRole;
65
- const { min, max } = band(role);
66
- const players = [];
67
- for (let i = 0; i < exports.PACK_SIZE; i++) {
68
- let player = player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
69
- for (let attempt = 0; attempt < MAX_REROLLS; attempt++) {
70
- const score = (0, player_1.calculateRoleScore)(player.stats, role);
71
- if (score >= min && score <= max)
72
- break;
73
- player = player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
74
- }
75
- players.push(player);
76
- }
77
- return players;
84
+ function generatePack(country, rarity, packRole) {
85
+ const skew = exports.EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
86
+ const roll = (role) => player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew);
87
+ if (packRole === exports.FREE_PACK_ROLE)
88
+ return ANY_PACK_ROLES.map(roll);
89
+ return Array.from({ length: exports.PACK_SIZE }, () => roll(packRole));
78
90
  }
79
91
  /** The role of the pack at `index`, or undefined once the draft is complete. */
80
92
  function draftPackRole(index) {
@@ -5,29 +5,55 @@ const event_draft_1 = require("./event-draft");
5
5
  const player_1 = require("../player");
6
6
  const test_helpers_1 = require("../test-helpers");
7
7
  const COUNTRY = (0, test_helpers_1.makeCountry)();
8
+ // Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
9
+ // off the flaky edge while still failing loudly if the skew were removed.
10
+ function median(values) {
11
+ const sorted = [...values].sort((a, b) => a - b);
12
+ return sorted[Math.floor(sorted.length / 2)];
13
+ }
14
+ function percentile(values, p) {
15
+ const sorted = [...values].sort((a, b) => a - b);
16
+ return sorted[Math.floor((sorted.length - 1) * p)];
17
+ }
18
+ /** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
19
+ function draftScores(rarity, role, n) {
20
+ const out = [];
21
+ while (out.length < n) {
22
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role))
23
+ out.push((0, player_1.calculateRoleScore)(p.stats, role));
24
+ }
25
+ return out.slice(0, n);
26
+ }
27
+ /** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
28
+ function uniformScores(rarity, role, n) {
29
+ return Array.from({ length: n }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
30
+ }
8
31
  (0, globals_1.describe)('DRAFT_PLAN', () => {
9
- (0, globals_1.it)('drafts a full 14-player squad, one pick per pack', () => {
10
- (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(14);
11
- (0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(14);
32
+ (0, globals_1.it)('drafts a 15-player squad, one pick per pack', () => {
33
+ (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(15);
34
+ (0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(15);
12
35
  });
13
- (0, globals_1.it)('guarantees a positionally valid squad', () => {
36
+ (0, globals_1.it)('fills every position outright, then adds the free pick on top', () => {
14
37
  const counts = event_draft_1.DRAFT_PLAN.reduce((acc, role) => {
15
38
  acc[role] = (acc[role] ?? 0) + 1;
16
39
  return acc;
17
40
  }, {});
18
41
  (0, globals_1.expect)(counts[player_1.RoleEnum.SETTER]).toBe(2);
19
42
  (0, globals_1.expect)(counts[player_1.RoleEnum.OUTSIDE_HITTER]).toBe(4);
20
- (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(3);
43
+ (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(4);
21
44
  (0, globals_1.expect)(counts[player_1.RoleEnum.OPPOSITE_HITTER]).toBe(2);
22
45
  (0, globals_1.expect)(counts[player_1.RoleEnum.LIBERO]).toBe(2);
23
46
  (0, globals_1.expect)(counts.ANY).toBe(1);
24
- // Enough for a legal starting six plus a libero, whatever the free pack is spent on: a 5-1 needs a setter,
25
- // two middles, two outsides and an opposite on court, and a libero off it.
26
- (0, globals_1.expect)(counts[player_1.RoleEnum.SETTER]).toBeGreaterThanOrEqual(1);
27
- (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBeGreaterThanOrEqual(2);
28
- (0, globals_1.expect)(counts[player_1.RoleEnum.OUTSIDE_HITTER]).toBeGreaterThanOrEqual(2);
29
- (0, globals_1.expect)(counts[player_1.RoleEnum.OPPOSITE_HITTER]).toBeGreaterThanOrEqual(1);
30
- (0, globals_1.expect)(counts[player_1.RoleEnum.LIBERO]).toBeGreaterThanOrEqual(1);
47
+ // The targeted packs alone are a complete 14-player squad, so the free pick adds to a squad that is
48
+ // already legal rather than filling a hole in it.
49
+ const targeted = event_draft_1.DRAFT_PLAN.filter(role => role !== event_draft_1.FREE_PACK_ROLE);
50
+ (0, globals_1.expect)(targeted).toHaveLength(14);
51
+ (0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(14);
52
+ (0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(event_draft_1.DRAFT_SQUAD_SIZE - 1);
53
+ });
54
+ (0, globals_1.it)('puts the free pick last, so nothing after it depends on what was taken', () => {
55
+ (0, globals_1.expect)(event_draft_1.DRAFT_PLAN[event_draft_1.DRAFT_PLAN.length - 1]).toBe(event_draft_1.FREE_PACK_ROLE);
56
+ (0, globals_1.expect)(event_draft_1.DRAFT_PLAN.filter(role => role === event_draft_1.FREE_PACK_ROLE)).toHaveLength(1);
31
57
  });
32
58
  (0, globals_1.it)('walks pack by pack and then reports the draft complete', () => {
33
59
  for (let i = 0; i < event_draft_1.DRAFT_SQUAD_SIZE; i++) {
@@ -39,53 +65,70 @@ const COUNTRY = (0, test_helpers_1.makeCountry)();
39
65
  });
40
66
  });
41
67
  (0, globals_1.describe)('generatePack()', () => {
42
- const first = (roles) => roles[0];
43
- globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('offers %s players of the pack role, all inside the fairness band', (rarity) => {
68
+ globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
44
69
  const role = player_1.RoleEnum.OUTSIDE_HITTER;
45
- const band = (0, event_draft_1.packQualityBand)(COUNTRY, rarity, role);
46
- const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role, () => band, first);
70
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role);
47
71
  (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
48
72
  for (const player of pack) {
49
73
  (0, globals_1.expect)(player.rarity).toBe(rarity);
50
74
  (0, globals_1.expect)(player.roles).toContain(role);
51
- const score = (0, player_1.calculateRoleScore)(player.stats, role);
52
- (0, globals_1.expect)(score).toBeGreaterThanOrEqual(band.min);
53
- (0, globals_1.expect)(score).toBeLessThanOrEqual(band.max);
54
75
  }
55
76
  });
56
- (0, globals_1.it)('keeps two packs of the same role comparable', () => {
57
- const role = player_1.RoleEnum.MIDDLE_BLOCKER;
58
- const band = (0, event_draft_1.packQualityBand)(COUNTRY, player_1.RarityEnum.RARE, role);
59
- const score = (p) => (0, player_1.calculateRoleScore)(p.stats, role);
60
- const a = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.RARE, role, () => band, first).map(score);
61
- const b = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.RARE, role, () => band, first).map(score);
62
- // The point of the band: the best pack cannot run away from the worst one.
63
- const spread = Math.max(...a, ...b) - Math.min(...a, ...b);
64
- (0, globals_1.expect)(spread).toBeLessThanOrEqual(band.max - band.min);
77
+ (0, globals_1.it)('offers one player per position in the free pack, so every position is on the board', () => {
78
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, event_draft_1.FREE_PACK_ROLE);
79
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
80
+ (0, globals_1.expect)(event_draft_1.FREE_PACK_SIZE).toBe(5);
81
+ // The primary role is the one the pack was rolled at, and all five positions appear exactly once.
82
+ const primary = pack.map(p => p.roles[0]);
83
+ (0, globals_1.expect)([...primary].sort()).toEqual([
84
+ player_1.RoleEnum.LIBERO, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.SETTER
85
+ ].sort());
65
86
  });
66
- (0, globals_1.it)('rolls a free pack at the role it is handed', () => {
67
- const band = (0, event_draft_1.packQualityBand)(COUNTRY, player_1.RarityEnum.COMMON, player_1.RoleEnum.LIBERO);
68
- const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, 'ANY', () => band, () => player_1.RoleEnum.LIBERO);
69
- (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
87
+ globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('rolls the free pack at the event rarity (%s), like every other pack', (rarity) => {
88
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, event_draft_1.FREE_PACK_ROLE);
89
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
70
90
  for (const player of pack)
71
- (0, globals_1.expect)(player.roles).toContain(player_1.RoleEnum.LIBERO);
91
+ (0, globals_1.expect)(player.rarity).toBe(rarity);
72
92
  });
73
- (0, globals_1.it)('terminates even when nothing can land in band', () => {
74
- const impossible = { role: player_1.RoleEnum.SETTER, min: Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER };
75
- const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, player_1.RoleEnum.SETTER, () => impossible, first);
76
- // Generation gives up rather than spinning: a full pack is still returned.
77
- (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
93
+ (0, globals_1.it)('never offers the same player twice in a free pack', () => {
94
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.RARE, event_draft_1.FREE_PACK_ROLE);
95
+ (0, globals_1.expect)(new Set(pack.map(p => p.id)).size).toBe(event_draft_1.FREE_PACK_SIZE);
96
+ });
97
+ });
98
+ (0, globals_1.describe)('EVENT_DRAFT_TOP_SKEW', () => {
99
+ (0, globals_1.it)('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
100
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.COMMON]).toBeGreaterThan(0);
101
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.RARE]).toBeGreaterThan(0);
102
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.LEGENDARY]).toBe(0);
103
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.MYTHIC]).toBe(0);
104
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.SPECIAL]).toBe(0);
78
105
  });
79
106
  });
80
- (0, globals_1.describe)('packQualityBand()', () => {
81
- (0, globals_1.it)('brackets the median of what the generator actually produces', () => {
82
- const band = (0, event_draft_1.packQualityBand)(COUNTRY, player_1.RarityEnum.LEGENDARY, player_1.RoleEnum.SETTER);
83
- (0, globals_1.expect)(band.min).toBeLessThan(band.max);
84
- (0, globals_1.expect)(band.min).toBeGreaterThan(0);
107
+ (0, globals_1.describe)('draft skew behaviour', () => {
108
+ const N = 2500;
109
+ const role = player_1.RoleEnum.OUTSIDE_HITTER;
110
+ globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE])('draws %s recruits near the top: the typical recruit outscores a uniform draw, and most land in its top fifth', (rarity) => {
111
+ const uniform = uniformScores(rarity, role, N);
112
+ const drafted = draftScores(rarity, role, N);
113
+ // The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
114
+ const topFifthFloor = percentile(uniform, 0.80);
115
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
116
+ // A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
117
+ (0, globals_1.expect)(draftedInTopFifth).toBeGreaterThan(0.45);
118
+ // The typical drafted recruit clearly outscores the typical uniform one.
119
+ (0, globals_1.expect)(median(drafted)).toBeGreaterThan(median(uniform));
120
+ // But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
121
+ // below the best in the sample rather than everyone landing on the ceiling.
122
+ (0, globals_1.expect)(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
123
+ (0, globals_1.expect)(median(drafted)).toBeLessThan(Math.max(...drafted));
85
124
  });
86
- (0, globals_1.it)('scales with rarity, so a legendary band sits above a common one', () => {
87
- const common = (0, event_draft_1.packQualityBand)(COUNTRY, player_1.RarityEnum.COMMON, player_1.RoleEnum.OUTSIDE_HITTER);
88
- const legendary = (0, event_draft_1.packQualityBand)(COUNTRY, player_1.RarityEnum.LEGENDARY, player_1.RoleEnum.OUTSIDE_HITTER);
89
- (0, globals_1.expect)(legendary.min).toBeGreaterThan(common.max);
125
+ (0, globals_1.it)('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
126
+ const uniform = uniformScores(player_1.RarityEnum.LEGENDARY, role, N);
127
+ const drafted = draftScores(player_1.RarityEnum.LEGENDARY, role, N);
128
+ const topFifthFloor = percentile(uniform, 0.80);
129
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
130
+ // Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
131
+ // >45% the skewed lower rarities reach.
132
+ (0, globals_1.expect)(draftedInTopFifth).toBeLessThan(0.35);
90
133
  });
91
134
  });
@@ -42,7 +42,7 @@ export declare function rollRarity(pity: PityState, config?: GachaPullConfig): R
42
42
  export declare class PlayerGenerator {
43
43
  private constructor();
44
44
  private static generatePerformance;
45
- static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number): Player;
45
+ static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
46
46
  static generatePlayers(count: number, countries: Country[]): Player[];
47
47
  }
48
48
  export declare function pickPlayerCountry(teamCountry: Country, allCountries: Country[]): Country;
@@ -209,7 +209,7 @@ function rollRarity(pity, config = WORLD_PULL_CONFIG) {
209
209
  /* eslint-disable @typescript-eslint/no-extraneous-class */
210
210
  class PlayerGenerator {
211
211
  constructor() { }
212
- static generatePerformance(rarity, role) {
212
+ static generatePerformance(rarity, role, topSkew = 0) {
213
213
  const ATTACK = (0, stats_1.getMultipliers)(stats_1.StatsEnum.ATTACK);
214
214
  const SET = (0, stats_1.getMultipliers)(stats_1.StatsEnum.SET);
215
215
  const RECEIVE = (0, stats_1.getMultipliers)(stats_1.StatsEnum.RECEIVE);
@@ -220,8 +220,10 @@ class PlayerGenerator {
220
220
  // Stamina always uses its own per-rarity range (guaranteed floor), regardless of role weight.
221
221
  if (key === 'stamina')
222
222
  return [key, rollStamina(rarity)];
223
+ // On-role stats drive the role score, so a positive topSkew bends only these toward the top of the
224
+ // range; off-role stats stay uniform. topSkew defaults to 0 (the historic uniform draw).
223
225
  if (weights[key] > 0)
224
- return [key, (0, node_crypto_1.randomInt)(minValue, maxValue + 1)];
226
+ return [key, randomIntSkewed(minValue, maxValue, topSkew)];
225
227
  else
226
228
  return [key, (0, node_crypto_1.randomInt)(rarity_1.RarityRanges.COMMON[0], Math.max(minValue - 1, rarity_1.RarityRanges.COMMON[0] + 1))];
227
229
  })));
@@ -315,10 +317,10 @@ class PlayerGenerator {
315
317
  }
316
318
  }
317
319
  }
318
- static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1) {
320
+ static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
319
321
  const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
320
322
  const name = (0, utils_1.generatePlayerName)(country.locales, 1)[0];
321
- const stats = PlayerGenerator.generatePerformance(rarity, role);
323
+ const stats = PlayerGenerator.generatePerformance(rarity, role, topSkew);
322
324
  const roles = (0, role_1.assignRoles)(stats, rarity, role);
323
325
  const traits = (0, trait_1.assignTraits)(roles, rarity, maxTraits);
324
326
  const birthAge = (0, node_crypto_1.randomInt)(15, 21);
@@ -360,6 +362,21 @@ function rollStamina(rarity) {
360
362
  const [low, high] = rarity_1.StaminaRanges[rarity];
361
363
  return (0, node_crypto_1.randomInt)(low, high + 1);
362
364
  }
365
+ /**
366
+ * A crypto.randomInt draw over [min, max] inclusive, optionally skewed toward the top.
367
+ *
368
+ * skew = 0 is the plain uniform draw the generator has always used. A positive skew bends a uniform fraction
369
+ * upward with u^(1/(1+skew)), so higher values become progressively likelier while the maximum is never forced,
370
+ * which keeps the result random rather than a maxed stat. Event drafts use this to make the lower rarities'
371
+ * recruits cluster near the top of their range (see EVENT_DRAFT_TOP_SKEW).
372
+ */
373
+ function randomIntSkewed(min, max, skew) {
374
+ if (skew <= 0 || max <= min)
375
+ return (0, node_crypto_1.randomInt)(min, max + 1);
376
+ const u = (0, node_crypto_1.randomInt)(0, 1000001) / 1000000;
377
+ const frac = Math.pow(u, 1 / (1 + skew));
378
+ return min + Math.round(frac * (max - min));
379
+ }
363
380
  function assignValues(stat, low, high, performance) {
364
381
  performance_stats_1.performanceStatKeys.filter((key) => stat[key] != null && stat[key] > 0)
365
382
  .forEach((key) => {
@@ -1,6 +1,6 @@
1
1
  import * as Sequelize from 'sequelize';
2
2
  import { Model } from 'sequelize';
3
- import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
3
+ import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, EventCompetitionAttributes, EventCompetitionId, EventCompetitionModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
4
4
  import { Status } from '../common';
5
5
  export interface CompetitionAttributes {
6
6
  competition_id: string;
@@ -15,6 +15,8 @@ export interface CompetitionAttributes {
15
15
  CompetitionTeams?: CompetitionTeamsAttributes[];
16
16
  RegionQualifier?: RegionQualifierAttributes;
17
17
  NationalCountry?: NationalCountryAttributes;
18
+ /** Set only on an EVENT competition: which event it belongs to, and which stage of it. */
19
+ EventCompetition?: EventCompetitionAttributes;
18
20
  UpperPromotionMatches?: PromotionMatchAttributes[];
19
21
  LowerPromotionMatches?: PromotionMatchAttributes[];
20
22
  }
@@ -72,6 +74,10 @@ export declare class CompetitionModel extends Model<CompetitionAttributes, Compe
72
74
  getNationalCountry: Sequelize.HasOneGetAssociationMixin<NationalCountryModel>;
73
75
  setNationalCountry: Sequelize.HasOneSetAssociationMixin<NationalCountryModel, NationalCountryId>;
74
76
  createNationalCountry: Sequelize.HasOneCreateAssociationMixin<NationalCountryModel>;
77
+ EventCompetition: EventCompetitionModel;
78
+ getEventCompetition: Sequelize.HasOneGetAssociationMixin<EventCompetitionModel>;
79
+ setEventCompetition: Sequelize.HasOneSetAssociationMixin<EventCompetitionModel, EventCompetitionId>;
80
+ createEventCompetition: Sequelize.HasOneCreateAssociationMixin<EventCompetitionModel>;
75
81
  UpperPromotionMatches: PromotionMatchModel[];
76
82
  getUpperPromotionMatches: Sequelize.HasManyGetAssociationsMixin<PromotionMatchModel>;
77
83
  setUpperPromotionMatches: Sequelize.HasManySetAssociationsMixin<PromotionMatchModel, PromotionMatchId>;
@@ -1,46 +1,55 @@
1
1
  import { Country } from '../country';
2
2
  import { Player, Rarity, Role } from '../player';
3
- /** A pack targets one role, or ANY for the single free pick at the end. */
4
- export type DraftRole = Role | 'ANY';
5
- /** How many players an opened pack offers. */
3
+ /**
4
+ * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
5
+ * read by the view to label it, so it is a named constant rather than a bare string in three repos.
6
+ */
7
+ export declare const FREE_PACK_ROLE = "ANY";
8
+ /** A pack targets one role, or the free pick. */
9
+ export type DraftRole = Role | typeof FREE_PACK_ROLE;
10
+ /** How many players a role-targeted pack offers. */
6
11
  export declare const PACK_SIZE = 4;
7
12
  /**
8
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
9
- * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
13
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
14
+ * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
15
+ * fifteenth free pick on top of it.
10
16
  *
11
17
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
12
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
18
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
19
+ * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
20
+ * slot the squad depends on.
13
21
  */
14
22
  export declare const DRAFT_PLAN: readonly DraftRole[];
15
23
  /** The size of a drafted squad: one pick per pack. */
16
24
  export declare const DRAFT_SQUAD_SIZE: number;
17
25
  /**
18
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
19
- *
20
- * The whole point of a generated draft is that the material is comparable: without this, one entrant opens a
21
- * pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
22
- * whose role score falls outside the band is rerolled.
26
+ * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
27
+ * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
28
+ * away from it.
23
29
  */
24
- export declare const PACK_QUALITY_TOLERANCE = 0.08;
25
- export interface PackQualityBand {
26
- readonly role: Role;
27
- readonly min: number;
28
- readonly max: number;
29
- }
30
+ export declare const TARGETED_PACK_COUNT: number;
31
+ /** How many players the free pack offers: one per position. */
32
+ export declare const FREE_PACK_SIZE: number;
30
33
  /**
31
- * The in-band range for a role at a rarity, measured from a sample of freshly generated players rather than
32
- * from hardcoded numbers, so it tracks the generator instead of drifting away from it whenever stats are
33
- * retuned. Deterministic enough in aggregate: the sample is large and the band is a fraction of its median.
34
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
35
+ *
36
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
37
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
38
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
39
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
40
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
41
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
42
+ * material is comparable in one draw.
34
43
  */
35
- export declare function packQualityBand(country: Country, rarity: Rarity, role: Role, sampleSize?: number): PackQualityBand;
44
+ export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
36
45
  /**
37
- * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity, every one of them inside
38
- * the fairness band.
46
+ * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
47
+ * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
39
48
  *
40
- * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
41
- * randomness (and a test can make it deterministic).
49
+ * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
50
+ * the best; legendaries are left uniform.
42
51
  */
43
- export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, band: (role: Role) => PackQualityBand, pickRole: (roles: readonly Role[]) => Role): Player[];
52
+ export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
44
53
  /** The role of the pack at `index`, or undefined once the draft is complete. */
45
54
  export declare function draftPackRole(index: number): DraftRole | undefined;
46
55
  export declare function isDraftComplete(picks: number): boolean;
@@ -1,73 +1,86 @@
1
- import { PlayerGenerator, RoleEnum, calculateRoleScore } from '../player';
2
- /** How many players an opened pack offers. */
1
+ import { PlayerGenerator, RarityEnum, RoleEnum } from '../player';
2
+ // The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
3
+ // packs, keep one player from each, and the resulting team is thrown away when the event ends.
4
+ //
5
+ // This module is pure: the pack sequence and pack generation. Persistence (which packs a user has opened, what
6
+ // they picked) lives in the API, because the draft is user-driven rather than scheduled.
7
+ /**
8
+ * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
9
+ * read by the view to label it, so it is a named constant rather than a bare string in three repos.
10
+ */
11
+ export const FREE_PACK_ROLE = 'ANY';
12
+ /** How many players a role-targeted pack offers. */
3
13
  export const PACK_SIZE = 4;
4
14
  /**
5
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
6
- * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
15
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
16
+ * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
17
+ * fifteenth free pick on top of it.
7
18
  *
8
19
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
9
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
20
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
21
+ * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
22
+ * slot the squad depends on.
10
23
  */
11
24
  export const DRAFT_PLAN = [
12
25
  RoleEnum.SETTER, RoleEnum.SETTER,
13
26
  RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER,
14
- RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER,
27
+ RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER,
15
28
  RoleEnum.OPPOSITE_HITTER, RoleEnum.OPPOSITE_HITTER,
16
29
  RoleEnum.LIBERO, RoleEnum.LIBERO,
17
- 'ANY'
30
+ FREE_PACK_ROLE
18
31
  ];
19
32
  /** The size of a drafted squad: one pick per pack. */
20
33
  export const DRAFT_SQUAD_SIZE = DRAFT_PLAN.length;
21
- /** The role a free pack rolls its choices at, so an ANY pack still offers a coherent set. */
34
+ /**
35
+ * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
36
+ * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
37
+ * away from it.
38
+ */
39
+ export const TARGETED_PACK_COUNT = DRAFT_PLAN.filter(role => role !== FREE_PACK_ROLE).length;
40
+ /**
41
+ * The free pack offers one player per position, in this order, so the entrant chooses which position to
42
+ * strengthen rather than being handed a position at random.
43
+ *
44
+ * It used to roll ONE role at random and offer four of it, which meant a 1-in-5 lottery decided whether an
45
+ * entrant ended up with a third setter or a fifth outside. The squad is complete without this pick now, so
46
+ * offering the whole board is both fairer and the point of calling it free.
47
+ */
22
48
  const ANY_PACK_ROLES = [
23
49
  RoleEnum.SETTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.OPPOSITE_HITTER, RoleEnum.LIBERO
24
50
  ];
51
+ /** How many players the free pack offers: one per position. */
52
+ export const FREE_PACK_SIZE = ANY_PACK_ROLES.length;
25
53
  /**
26
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
54
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
27
55
  *
28
- * The whole point of a generated draft is that the material is comparable: without this, one entrant opens a
29
- * pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
30
- * whose role score falls outside the band is rerolled.
56
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
57
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
58
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
59
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
60
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
61
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
62
+ * material is comparable in one draw.
31
63
  */
32
- export const PACK_QUALITY_TOLERANCE = 0.08;
33
- /** Give up rerolling after this many attempts and take what we have, so generation can never spin forever. */
34
- const MAX_REROLLS = 40;
35
- /**
36
- * The in-band range for a role at a rarity, measured from a sample of freshly generated players rather than
37
- * from hardcoded numbers, so it tracks the generator instead of drifting away from it whenever stats are
38
- * retuned. Deterministic enough in aggregate: the sample is large and the band is a fraction of its median.
39
- */
40
- export function packQualityBand(country, rarity, role, sampleSize = 60) {
41
- const scores = Array.from({ length: sampleSize }, () => calculateRoleScore(PlayerGenerator.generatePlayer(country, rarity, role, false, 1).stats, role)).sort((a, b) => a - b);
42
- const median = scores[Math.floor(scores.length / 2)];
43
- return {
44
- role,
45
- min: median * (1 - PACK_QUALITY_TOLERANCE),
46
- max: median * (1 + PACK_QUALITY_TOLERANCE)
47
- };
48
- }
64
+ export const EVENT_DRAFT_TOP_SKEW = {
65
+ [RarityEnum.COMMON]: 2,
66
+ [RarityEnum.RARE]: 2,
67
+ [RarityEnum.LEGENDARY]: 0,
68
+ [RarityEnum.MYTHIC]: 0,
69
+ [RarityEnum.SPECIAL]: 0
70
+ };
49
71
  /**
50
- * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity, every one of them inside
51
- * the fairness band.
72
+ * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
73
+ * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
52
74
  *
53
- * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
54
- * randomness (and a test can make it deterministic).
75
+ * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
76
+ * the best; legendaries are left uniform.
55
77
  */
56
- export function generatePack(country, rarity, packRole, band, pickRole) {
57
- const role = packRole === 'ANY' ? pickRole(ANY_PACK_ROLES) : packRole;
58
- const { min, max } = band(role);
59
- const players = [];
60
- for (let i = 0; i < PACK_SIZE; i++) {
61
- let player = PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
62
- for (let attempt = 0; attempt < MAX_REROLLS; attempt++) {
63
- const score = calculateRoleScore(player.stats, role);
64
- if (score >= min && score <= max)
65
- break;
66
- player = PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
67
- }
68
- players.push(player);
69
- }
70
- return players;
78
+ export function generatePack(country, rarity, packRole) {
79
+ const skew = EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
80
+ const roll = (role) => PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew);
81
+ if (packRole === FREE_PACK_ROLE)
82
+ return ANY_PACK_ROLES.map(roll);
83
+ return Array.from({ length: PACK_SIZE }, () => roll(packRole));
71
84
  }
72
85
  /** The role of the pack at `index`, or undefined once the draft is complete. */
73
86
  export function draftPackRole(index) {
@@ -1,31 +1,57 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
- import { DRAFT_PLAN, DRAFT_SQUAD_SIZE, PACK_SIZE, draftPackRole, generatePack, isDraftComplete, packQualityBand } from './event-draft';
3
- import { RarityEnum, RoleEnum, calculateRoleScore } from '../player';
2
+ import { DRAFT_PLAN, DRAFT_SQUAD_SIZE, EVENT_DRAFT_TOP_SKEW, FREE_PACK_ROLE, FREE_PACK_SIZE, PACK_SIZE, TARGETED_PACK_COUNT, draftPackRole, generatePack, isDraftComplete } from './event-draft';
3
+ import { PlayerGenerator, RarityEnum, RoleEnum, calculateRoleScore } from '../player';
4
4
  import { makeCountry } from '../test-helpers';
5
5
  const COUNTRY = makeCountry();
6
+ // Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
7
+ // off the flaky edge while still failing loudly if the skew were removed.
8
+ function median(values) {
9
+ const sorted = [...values].sort((a, b) => a - b);
10
+ return sorted[Math.floor(sorted.length / 2)];
11
+ }
12
+ function percentile(values, p) {
13
+ const sorted = [...values].sort((a, b) => a - b);
14
+ return sorted[Math.floor((sorted.length - 1) * p)];
15
+ }
16
+ /** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
17
+ function draftScores(rarity, role, n) {
18
+ const out = [];
19
+ while (out.length < n) {
20
+ for (const p of generatePack(COUNTRY, rarity, role))
21
+ out.push(calculateRoleScore(p.stats, role));
22
+ }
23
+ return out.slice(0, n);
24
+ }
25
+ /** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
26
+ function uniformScores(rarity, role, n) {
27
+ return Array.from({ length: n }, () => calculateRoleScore(PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
28
+ }
6
29
  describe('DRAFT_PLAN', () => {
7
- it('drafts a full 14-player squad, one pick per pack', () => {
8
- expect(DRAFT_SQUAD_SIZE).toBe(14);
9
- expect(DRAFT_PLAN).toHaveLength(14);
30
+ it('drafts a 15-player squad, one pick per pack', () => {
31
+ expect(DRAFT_SQUAD_SIZE).toBe(15);
32
+ expect(DRAFT_PLAN).toHaveLength(15);
10
33
  });
11
- it('guarantees a positionally valid squad', () => {
34
+ it('fills every position outright, then adds the free pick on top', () => {
12
35
  const counts = DRAFT_PLAN.reduce((acc, role) => {
13
36
  acc[role] = (acc[role] ?? 0) + 1;
14
37
  return acc;
15
38
  }, {});
16
39
  expect(counts[RoleEnum.SETTER]).toBe(2);
17
40
  expect(counts[RoleEnum.OUTSIDE_HITTER]).toBe(4);
18
- expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBe(3);
41
+ expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBe(4);
19
42
  expect(counts[RoleEnum.OPPOSITE_HITTER]).toBe(2);
20
43
  expect(counts[RoleEnum.LIBERO]).toBe(2);
21
44
  expect(counts.ANY).toBe(1);
22
- // Enough for a legal starting six plus a libero, whatever the free pack is spent on: a 5-1 needs a setter,
23
- // two middles, two outsides and an opposite on court, and a libero off it.
24
- expect(counts[RoleEnum.SETTER]).toBeGreaterThanOrEqual(1);
25
- expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBeGreaterThanOrEqual(2);
26
- expect(counts[RoleEnum.OUTSIDE_HITTER]).toBeGreaterThanOrEqual(2);
27
- expect(counts[RoleEnum.OPPOSITE_HITTER]).toBeGreaterThanOrEqual(1);
28
- expect(counts[RoleEnum.LIBERO]).toBeGreaterThanOrEqual(1);
45
+ // The targeted packs alone are a complete 14-player squad, so the free pick adds to a squad that is
46
+ // already legal rather than filling a hole in it.
47
+ const targeted = DRAFT_PLAN.filter(role => role !== FREE_PACK_ROLE);
48
+ expect(targeted).toHaveLength(14);
49
+ expect(TARGETED_PACK_COUNT).toBe(14);
50
+ expect(TARGETED_PACK_COUNT).toBe(DRAFT_SQUAD_SIZE - 1);
51
+ });
52
+ it('puts the free pick last, so nothing after it depends on what was taken', () => {
53
+ expect(DRAFT_PLAN[DRAFT_PLAN.length - 1]).toBe(FREE_PACK_ROLE);
54
+ expect(DRAFT_PLAN.filter(role => role === FREE_PACK_ROLE)).toHaveLength(1);
29
55
  });
30
56
  it('walks pack by pack and then reports the draft complete', () => {
31
57
  for (let i = 0; i < DRAFT_SQUAD_SIZE; i++) {
@@ -37,53 +63,70 @@ describe('DRAFT_PLAN', () => {
37
63
  });
38
64
  });
39
65
  describe('generatePack()', () => {
40
- const first = (roles) => roles[0];
41
- it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role, all inside the fairness band', (rarity) => {
66
+ it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
42
67
  const role = RoleEnum.OUTSIDE_HITTER;
43
- const band = packQualityBand(COUNTRY, rarity, role);
44
- const pack = generatePack(COUNTRY, rarity, role, () => band, first);
68
+ const pack = generatePack(COUNTRY, rarity, role);
45
69
  expect(pack).toHaveLength(PACK_SIZE);
46
70
  for (const player of pack) {
47
71
  expect(player.rarity).toBe(rarity);
48
72
  expect(player.roles).toContain(role);
49
- const score = calculateRoleScore(player.stats, role);
50
- expect(score).toBeGreaterThanOrEqual(band.min);
51
- expect(score).toBeLessThanOrEqual(band.max);
52
73
  }
53
74
  });
54
- it('keeps two packs of the same role comparable', () => {
55
- const role = RoleEnum.MIDDLE_BLOCKER;
56
- const band = packQualityBand(COUNTRY, RarityEnum.RARE, role);
57
- const score = (p) => calculateRoleScore(p.stats, role);
58
- const a = generatePack(COUNTRY, RarityEnum.RARE, role, () => band, first).map(score);
59
- const b = generatePack(COUNTRY, RarityEnum.RARE, role, () => band, first).map(score);
60
- // The point of the band: the best pack cannot run away from the worst one.
61
- const spread = Math.max(...a, ...b) - Math.min(...a, ...b);
62
- expect(spread).toBeLessThanOrEqual(band.max - band.min);
75
+ it('offers one player per position in the free pack, so every position is on the board', () => {
76
+ const pack = generatePack(COUNTRY, RarityEnum.COMMON, FREE_PACK_ROLE);
77
+ expect(pack).toHaveLength(FREE_PACK_SIZE);
78
+ expect(FREE_PACK_SIZE).toBe(5);
79
+ // The primary role is the one the pack was rolled at, and all five positions appear exactly once.
80
+ const primary = pack.map(p => p.roles[0]);
81
+ expect([...primary].sort()).toEqual([
82
+ RoleEnum.LIBERO, RoleEnum.MIDDLE_BLOCKER, RoleEnum.OPPOSITE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.SETTER
83
+ ].sort());
63
84
  });
64
- it('rolls a free pack at the role it is handed', () => {
65
- const band = packQualityBand(COUNTRY, RarityEnum.COMMON, RoleEnum.LIBERO);
66
- const pack = generatePack(COUNTRY, RarityEnum.COMMON, 'ANY', () => band, () => RoleEnum.LIBERO);
67
- expect(pack).toHaveLength(PACK_SIZE);
85
+ it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('rolls the free pack at the event rarity (%s), like every other pack', (rarity) => {
86
+ const pack = generatePack(COUNTRY, rarity, FREE_PACK_ROLE);
87
+ expect(pack).toHaveLength(FREE_PACK_SIZE);
68
88
  for (const player of pack)
69
- expect(player.roles).toContain(RoleEnum.LIBERO);
89
+ expect(player.rarity).toBe(rarity);
70
90
  });
71
- it('terminates even when nothing can land in band', () => {
72
- const impossible = { role: RoleEnum.SETTER, min: Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER };
73
- const pack = generatePack(COUNTRY, RarityEnum.COMMON, RoleEnum.SETTER, () => impossible, first);
74
- // Generation gives up rather than spinning: a full pack is still returned.
75
- expect(pack).toHaveLength(PACK_SIZE);
91
+ it('never offers the same player twice in a free pack', () => {
92
+ const pack = generatePack(COUNTRY, RarityEnum.RARE, FREE_PACK_ROLE);
93
+ expect(new Set(pack.map(p => p.id)).size).toBe(FREE_PACK_SIZE);
94
+ });
95
+ });
96
+ describe('EVENT_DRAFT_TOP_SKEW', () => {
97
+ it('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
98
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.COMMON]).toBeGreaterThan(0);
99
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.RARE]).toBeGreaterThan(0);
100
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.LEGENDARY]).toBe(0);
101
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.MYTHIC]).toBe(0);
102
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.SPECIAL]).toBe(0);
76
103
  });
77
104
  });
78
- describe('packQualityBand()', () => {
79
- it('brackets the median of what the generator actually produces', () => {
80
- const band = packQualityBand(COUNTRY, RarityEnum.LEGENDARY, RoleEnum.SETTER);
81
- expect(band.min).toBeLessThan(band.max);
82
- expect(band.min).toBeGreaterThan(0);
105
+ describe('draft skew behaviour', () => {
106
+ const N = 2500;
107
+ const role = RoleEnum.OUTSIDE_HITTER;
108
+ it.each([RarityEnum.COMMON, RarityEnum.RARE])('draws %s recruits near the top: the typical recruit outscores a uniform draw, and most land in its top fifth', (rarity) => {
109
+ const uniform = uniformScores(rarity, role, N);
110
+ const drafted = draftScores(rarity, role, N);
111
+ // The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
112
+ const topFifthFloor = percentile(uniform, 0.80);
113
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
114
+ // A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
115
+ expect(draftedInTopFifth).toBeGreaterThan(0.45);
116
+ // The typical drafted recruit clearly outscores the typical uniform one.
117
+ expect(median(drafted)).toBeGreaterThan(median(uniform));
118
+ // But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
119
+ // below the best in the sample rather than everyone landing on the ceiling.
120
+ expect(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
121
+ expect(median(drafted)).toBeLessThan(Math.max(...drafted));
83
122
  });
84
- it('scales with rarity, so a legendary band sits above a common one', () => {
85
- const common = packQualityBand(COUNTRY, RarityEnum.COMMON, RoleEnum.OUTSIDE_HITTER);
86
- const legendary = packQualityBand(COUNTRY, RarityEnum.LEGENDARY, RoleEnum.OUTSIDE_HITTER);
87
- expect(legendary.min).toBeGreaterThan(common.max);
123
+ it('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
124
+ const uniform = uniformScores(RarityEnum.LEGENDARY, role, N);
125
+ const drafted = draftScores(RarityEnum.LEGENDARY, role, N);
126
+ const topFifthFloor = percentile(uniform, 0.80);
127
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
128
+ // Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
129
+ // >45% the skewed lower rarities reach.
130
+ expect(draftedInTopFifth).toBeLessThan(0.35);
88
131
  });
89
132
  });
@@ -42,7 +42,7 @@ export declare function rollRarity(pity: PityState, config?: GachaPullConfig): R
42
42
  export declare class PlayerGenerator {
43
43
  private constructor();
44
44
  private static generatePerformance;
45
- static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number): Player;
45
+ static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
46
46
  static generatePlayers(count: number, countries: Country[]): Player[];
47
47
  }
48
48
  export declare function pickPlayerCountry(teamCountry: Country, allCountries: Country[]): Country;
@@ -203,7 +203,7 @@ export function rollRarity(pity, config = WORLD_PULL_CONFIG) {
203
203
  /* eslint-disable @typescript-eslint/no-extraneous-class */
204
204
  export class PlayerGenerator {
205
205
  constructor() { }
206
- static generatePerformance(rarity, role) {
206
+ static generatePerformance(rarity, role, topSkew = 0) {
207
207
  const ATTACK = getMultipliers(StatsEnum.ATTACK);
208
208
  const SET = getMultipliers(StatsEnum.SET);
209
209
  const RECEIVE = getMultipliers(StatsEnum.RECEIVE);
@@ -214,8 +214,10 @@ export class PlayerGenerator {
214
214
  // Stamina always uses its own per-rarity range (guaranteed floor), regardless of role weight.
215
215
  if (key === 'stamina')
216
216
  return [key, rollStamina(rarity)];
217
+ // On-role stats drive the role score, so a positive topSkew bends only these toward the top of the
218
+ // range; off-role stats stay uniform. topSkew defaults to 0 (the historic uniform draw).
217
219
  if (weights[key] > 0)
218
- return [key, randomInt(minValue, maxValue + 1)];
220
+ return [key, randomIntSkewed(minValue, maxValue, topSkew)];
219
221
  else
220
222
  return [key, randomInt(RarityRanges.COMMON[0], Math.max(minValue - 1, RarityRanges.COMMON[0] + 1))];
221
223
  })));
@@ -309,10 +311,10 @@ export class PlayerGenerator {
309
311
  }
310
312
  }
311
313
  }
312
- static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1) {
314
+ static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
313
315
  const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
314
316
  const name = generatePlayerName(country.locales, 1)[0];
315
- const stats = PlayerGenerator.generatePerformance(rarity, role);
317
+ const stats = PlayerGenerator.generatePerformance(rarity, role, topSkew);
316
318
  const roles = assignRoles(stats, rarity, role);
317
319
  const traits = assignTraits(roles, rarity, maxTraits);
318
320
  const birthAge = randomInt(15, 21);
@@ -353,6 +355,21 @@ function rollStamina(rarity) {
353
355
  const [low, high] = StaminaRanges[rarity];
354
356
  return randomInt(low, high + 1);
355
357
  }
358
+ /**
359
+ * A crypto.randomInt draw over [min, max] inclusive, optionally skewed toward the top.
360
+ *
361
+ * skew = 0 is the plain uniform draw the generator has always used. A positive skew bends a uniform fraction
362
+ * upward with u^(1/(1+skew)), so higher values become progressively likelier while the maximum is never forced,
363
+ * which keeps the result random rather than a maxed stat. Event drafts use this to make the lower rarities'
364
+ * recruits cluster near the top of their range (see EVENT_DRAFT_TOP_SKEW).
365
+ */
366
+ function randomIntSkewed(min, max, skew) {
367
+ if (skew <= 0 || max <= min)
368
+ return randomInt(min, max + 1);
369
+ const u = randomInt(0, 1000001) / 1000000;
370
+ const frac = Math.pow(u, 1 / (1 + skew));
371
+ return min + Math.round(frac * (max - min));
372
+ }
356
373
  function assignValues(stat, low, high, performance) {
357
374
  performanceStatKeys.filter((key) => stat[key] != null && stat[key] > 0)
358
375
  .forEach((key) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.492",
3
+ "version": "0.0.494",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",