volleyballsimtypes 0.0.531 → 0.0.532

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.
@@ -31,24 +31,35 @@ export declare const TARGETED_PACK_COUNT: number;
31
31
  /** How many players the free pack offers: one per position. */
32
32
  export declare const FREE_PACK_SIZE: number;
33
33
  /**
34
- * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
34
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event, on top of the
35
+ * pairing design (see generatePack). The number is a generator skew strength, see randomIntSkewed: 0 is the plain
36
+ * uniform draw over the rarity band.
35
37
  *
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.
38
+ * 0 everywhere (owner 2026-09-03, measured). The pairing already puts BOTH of a recruit's specialities inside the
39
+ * rarity band, which is far above what an untargeted pull gives, and skewing on top of that pinned the composites
40
+ * to the ceiling: half a pack came out maxed. On the mythic band (80-89), for a dominant composite:
41
+ *
42
+ * skew 0 mean 84.6 6% at 88+ 0% maxed
43
+ * skew 1 mean 86.1 17% at 88+ 1% maxed
44
+ * skew 2 mean 86.8 34% at 88+ 4% maxed
45
+ * skew 4 mean 87.6 61% at 88+ 13% maxed
46
+ *
47
+ * Uniform keeps a pack of four spread across its band rather than clustered at the top, so the four pairings differ
48
+ * in strength as well as in shape. Raise a rarity here to bend its recruits upward again.
43
49
  */
44
50
  export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
45
51
  /**
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.
52
+ * The players a pack offers, at the event's rarity.
53
+ *
54
+ * A role-targeted pack offers one recruit per ROLE PAIRING (owner 2026-09-03): the targeted role plus each of the
55
+ * four others, so the pick is which second role to take on and the four options are genuinely different players
56
+ * rather than four rolls of the same thing. The free pack offers one per position, each with a random second role.
57
+ * Every recruit carries the maximum traits its rarity allows, from its own roles' pool.
48
58
  *
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.
59
+ * COMMON is the exception: one role and no traits, so its packs are plain draws of the target role.
51
60
  */
61
+ /** The four roles a pack's target can be paired with, in a stable order. */
62
+ export declare function pairingsFor(role: Role): Role[];
52
63
  export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
53
64
  /** The role of the pack at `index`, or undefined once the draft is complete. */
54
65
  export declare function draftPackRole(index: number): DraftRole | undefined;
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
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;
4
+ exports.pairingsFor = pairingsFor;
4
5
  exports.generatePack = generatePack;
5
6
  exports.draftPackRole = draftPackRole;
6
7
  exports.isDraftComplete = isDraftComplete;
8
+ const node_crypto_1 = require("node:crypto");
7
9
  const player_1 = require("../player");
8
10
  // The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
9
11
  // packs, keep one player from each, and the resulting team is thrown away when the event ends.
@@ -57,36 +59,64 @@ const ANY_PACK_ROLES = [
57
59
  /** How many players the free pack offers: one per position. */
58
60
  exports.FREE_PACK_SIZE = ANY_PACK_ROLES.length;
59
61
  /**
60
- * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
62
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event, on top of the
63
+ * pairing design (see generatePack). The number is a generator skew strength, see randomIntSkewed: 0 is the plain
64
+ * uniform draw over the rarity band.
61
65
  *
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.
66
+ * 0 everywhere (owner 2026-09-03, measured). The pairing already puts BOTH of a recruit's specialities inside the
67
+ * rarity band, which is far above what an untargeted pull gives, and skewing on top of that pinned the composites
68
+ * to the ceiling: half a pack came out maxed. On the mythic band (80-89), for a dominant composite:
69
+ *
70
+ * skew 0 mean 84.6 6% at 88+ 0% maxed
71
+ * skew 1 mean 86.1 17% at 88+ 1% maxed
72
+ * skew 2 mean 86.8 34% at 88+ 4% maxed
73
+ * skew 4 mean 87.6 61% at 88+ 13% maxed
74
+ *
75
+ * Uniform keeps a pack of four spread across its band rather than clustered at the top, so the four pairings differ
76
+ * in strength as well as in shape. Raise a rarity here to bend its recruits upward again.
69
77
  */
70
78
  exports.EVENT_DRAFT_TOP_SKEW = {
71
- [player_1.RarityEnum.COMMON]: 2,
72
- [player_1.RarityEnum.RARE]: 2,
79
+ [player_1.RarityEnum.COMMON]: 0,
80
+ [player_1.RarityEnum.RARE]: 0,
73
81
  [player_1.RarityEnum.LEGENDARY]: 0,
74
82
  [player_1.RarityEnum.MYTHIC]: 0,
75
83
  [player_1.RarityEnum.SPECIAL]: 0
76
84
  };
77
85
  /**
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.
86
+ * The players a pack offers, at the event's rarity.
80
87
  *
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.
88
+ * A role-targeted pack offers one recruit per ROLE PAIRING (owner 2026-09-03): the targeted role plus each of the
89
+ * four others, so the pick is which second role to take on and the four options are genuinely different players
90
+ * rather than four rolls of the same thing. The free pack offers one per position, each with a random second role.
91
+ * Every recruit carries the maximum traits its rarity allows, from its own roles' pool.
92
+ *
93
+ * COMMON is the exception: one role and no traits, so its packs are plain draws of the target role.
83
94
  */
95
+ /** The four roles a pack's target can be paired with, in a stable order. */
96
+ function pairingsFor(role) {
97
+ return ANY_PACK_ROLES.filter((other) => other !== role);
98
+ }
84
99
  function generatePack(country, rarity, packRole) {
85
100
  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));
101
+ const roll = (roles) => player_1.PlayerGenerator.generateEventPlayer(country, rarity, roles, skew);
102
+ // COMMON holds ONE role and no traits (owner 2026-09-03), so there is no second role to vary: the pack is
103
+ // PACK_SIZE draws of the target role and the choice is purely which stat line to take.
104
+ const single = rarity === player_1.RarityEnum.COMMON;
105
+ if (packRole === exports.FREE_PACK_ROLE) {
106
+ // One recruit per position, so the choice is which position to add to. Each still gets a second role (drawn at
107
+ // random, since no pairing was targeted) unless the rarity is single-role.
108
+ return ANY_PACK_ROLES.map((role) => {
109
+ if (single)
110
+ return roll([role]);
111
+ const partners = pairingsFor(role);
112
+ return roll([role, partners[(0, node_crypto_1.randomInt)(0, partners.length)]]);
113
+ });
114
+ }
115
+ if (single)
116
+ return Array.from({ length: exports.PACK_SIZE }, () => roll([packRole]));
117
+ // One recruit per pairing: a SETTER pack offers (S,L), (S,OH), (S,OP) and (S,MB), so the pick is which second
118
+ // role to take on. There are exactly four other roles, which is PACK_SIZE.
119
+ return pairingsFor(packRole).map((partner) => roll([packRole, partner]));
90
120
  }
91
121
  /** The role of the pack at `index`, or undefined once the draft is complete. */
92
122
  function draftPackRole(index) {
@@ -5,29 +5,6 @@ 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
- }
31
8
  (0, globals_1.describe)('DRAFT_PLAN', () => {
32
9
  (0, globals_1.it)('drafts a 15-player squad, one pick per pack', () => {
33
10
  (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(15);
@@ -96,39 +73,114 @@ function uniformScores(rarity, role, n) {
96
73
  });
97
74
  });
98
75
  (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);
76
+ // The pairing already puts both of a recruit's specialities inside the rarity band, so skewing on top of that
77
+ // pinned the composites to the ceiling and half a pack came out maxed (owner 2026-09-03). Uniform keeps a pack
78
+ // spread across its band. The constant stays as the dial; the measured alternatives are in its comment.
79
+ (0, globals_1.it)('draws every rarity uniformly across its band', () => {
80
+ for (const rarity of [player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY, player_1.RarityEnum.MYTHIC, player_1.RarityEnum.SPECIAL]) {
81
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[rarity]).toBe(0);
82
+ }
83
+ });
84
+ (0, globals_1.it)('rarely pins a composite to the top of the band, so a pack is not clustered at the ceiling', () => {
85
+ // The regression this guards: at skew 4 61% of dominant composites reached 88 and 13% hit the 89 ceiling, so
86
+ // half a pack came out maxed. Uniform measures ~6% at 88+ and ~0% maxed. A rate, not an absolute, because a
87
+ // uniform draw can of course still land on the ceiling now and then.
88
+ const [, max] = player_1.RarityRanges[player_1.RarityEnum.MYTHIC];
89
+ let composites = 0;
90
+ let nearMax = 0;
91
+ for (let i = 0; i < 300; i++) {
92
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.MYTHIC, player_1.RoleEnum.OUTSIDE_HITTER)) {
93
+ for (const s of p.generalStats) {
94
+ composites++;
95
+ if (s.value >= max - 1)
96
+ nearMax++;
97
+ }
98
+ }
99
+ }
100
+ (0, globals_1.expect)(nearMax / composites).toBeLessThan(0.15);
101
+ });
102
+ });
103
+ (0, globals_1.describe)('event recruits are role PAIRINGS (owner 2026-09-03)', () => {
104
+ const NON_COMMON = [player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY, player_1.RarityEnum.MYTHIC, player_1.RarityEnum.SPECIAL];
105
+ const ROLES = [player_1.RoleEnum.SETTER, player_1.RoleEnum.LIBERO, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER];
106
+ globals_1.it.each(NON_COMMON)('a %s pack offers the target role paired with each of the four others, exactly once', (rarity) => {
107
+ for (const role of ROLES) {
108
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role);
109
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
110
+ for (const p of pack) {
111
+ (0, globals_1.expect)(p.roles).toHaveLength(2);
112
+ (0, globals_1.expect)(p.roles[0]).toBe(role); // the drafted role is always primary
113
+ }
114
+ // One recruit per partner, no repeats: the four options are different players, not four rolls of one.
115
+ const partners = pack.map(p => p.roles[1]);
116
+ (0, globals_1.expect)(new Set(partners).size).toBe(event_draft_1.PACK_SIZE);
117
+ (0, globals_1.expect)([...partners].sort()).toEqual([...(0, event_draft_1.pairingsFor)(role)].sort());
118
+ }
119
+ });
120
+ globals_1.it.each(NON_COMMON)('gives every %s recruit the MAXIMUM traits its rarity allows, from its own roles pool', (rarity) => {
121
+ const expected = (0, player_1.assignTraits)([player_1.RoleEnum.SETTER], rarity, true).length;
122
+ (0, globals_1.expect)(expected).toBeGreaterThan(0);
123
+ for (const role of ROLES) {
124
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role)) {
125
+ (0, globals_1.expect)(p.traits).toHaveLength(expected);
126
+ // Every trait belongs to one of the two roles the player actually holds.
127
+ for (const t of p.traits) {
128
+ const def = player_1.traitMap.get(t);
129
+ (0, globals_1.expect)(def?.roles.some((r) => p.roles.includes(r))).toBe(true);
130
+ }
131
+ }
132
+ }
133
+ });
134
+ // COMMON is the stated exception: one role, no traits, so there is no second role to vary.
135
+ (0, globals_1.it)('a COMMON pack is single-role and trait-free', () => {
136
+ for (const role of ROLES) {
137
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, role);
138
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
139
+ for (const p of pack) {
140
+ (0, globals_1.expect)(p.roles).toEqual([role]);
141
+ (0, globals_1.expect)(p.traits).toHaveLength(0);
142
+ }
143
+ }
144
+ });
145
+ (0, globals_1.it)('the free pack still offers one recruit per position', () => {
146
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.MYTHIC, event_draft_1.FREE_PACK_ROLE);
147
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
148
+ (0, globals_1.expect)(new Set(pack.map(p => p.roles[0])).size).toBe(event_draft_1.FREE_PACK_SIZE); // one per position
149
+ for (const p of pack)
150
+ (0, globals_1.expect)(p.roles).toHaveLength(2); // each still picks up a second role
105
151
  });
106
152
  });
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));
124
- });
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);
153
+ (0, globals_1.describe)('a pairing is SHARP: elite at both roles, with real holes', () => {
154
+ const N = 400;
155
+ // The stat groups each role is FOR, so a recruit can be checked against what it claims to be.
156
+ const DOMINANT = {
157
+ [player_1.RoleEnum.SETTER]: player_1.StatsEnum.SET,
158
+ [player_1.RoleEnum.LIBERO]: player_1.StatsEnum.RECEIVE,
159
+ [player_1.RoleEnum.OUTSIDE_HITTER]: player_1.StatsEnum.ATTACK,
160
+ [player_1.RoleEnum.OPPOSITE_HITTER]: player_1.StatsEnum.ATTACK,
161
+ [player_1.RoleEnum.MIDDLE_BLOCKER]: player_1.StatsEnum.BLOCK
162
+ };
163
+ const rating = (p, g) => p.generalStats.find(s => s.name === g)?.value ?? 0;
164
+ (0, globals_1.it)('puts BOTH roles dominant ratings inside the rarity band', () => {
165
+ // The floor is the band MINIMUM, not its top fifth: a rating is a weighted blend, and a few of its inputs
166
+ // (ATTACK's focus and awareness, BLOCK's defense) are supporting stats for the role, which pulls the rating a
167
+ // little below the raw stats. What matters is that both of a pairing's specialities land in mythic territory.
168
+ const [min] = player_1.RarityRanges[player_1.RarityEnum.MYTHIC];
169
+ for (let i = 0; i < N; i++) {
170
+ for (const role of [player_1.RoleEnum.SETTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER]) {
171
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.MYTHIC, role)) {
172
+ for (const held of p.roles)
173
+ (0, globals_1.expect)(rating(p, DOMINANT[held])).toBeGreaterThanOrEqual(min);
174
+ }
175
+ }
176
+ }
177
+ });
178
+ (0, globals_1.it)('leaves a real hole where NEITHER role reaches, so the four options are a genuine choice', () => {
179
+ // A (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and blocking and cannot set: neither role touches
180
+ // the SET group, so it keeps the near-common draw and lands far below the band.
181
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.MYTHIC, player_1.RoleEnum.OUTSIDE_HITTER);
182
+ const withMiddle = pack.find(p => p.roles[1] === player_1.RoleEnum.MIDDLE_BLOCKER);
183
+ (0, globals_1.expect)(withMiddle).toBeDefined();
184
+ (0, globals_1.expect)(rating(withMiddle, player_1.StatsEnum.SET)).toBeLessThan(player_1.RarityRanges[player_1.RarityEnum.MYTHIC][0]);
133
185
  });
134
186
  });
@@ -42,6 +42,28 @@ 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
+ /**
46
+ * The stat block for an EVENT DRAFT recruit, built from the exact roles it will hold (owner 2026-09-03).
47
+ *
48
+ * Each role's DOMINANT rating group is drawn from the rarity band bent toward its top fifth, so the player is
49
+ * genuinely elite at what each of its roles is for. The roles' SUPPORTING groups drop two rarity tiers, so a
50
+ * pairing is sharp rather than good at everything: a (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and
51
+ * blocking, ordinary at receiving and serving, and has a real hole at setting. Anything neither role touches
52
+ * keeps the historic near-common draw, and stamina keeps its own per-rarity floor.
53
+ */
54
+ private static generateEventPerformance;
55
+ /**
56
+ * An EVENT DRAFT recruit (owner 2026-09-03). `roles` is exactly what the player will hold, `roles[0]` being the
57
+ * role the pack targeted; the draft offers one recruit per pairing so the choice is which second role to take.
58
+ *
59
+ * Traits are always the MAXIMUM the rarity allows, drawn from these roles' pool (assignTraits already filters by
60
+ * role), so an event recruit is a finished, interesting player rather than a stat line. A COMMON holds one role
61
+ * and, by that same table, no traits.
62
+ *
63
+ * Deliberately separate from generatePlayer: the gacha, bot teams and the seeds keep their own generation
64
+ * untouched. This shape is for events only.
65
+ */
66
+ static generateEventPlayer(country: Country, rarity: Rarity, roles: Role[], topSkew?: number, birthIteration?: number): Player;
45
67
  static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
46
68
  static generatePlayers(count: number, countries: Country[]): Player[];
47
69
  }
@@ -317,6 +317,51 @@ class PlayerGenerator {
317
317
  }
318
318
  }
319
319
  }
320
+ /**
321
+ * The stat block for an EVENT DRAFT recruit, built from the exact roles it will hold (owner 2026-09-03).
322
+ *
323
+ * Each role's DOMINANT rating group is drawn from the rarity band bent toward its top fifth, so the player is
324
+ * genuinely elite at what each of its roles is for. The roles' SUPPORTING groups drop two rarity tiers, so a
325
+ * pairing is sharp rather than good at everything: a (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and
326
+ * blocking, ordinary at receiving and serving, and has a real hole at setting. Anything neither role touches
327
+ * keeps the historic near-common draw, and stamina keeps its own per-rarity floor.
328
+ */
329
+ static generateEventPerformance(rarity, roles, topSkew) {
330
+ const [min, max] = rarity_1.RarityRanges[rarity];
331
+ const [supportMin, supportMax] = rarity_1.RarityRanges[(0, rarity_1.rarityBelow)(rarity, 2)];
332
+ const { dominant, supporting } = (0, role_1.roleStatTiers)(roles);
333
+ return performance_stats_1.PerformanceStats.create(Object.fromEntries(performance_stats_1.performanceStatKeys.map((key) => {
334
+ if (key === 'stamina')
335
+ return [key, rollStamina(rarity)];
336
+ if (dominant.has(key))
337
+ return [key, randomIntSkewed(min, max, topSkew)];
338
+ if (supporting.has(key))
339
+ return [key, (0, node_crypto_1.randomInt)(supportMin, supportMax + 1)];
340
+ return [key, (0, node_crypto_1.randomInt)(rarity_1.RarityRanges.COMMON[0], Math.max(min - 1, rarity_1.RarityRanges.COMMON[0] + 1))];
341
+ })));
342
+ }
343
+ /**
344
+ * An EVENT DRAFT recruit (owner 2026-09-03). `roles` is exactly what the player will hold, `roles[0]` being the
345
+ * role the pack targeted; the draft offers one recruit per pairing so the choice is which second role to take.
346
+ *
347
+ * Traits are always the MAXIMUM the rarity allows, drawn from these roles' pool (assignTraits already filters by
348
+ * role), so an event recruit is a finished, interesting player rather than a stat line. A COMMON holds one role
349
+ * and, by that same table, no traits.
350
+ *
351
+ * Deliberately separate from generatePlayer: the gacha, bot teams and the seeds keep their own generation
352
+ * untouched. This shape is for events only.
353
+ */
354
+ static generateEventPlayer(country, rarity, roles, topSkew = 0, birthIteration = 1) {
355
+ if (roles.length === 0)
356
+ throw new Error('EVENT_PLAYER_NEEDS_A_ROLE');
357
+ const name = (0, utils_1.generatePlayerName)(country.locales, 1)[0];
358
+ const stats = PlayerGenerator.generateEventPerformance(rarity, roles, topSkew);
359
+ const traits = (0, trait_1.assignTraits)(roles, rarity, true);
360
+ const birthAge = (0, node_crypto_1.randomInt)(15, 21);
361
+ const declineProfile = decline_1.declineProfiles[(0, node_crypto_1.randomInt)(0, decline_1.declineProfiles.length)];
362
+ const injuryProneProfile = (0, node_crypto_1.randomInt)(0, 101) / 100;
363
+ return player_1.Player.create({ id: (0, uuid_1.v4)(), name, country, stats, roles, traits, rarity, age: birthAge, birthAge, birthIteration, declineProfile, injuryProneProfile });
364
+ }
320
365
  static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
321
366
  const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
322
367
  const name = (0, utils_1.generatePlayerName)(country.locales, 1)[0];
@@ -13,6 +13,10 @@ export declare const RarityRanges: {
13
13
  MYTHIC: number[];
14
14
  SPECIAL: number[];
15
15
  };
16
+ /** The rarity ladder, lowest first. Used to step a band down a tier. */
17
+ export declare const RARITY_LADDER: Rarity[];
18
+ /** `steps` tiers below `rarity`, floored at COMMON. */
19
+ export declare function rarityBelow(rarity: Rarity, steps: number): Rarity;
16
20
  export declare const StaminaRanges: {
17
21
  COMMON: number[];
18
22
  RARE: number[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StaminaRanges = exports.RarityRanges = exports.RarityEnum = void 0;
3
+ exports.StaminaRanges = exports.RARITY_LADDER = exports.RarityRanges = exports.RarityEnum = void 0;
4
+ exports.rarityBelow = rarityBelow;
4
5
  var RarityEnum;
5
6
  (function (RarityEnum) {
6
7
  RarityEnum["COMMON"] = "COMMON";
@@ -16,6 +17,15 @@ exports.RarityRanges = {
16
17
  [RarityEnum.MYTHIC]: [80, 89],
17
18
  [RarityEnum.SPECIAL]: [90, 99]
18
19
  };
20
+ /** The rarity ladder, lowest first. Used to step a band down a tier. */
21
+ exports.RARITY_LADDER = [
22
+ RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL
23
+ ];
24
+ /** `steps` tiers below `rarity`, floored at COMMON. */
25
+ function rarityBelow(rarity, steps) {
26
+ const i = exports.RARITY_LADDER.indexOf(rarity);
27
+ return exports.RARITY_LADDER[Math.max(0, i - steps)];
28
+ }
19
29
  // Stamina has its own per-rarity range with a guaranteed floor, applied identically in both the
20
30
  // role-weighted and the generic generation paths (stamina is otherwise a secondary stat that would
21
31
  // bottom out near 1 in the generic path). Floors: RARE 15, LEGENDARY 30, MYTHIC 45, SPECIAL 60.
@@ -1,4 +1,5 @@
1
- import { PerformanceStats, PerformanceStatsParams } from './performance-stats';
1
+ import { PerformanceStats, PerformanceStatsKey, PerformanceStatsParams } from './performance-stats';
2
+ import { Stats } from './stats';
2
3
  import { Rarity } from './rarity';
3
4
  export declare enum RoleEnum {
4
5
  SETTER = "SETTER",
@@ -14,6 +15,23 @@ export interface RolesFormula {
14
15
  weight: PerformanceStatsParams;
15
16
  }
16
17
  export declare function calculateRoleScore(stats: PerformanceStats, role: Role): number;
18
+ /**
19
+ * How much a role cares about each rating group: the sum of its roles.json weights over the stats inside that
20
+ * group. An outside hitter is spike 0.3 + backAttack 0.1 = 0.4 on ATTACK, then 0.2 on RECEIVE and 0.2 on BLOCK,
21
+ * so ATTACK is what it is FOR. Groups the role has no weight in are absent.
22
+ */
23
+ export declare function roleGroupWeights(role: Role): Map<Stats, number>;
24
+ /**
25
+ * Split the stats of a role SET into the ones the players are FOR and the ones they merely support.
26
+ *
27
+ * A role's DOMINANT group is the one it weighs most (a middle blocker's BLOCK, a libero's RECEIVE); every stat
28
+ * feeding it is a dominant stat. The role's other groups are supporting. Union across the given roles, dominant
29
+ * winning any overlap, so a two-role player is elite at both roles' specialities and merely useful around them.
30
+ */
31
+ export declare function roleStatTiers(roles: Role[]): {
32
+ dominant: Set<PerformanceStatsKey>;
33
+ supporting: Set<PerformanceStatsKey>;
34
+ };
17
35
  export declare function assignRoles(stats: PerformanceStats, rarity: Rarity, forcedRole?: Role): Role[];
18
36
  /**
19
37
  * Returns the role-based multiplier for a player performing a given action.
@@ -5,9 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RoleEnum = void 0;
7
7
  exports.calculateRoleScore = calculateRoleScore;
8
+ exports.roleGroupWeights = roleGroupWeights;
9
+ exports.roleStatTiers = roleStatTiers;
8
10
  exports.assignRoles = assignRoles;
9
11
  exports.getRoleEventPenalty = getRoleEventPenalty;
10
12
  exports.getBlockRolePenalty = getBlockRolePenalty;
13
+ const stats_1 = require("./stats");
11
14
  const rarity_1 = require("./rarity");
12
15
  const roles_json_1 = __importDefault(require("../../stat-config/roles.json"));
13
16
  var RoleEnum;
@@ -43,6 +46,54 @@ function getRoleCountRange(rarity) {
43
46
  default: return [1, 1];
44
47
  }
45
48
  }
49
+ /** The five rating groups a role can care about (STAMINA is excluded: it has its own per-rarity floor). */
50
+ const RATING_GROUPS = [stats_1.StatsEnum.ATTACK, stats_1.StatsEnum.SET, stats_1.StatsEnum.RECEIVE, stats_1.StatsEnum.SERVE, stats_1.StatsEnum.BLOCK];
51
+ /**
52
+ * How much a role cares about each rating group: the sum of its roles.json weights over the stats inside that
53
+ * group. An outside hitter is spike 0.3 + backAttack 0.1 = 0.4 on ATTACK, then 0.2 on RECEIVE and 0.2 on BLOCK,
54
+ * so ATTACK is what it is FOR. Groups the role has no weight in are absent.
55
+ */
56
+ function roleGroupWeights(role) {
57
+ const weights = rolesFormula.find(r => r.role === role)?.weight;
58
+ if (weights == null)
59
+ throw new Error('COULD_NOT_FIND_ROLE_WEIGHT');
60
+ const out = new Map();
61
+ for (const group of RATING_GROUPS) {
62
+ const total = Object.keys((0, stats_1.getMultipliers)(group))
63
+ .reduce((sum, key) => sum + (key === 'stamina' ? 0 : (weights[key] ?? 0)), 0);
64
+ if (total > 0)
65
+ out.set(group, total);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Split the stats of a role SET into the ones the players are FOR and the ones they merely support.
71
+ *
72
+ * A role's DOMINANT group is the one it weighs most (a middle blocker's BLOCK, a libero's RECEIVE); every stat
73
+ * feeding it is a dominant stat. The role's other groups are supporting. Union across the given roles, dominant
74
+ * winning any overlap, so a two-role player is elite at both roles' specialities and merely useful around them.
75
+ */
76
+ function roleStatTiers(roles) {
77
+ const dominant = new Set();
78
+ const supporting = new Set();
79
+ for (const role of roles) {
80
+ const groups = roleGroupWeights(role);
81
+ const best = Math.max(...groups.values());
82
+ for (const [group, weight] of groups) {
83
+ for (const key of Object.keys((0, stats_1.getMultipliers)(group))) {
84
+ if (key === 'stamina')
85
+ continue;
86
+ if (weight === best)
87
+ dominant.add(key);
88
+ else
89
+ supporting.add(key);
90
+ }
91
+ }
92
+ }
93
+ for (const key of dominant)
94
+ supporting.delete(key);
95
+ return { dominant, supporting };
96
+ }
46
97
  function assignRoles(stats, rarity, forcedRole) {
47
98
  const scores = Object.values(RoleEnum)
48
99
  .map((role) => ({
@@ -31,24 +31,35 @@ export declare const TARGETED_PACK_COUNT: number;
31
31
  /** How many players the free pack offers: one per position. */
32
32
  export declare const FREE_PACK_SIZE: number;
33
33
  /**
34
- * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
34
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event, on top of the
35
+ * pairing design (see generatePack). The number is a generator skew strength, see randomIntSkewed: 0 is the plain
36
+ * uniform draw over the rarity band.
35
37
  *
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.
38
+ * 0 everywhere (owner 2026-09-03, measured). The pairing already puts BOTH of a recruit's specialities inside the
39
+ * rarity band, which is far above what an untargeted pull gives, and skewing on top of that pinned the composites
40
+ * to the ceiling: half a pack came out maxed. On the mythic band (80-89), for a dominant composite:
41
+ *
42
+ * skew 0 mean 84.6 6% at 88+ 0% maxed
43
+ * skew 1 mean 86.1 17% at 88+ 1% maxed
44
+ * skew 2 mean 86.8 34% at 88+ 4% maxed
45
+ * skew 4 mean 87.6 61% at 88+ 13% maxed
46
+ *
47
+ * Uniform keeps a pack of four spread across its band rather than clustered at the top, so the four pairings differ
48
+ * in strength as well as in shape. Raise a rarity here to bend its recruits upward again.
43
49
  */
44
50
  export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
45
51
  /**
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.
52
+ * The players a pack offers, at the event's rarity.
53
+ *
54
+ * A role-targeted pack offers one recruit per ROLE PAIRING (owner 2026-09-03): the targeted role plus each of the
55
+ * four others, so the pick is which second role to take on and the four options are genuinely different players
56
+ * rather than four rolls of the same thing. The free pack offers one per position, each with a random second role.
57
+ * Every recruit carries the maximum traits its rarity allows, from its own roles' pool.
48
58
  *
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.
59
+ * COMMON is the exception: one role and no traits, so its packs are plain draws of the target role.
51
60
  */
61
+ /** The four roles a pack's target can be paired with, in a stable order. */
62
+ export declare function pairingsFor(role: Role): Role[];
52
63
  export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
53
64
  /** The role of the pack at `index`, or undefined once the draft is complete. */
54
65
  export declare function draftPackRole(index: number): DraftRole | undefined;
@@ -1,3 +1,4 @@
1
+ import { randomInt } from 'node:crypto';
1
2
  import { PlayerGenerator, RarityEnum, RoleEnum } from '../player';
2
3
  // The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
3
4
  // packs, keep one player from each, and the resulting team is thrown away when the event ends.
@@ -51,36 +52,64 @@ const ANY_PACK_ROLES = [
51
52
  /** How many players the free pack offers: one per position. */
52
53
  export const FREE_PACK_SIZE = ANY_PACK_ROLES.length;
53
54
  /**
54
- * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
55
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event, on top of the
56
+ * pairing design (see generatePack). The number is a generator skew strength, see randomIntSkewed: 0 is the plain
57
+ * uniform draw over the rarity band.
55
58
  *
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.
59
+ * 0 everywhere (owner 2026-09-03, measured). The pairing already puts BOTH of a recruit's specialities inside the
60
+ * rarity band, which is far above what an untargeted pull gives, and skewing on top of that pinned the composites
61
+ * to the ceiling: half a pack came out maxed. On the mythic band (80-89), for a dominant composite:
62
+ *
63
+ * skew 0 mean 84.6 6% at 88+ 0% maxed
64
+ * skew 1 mean 86.1 17% at 88+ 1% maxed
65
+ * skew 2 mean 86.8 34% at 88+ 4% maxed
66
+ * skew 4 mean 87.6 61% at 88+ 13% maxed
67
+ *
68
+ * Uniform keeps a pack of four spread across its band rather than clustered at the top, so the four pairings differ
69
+ * in strength as well as in shape. Raise a rarity here to bend its recruits upward again.
63
70
  */
64
71
  export const EVENT_DRAFT_TOP_SKEW = {
65
- [RarityEnum.COMMON]: 2,
66
- [RarityEnum.RARE]: 2,
72
+ [RarityEnum.COMMON]: 0,
73
+ [RarityEnum.RARE]: 0,
67
74
  [RarityEnum.LEGENDARY]: 0,
68
75
  [RarityEnum.MYTHIC]: 0,
69
76
  [RarityEnum.SPECIAL]: 0
70
77
  };
71
78
  /**
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.
79
+ * The players a pack offers, at the event's rarity.
74
80
  *
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.
81
+ * A role-targeted pack offers one recruit per ROLE PAIRING (owner 2026-09-03): the targeted role plus each of the
82
+ * four others, so the pick is which second role to take on and the four options are genuinely different players
83
+ * rather than four rolls of the same thing. The free pack offers one per position, each with a random second role.
84
+ * Every recruit carries the maximum traits its rarity allows, from its own roles' pool.
85
+ *
86
+ * COMMON is the exception: one role and no traits, so its packs are plain draws of the target role.
77
87
  */
88
+ /** The four roles a pack's target can be paired with, in a stable order. */
89
+ export function pairingsFor(role) {
90
+ return ANY_PACK_ROLES.filter((other) => other !== role);
91
+ }
78
92
  export function generatePack(country, rarity, packRole) {
79
93
  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));
94
+ const roll = (roles) => PlayerGenerator.generateEventPlayer(country, rarity, roles, skew);
95
+ // COMMON holds ONE role and no traits (owner 2026-09-03), so there is no second role to vary: the pack is
96
+ // PACK_SIZE draws of the target role and the choice is purely which stat line to take.
97
+ const single = rarity === RarityEnum.COMMON;
98
+ if (packRole === FREE_PACK_ROLE) {
99
+ // One recruit per position, so the choice is which position to add to. Each still gets a second role (drawn at
100
+ // random, since no pairing was targeted) unless the rarity is single-role.
101
+ return ANY_PACK_ROLES.map((role) => {
102
+ if (single)
103
+ return roll([role]);
104
+ const partners = pairingsFor(role);
105
+ return roll([role, partners[randomInt(0, partners.length)]]);
106
+ });
107
+ }
108
+ if (single)
109
+ return Array.from({ length: PACK_SIZE }, () => roll([packRole]));
110
+ // One recruit per pairing: a SETTER pack offers (S,L), (S,OH), (S,OP) and (S,MB), so the pick is which second
111
+ // role to take on. There are exactly four other roles, which is PACK_SIZE.
112
+ return pairingsFor(packRole).map((partner) => roll([packRole, partner]));
84
113
  }
85
114
  /** The role of the pack at `index`, or undefined once the draft is complete. */
86
115
  export function draftPackRole(index) {
@@ -1,31 +1,8 @@
1
1
  import { describe, it, expect } from '@jest/globals';
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';
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, pairingsFor } from './event-draft';
3
+ import { RarityEnum, RarityRanges, RoleEnum, StatsEnum, assignTraits, traitMap } 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
- }
29
6
  describe('DRAFT_PLAN', () => {
30
7
  it('drafts a 15-player squad, one pick per pack', () => {
31
8
  expect(DRAFT_SQUAD_SIZE).toBe(15);
@@ -94,39 +71,114 @@ describe('generatePack()', () => {
94
71
  });
95
72
  });
96
73
  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);
74
+ // The pairing already puts both of a recruit's specialities inside the rarity band, so skewing on top of that
75
+ // pinned the composites to the ceiling and half a pack came out maxed (owner 2026-09-03). Uniform keeps a pack
76
+ // spread across its band. The constant stays as the dial; the measured alternatives are in its comment.
77
+ it('draws every rarity uniformly across its band', () => {
78
+ for (const rarity of [RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL]) {
79
+ expect(EVENT_DRAFT_TOP_SKEW[rarity]).toBe(0);
80
+ }
81
+ });
82
+ it('rarely pins a composite to the top of the band, so a pack is not clustered at the ceiling', () => {
83
+ // The regression this guards: at skew 4 61% of dominant composites reached 88 and 13% hit the 89 ceiling, so
84
+ // half a pack came out maxed. Uniform measures ~6% at 88+ and ~0% maxed. A rate, not an absolute, because a
85
+ // uniform draw can of course still land on the ceiling now and then.
86
+ const [, max] = RarityRanges[RarityEnum.MYTHIC];
87
+ let composites = 0;
88
+ let nearMax = 0;
89
+ for (let i = 0; i < 300; i++) {
90
+ for (const p of generatePack(COUNTRY, RarityEnum.MYTHIC, RoleEnum.OUTSIDE_HITTER)) {
91
+ for (const s of p.generalStats) {
92
+ composites++;
93
+ if (s.value >= max - 1)
94
+ nearMax++;
95
+ }
96
+ }
97
+ }
98
+ expect(nearMax / composites).toBeLessThan(0.15);
99
+ });
100
+ });
101
+ describe('event recruits are role PAIRINGS (owner 2026-09-03)', () => {
102
+ const NON_COMMON = [RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL];
103
+ const ROLES = [RoleEnum.SETTER, RoleEnum.LIBERO, RoleEnum.OUTSIDE_HITTER, RoleEnum.OPPOSITE_HITTER, RoleEnum.MIDDLE_BLOCKER];
104
+ it.each(NON_COMMON)('a %s pack offers the target role paired with each of the four others, exactly once', (rarity) => {
105
+ for (const role of ROLES) {
106
+ const pack = generatePack(COUNTRY, rarity, role);
107
+ expect(pack).toHaveLength(PACK_SIZE);
108
+ for (const p of pack) {
109
+ expect(p.roles).toHaveLength(2);
110
+ expect(p.roles[0]).toBe(role); // the drafted role is always primary
111
+ }
112
+ // One recruit per partner, no repeats: the four options are different players, not four rolls of one.
113
+ const partners = pack.map(p => p.roles[1]);
114
+ expect(new Set(partners).size).toBe(PACK_SIZE);
115
+ expect([...partners].sort()).toEqual([...pairingsFor(role)].sort());
116
+ }
117
+ });
118
+ it.each(NON_COMMON)('gives every %s recruit the MAXIMUM traits its rarity allows, from its own roles pool', (rarity) => {
119
+ const expected = assignTraits([RoleEnum.SETTER], rarity, true).length;
120
+ expect(expected).toBeGreaterThan(0);
121
+ for (const role of ROLES) {
122
+ for (const p of generatePack(COUNTRY, rarity, role)) {
123
+ expect(p.traits).toHaveLength(expected);
124
+ // Every trait belongs to one of the two roles the player actually holds.
125
+ for (const t of p.traits) {
126
+ const def = traitMap.get(t);
127
+ expect(def?.roles.some((r) => p.roles.includes(r))).toBe(true);
128
+ }
129
+ }
130
+ }
131
+ });
132
+ // COMMON is the stated exception: one role, no traits, so there is no second role to vary.
133
+ it('a COMMON pack is single-role and trait-free', () => {
134
+ for (const role of ROLES) {
135
+ const pack = generatePack(COUNTRY, RarityEnum.COMMON, role);
136
+ expect(pack).toHaveLength(PACK_SIZE);
137
+ for (const p of pack) {
138
+ expect(p.roles).toEqual([role]);
139
+ expect(p.traits).toHaveLength(0);
140
+ }
141
+ }
142
+ });
143
+ it('the free pack still offers one recruit per position', () => {
144
+ const pack = generatePack(COUNTRY, RarityEnum.MYTHIC, FREE_PACK_ROLE);
145
+ expect(pack).toHaveLength(FREE_PACK_SIZE);
146
+ expect(new Set(pack.map(p => p.roles[0])).size).toBe(FREE_PACK_SIZE); // one per position
147
+ for (const p of pack)
148
+ expect(p.roles).toHaveLength(2); // each still picks up a second role
103
149
  });
104
150
  });
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));
122
- });
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);
151
+ describe('a pairing is SHARP: elite at both roles, with real holes', () => {
152
+ const N = 400;
153
+ // The stat groups each role is FOR, so a recruit can be checked against what it claims to be.
154
+ const DOMINANT = {
155
+ [RoleEnum.SETTER]: StatsEnum.SET,
156
+ [RoleEnum.LIBERO]: StatsEnum.RECEIVE,
157
+ [RoleEnum.OUTSIDE_HITTER]: StatsEnum.ATTACK,
158
+ [RoleEnum.OPPOSITE_HITTER]: StatsEnum.ATTACK,
159
+ [RoleEnum.MIDDLE_BLOCKER]: StatsEnum.BLOCK
160
+ };
161
+ const rating = (p, g) => p.generalStats.find(s => s.name === g)?.value ?? 0;
162
+ it('puts BOTH roles dominant ratings inside the rarity band', () => {
163
+ // The floor is the band MINIMUM, not its top fifth: a rating is a weighted blend, and a few of its inputs
164
+ // (ATTACK's focus and awareness, BLOCK's defense) are supporting stats for the role, which pulls the rating a
165
+ // little below the raw stats. What matters is that both of a pairing's specialities land in mythic territory.
166
+ const [min] = RarityRanges[RarityEnum.MYTHIC];
167
+ for (let i = 0; i < N; i++) {
168
+ for (const role of [RoleEnum.SETTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.MIDDLE_BLOCKER]) {
169
+ for (const p of generatePack(COUNTRY, RarityEnum.MYTHIC, role)) {
170
+ for (const held of p.roles)
171
+ expect(rating(p, DOMINANT[held])).toBeGreaterThanOrEqual(min);
172
+ }
173
+ }
174
+ }
175
+ });
176
+ it('leaves a real hole where NEITHER role reaches, so the four options are a genuine choice', () => {
177
+ // A (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and blocking and cannot set: neither role touches
178
+ // the SET group, so it keeps the near-common draw and lands far below the band.
179
+ const pack = generatePack(COUNTRY, RarityEnum.MYTHIC, RoleEnum.OUTSIDE_HITTER);
180
+ const withMiddle = pack.find(p => p.roles[1] === RoleEnum.MIDDLE_BLOCKER);
181
+ expect(withMiddle).toBeDefined();
182
+ expect(rating(withMiddle, StatsEnum.SET)).toBeLessThan(RarityRanges[RarityEnum.MYTHIC][0]);
131
183
  });
132
184
  });
@@ -42,6 +42,28 @@ 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
+ /**
46
+ * The stat block for an EVENT DRAFT recruit, built from the exact roles it will hold (owner 2026-09-03).
47
+ *
48
+ * Each role's DOMINANT rating group is drawn from the rarity band bent toward its top fifth, so the player is
49
+ * genuinely elite at what each of its roles is for. The roles' SUPPORTING groups drop two rarity tiers, so a
50
+ * pairing is sharp rather than good at everything: a (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and
51
+ * blocking, ordinary at receiving and serving, and has a real hole at setting. Anything neither role touches
52
+ * keeps the historic near-common draw, and stamina keeps its own per-rarity floor.
53
+ */
54
+ private static generateEventPerformance;
55
+ /**
56
+ * An EVENT DRAFT recruit (owner 2026-09-03). `roles` is exactly what the player will hold, `roles[0]` being the
57
+ * role the pack targeted; the draft offers one recruit per pairing so the choice is which second role to take.
58
+ *
59
+ * Traits are always the MAXIMUM the rarity allows, drawn from these roles' pool (assignTraits already filters by
60
+ * role), so an event recruit is a finished, interesting player rather than a stat line. A COMMON holds one role
61
+ * and, by that same table, no traits.
62
+ *
63
+ * Deliberately separate from generatePlayer: the gacha, bot teams and the seeds keep their own generation
64
+ * untouched. This shape is for events only.
65
+ */
66
+ static generateEventPlayer(country: Country, rarity: Rarity, roles: Role[], topSkew?: number, birthIteration?: number): Player;
45
67
  static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
46
68
  static generatePlayers(count: number, countries: Country[]): Player[];
47
69
  }
@@ -1,8 +1,8 @@
1
1
  import { v4 as uuidv4 } from 'uuid';
2
2
  import { randomInt } from 'node:crypto';
3
- import { RarityEnum, RarityRanges, StaminaRanges } from './rarity';
3
+ import { RarityEnum, RarityRanges, rarityBelow, StaminaRanges } from './rarity';
4
4
  import { performanceStatKeys, PerformanceStats } from './performance-stats';
5
- import { assignRoles } from './role';
5
+ import { assignRoles, roleStatTiers } from './role';
6
6
  import { getMultipliers, StatsEnum } from './stats';
7
7
  import { rolesJSON } from '../../stat-config';
8
8
  import { shuffle, generatePlayerName } from '../utils';
@@ -311,6 +311,51 @@ export class PlayerGenerator {
311
311
  }
312
312
  }
313
313
  }
314
+ /**
315
+ * The stat block for an EVENT DRAFT recruit, built from the exact roles it will hold (owner 2026-09-03).
316
+ *
317
+ * Each role's DOMINANT rating group is drawn from the rarity band bent toward its top fifth, so the player is
318
+ * genuinely elite at what each of its roles is for. The roles' SUPPORTING groups drop two rarity tiers, so a
319
+ * pairing is sharp rather than good at everything: a (OUTSIDE_HITTER, MIDDLE_BLOCKER) is elite at attacking and
320
+ * blocking, ordinary at receiving and serving, and has a real hole at setting. Anything neither role touches
321
+ * keeps the historic near-common draw, and stamina keeps its own per-rarity floor.
322
+ */
323
+ static generateEventPerformance(rarity, roles, topSkew) {
324
+ const [min, max] = RarityRanges[rarity];
325
+ const [supportMin, supportMax] = RarityRanges[rarityBelow(rarity, 2)];
326
+ const { dominant, supporting } = roleStatTiers(roles);
327
+ return PerformanceStats.create(Object.fromEntries(performanceStatKeys.map((key) => {
328
+ if (key === 'stamina')
329
+ return [key, rollStamina(rarity)];
330
+ if (dominant.has(key))
331
+ return [key, randomIntSkewed(min, max, topSkew)];
332
+ if (supporting.has(key))
333
+ return [key, randomInt(supportMin, supportMax + 1)];
334
+ return [key, randomInt(RarityRanges.COMMON[0], Math.max(min - 1, RarityRanges.COMMON[0] + 1))];
335
+ })));
336
+ }
337
+ /**
338
+ * An EVENT DRAFT recruit (owner 2026-09-03). `roles` is exactly what the player will hold, `roles[0]` being the
339
+ * role the pack targeted; the draft offers one recruit per pairing so the choice is which second role to take.
340
+ *
341
+ * Traits are always the MAXIMUM the rarity allows, drawn from these roles' pool (assignTraits already filters by
342
+ * role), so an event recruit is a finished, interesting player rather than a stat line. A COMMON holds one role
343
+ * and, by that same table, no traits.
344
+ *
345
+ * Deliberately separate from generatePlayer: the gacha, bot teams and the seeds keep their own generation
346
+ * untouched. This shape is for events only.
347
+ */
348
+ static generateEventPlayer(country, rarity, roles, topSkew = 0, birthIteration = 1) {
349
+ if (roles.length === 0)
350
+ throw new Error('EVENT_PLAYER_NEEDS_A_ROLE');
351
+ const name = generatePlayerName(country.locales, 1)[0];
352
+ const stats = PlayerGenerator.generateEventPerformance(rarity, roles, topSkew);
353
+ const traits = assignTraits(roles, rarity, true);
354
+ const birthAge = randomInt(15, 21);
355
+ const declineProfile = declineProfiles[randomInt(0, declineProfiles.length)];
356
+ const injuryProneProfile = randomInt(0, 101) / 100;
357
+ return Player.create({ id: uuidv4(), name, country, stats, roles, traits, rarity, age: birthAge, birthAge, birthIteration, declineProfile, injuryProneProfile });
358
+ }
314
359
  static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
315
360
  const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
316
361
  const name = generatePlayerName(country.locales, 1)[0];
@@ -13,6 +13,10 @@ export declare const RarityRanges: {
13
13
  MYTHIC: number[];
14
14
  SPECIAL: number[];
15
15
  };
16
+ /** The rarity ladder, lowest first. Used to step a band down a tier. */
17
+ export declare const RARITY_LADDER: Rarity[];
18
+ /** `steps` tiers below `rarity`, floored at COMMON. */
19
+ export declare function rarityBelow(rarity: Rarity, steps: number): Rarity;
16
20
  export declare const StaminaRanges: {
17
21
  COMMON: number[];
18
22
  RARE: number[];
@@ -13,6 +13,15 @@ export const RarityRanges = {
13
13
  [RarityEnum.MYTHIC]: [80, 89],
14
14
  [RarityEnum.SPECIAL]: [90, 99]
15
15
  };
16
+ /** The rarity ladder, lowest first. Used to step a band down a tier. */
17
+ export const RARITY_LADDER = [
18
+ RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL
19
+ ];
20
+ /** `steps` tiers below `rarity`, floored at COMMON. */
21
+ export function rarityBelow(rarity, steps) {
22
+ const i = RARITY_LADDER.indexOf(rarity);
23
+ return RARITY_LADDER[Math.max(0, i - steps)];
24
+ }
16
25
  // Stamina has its own per-rarity range with a guaranteed floor, applied identically in both the
17
26
  // role-weighted and the generic generation paths (stamina is otherwise a secondary stat that would
18
27
  // bottom out near 1 in the generic path). Floors: RARE 15, LEGENDARY 30, MYTHIC 45, SPECIAL 60.
@@ -1,4 +1,5 @@
1
- import { PerformanceStats, PerformanceStatsParams } from './performance-stats';
1
+ import { PerformanceStats, PerformanceStatsKey, PerformanceStatsParams } from './performance-stats';
2
+ import { Stats } from './stats';
2
3
  import { Rarity } from './rarity';
3
4
  export declare enum RoleEnum {
4
5
  SETTER = "SETTER",
@@ -14,6 +15,23 @@ export interface RolesFormula {
14
15
  weight: PerformanceStatsParams;
15
16
  }
16
17
  export declare function calculateRoleScore(stats: PerformanceStats, role: Role): number;
18
+ /**
19
+ * How much a role cares about each rating group: the sum of its roles.json weights over the stats inside that
20
+ * group. An outside hitter is spike 0.3 + backAttack 0.1 = 0.4 on ATTACK, then 0.2 on RECEIVE and 0.2 on BLOCK,
21
+ * so ATTACK is what it is FOR. Groups the role has no weight in are absent.
22
+ */
23
+ export declare function roleGroupWeights(role: Role): Map<Stats, number>;
24
+ /**
25
+ * Split the stats of a role SET into the ones the players are FOR and the ones they merely support.
26
+ *
27
+ * A role's DOMINANT group is the one it weighs most (a middle blocker's BLOCK, a libero's RECEIVE); every stat
28
+ * feeding it is a dominant stat. The role's other groups are supporting. Union across the given roles, dominant
29
+ * winning any overlap, so a two-role player is elite at both roles' specialities and merely useful around them.
30
+ */
31
+ export declare function roleStatTiers(roles: Role[]): {
32
+ dominant: Set<PerformanceStatsKey>;
33
+ supporting: Set<PerformanceStatsKey>;
34
+ };
17
35
  export declare function assignRoles(stats: PerformanceStats, rarity: Rarity, forcedRole?: Role): Role[];
18
36
  /**
19
37
  * Returns the role-based multiplier for a player performing a given action.
@@ -1,3 +1,4 @@
1
+ import { getMultipliers, StatsEnum } from './stats';
1
2
  import { RarityEnum } from './rarity';
2
3
  import roles from '../../stat-config/roles.json';
3
4
  export var RoleEnum;
@@ -33,6 +34,54 @@ function getRoleCountRange(rarity) {
33
34
  default: return [1, 1];
34
35
  }
35
36
  }
37
+ /** The five rating groups a role can care about (STAMINA is excluded: it has its own per-rarity floor). */
38
+ const RATING_GROUPS = [StatsEnum.ATTACK, StatsEnum.SET, StatsEnum.RECEIVE, StatsEnum.SERVE, StatsEnum.BLOCK];
39
+ /**
40
+ * How much a role cares about each rating group: the sum of its roles.json weights over the stats inside that
41
+ * group. An outside hitter is spike 0.3 + backAttack 0.1 = 0.4 on ATTACK, then 0.2 on RECEIVE and 0.2 on BLOCK,
42
+ * so ATTACK is what it is FOR. Groups the role has no weight in are absent.
43
+ */
44
+ export function roleGroupWeights(role) {
45
+ const weights = rolesFormula.find(r => r.role === role)?.weight;
46
+ if (weights == null)
47
+ throw new Error('COULD_NOT_FIND_ROLE_WEIGHT');
48
+ const out = new Map();
49
+ for (const group of RATING_GROUPS) {
50
+ const total = Object.keys(getMultipliers(group))
51
+ .reduce((sum, key) => sum + (key === 'stamina' ? 0 : (weights[key] ?? 0)), 0);
52
+ if (total > 0)
53
+ out.set(group, total);
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Split the stats of a role SET into the ones the players are FOR and the ones they merely support.
59
+ *
60
+ * A role's DOMINANT group is the one it weighs most (a middle blocker's BLOCK, a libero's RECEIVE); every stat
61
+ * feeding it is a dominant stat. The role's other groups are supporting. Union across the given roles, dominant
62
+ * winning any overlap, so a two-role player is elite at both roles' specialities and merely useful around them.
63
+ */
64
+ export function roleStatTiers(roles) {
65
+ const dominant = new Set();
66
+ const supporting = new Set();
67
+ for (const role of roles) {
68
+ const groups = roleGroupWeights(role);
69
+ const best = Math.max(...groups.values());
70
+ for (const [group, weight] of groups) {
71
+ for (const key of Object.keys(getMultipliers(group))) {
72
+ if (key === 'stamina')
73
+ continue;
74
+ if (weight === best)
75
+ dominant.add(key);
76
+ else
77
+ supporting.add(key);
78
+ }
79
+ }
80
+ }
81
+ for (const key of dominant)
82
+ supporting.delete(key);
83
+ return { dominant, supporting };
84
+ }
36
85
  export function assignRoles(stats, rarity, forcedRole) {
37
86
  const scores = Object.values(RoleEnum)
38
87
  .map((role) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.531",
3
+ "version": "0.0.532",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",