volleyballsimtypes 0.0.492 → 0.0.493

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.
@@ -15,32 +15,26 @@ export declare const DRAFT_PLAN: readonly DraftRole[];
15
15
  /** The size of a drafted squad: one pick per pack. */
16
16
  export declare const DRAFT_SQUAD_SIZE: number;
17
17
  /**
18
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
18
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
19
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.
20
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
21
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
22
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
23
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
24
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
25
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
26
+ * material is comparable in one draw.
23
27
  */
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
- }
28
+ export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
30
29
  /**
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
- */
35
- export declare function packQualityBand(country: Country, rarity: Rarity, role: Role, sampleSize?: number): PackQualityBand;
36
- /**
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.
30
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
31
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
32
+ * are left uniform.
39
33
  *
40
34
  * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
41
35
  * randomness (and a test can make it deterministic).
42
36
  */
43
- export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, band: (role: Role) => PackQualityBand, pickRole: (roles: readonly Role[]) => Role): Player[];
37
+ export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, pickRole: (roles: readonly Role[]) => Role): Player[];
44
38
  /** The role of the pack at `index`, or undefined once the draft is complete. */
45
39
  export declare function draftPackRole(index: number): DraftRole | undefined;
46
40
  export declare function isDraftComplete(picks: number): boolean;
@@ -1,7 +1,6 @@
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.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = void 0;
5
4
  exports.generatePack = generatePack;
6
5
  exports.draftPackRole = draftPackRole;
7
6
  exports.isDraftComplete = isDraftComplete;
@@ -30,51 +29,35 @@ const ANY_PACK_ROLES = [
30
29
  player_1.RoleEnum.SETTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.LIBERO
31
30
  ];
32
31
  /**
33
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
32
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
34
33
  *
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.
34
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
35
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
36
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
37
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
38
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
39
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
40
+ * material is comparable in one draw.
38
41
  */
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
+ exports.EVENT_DRAFT_TOP_SKEW = {
43
+ [player_1.RarityEnum.COMMON]: 2,
44
+ [player_1.RarityEnum.RARE]: 2,
45
+ [player_1.RarityEnum.LEGENDARY]: 0,
46
+ [player_1.RarityEnum.MYTHIC]: 0,
47
+ [player_1.RarityEnum.SPECIAL]: 0
48
+ };
42
49
  /**
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
- }
56
- /**
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.
50
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
51
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
52
+ * are left uniform.
59
53
  *
60
54
  * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
61
55
  * randomness (and a test can make it deterministic).
62
56
  */
63
- function generatePack(country, rarity, packRole, band, pickRole) {
57
+ function generatePack(country, rarity, packRole, pickRole) {
64
58
  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;
59
+ const skew = exports.EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
60
+ return Array.from({ length: exports.PACK_SIZE }, () => player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew));
78
61
  }
79
62
  /** The role of the pack at `index`, or undefined once the draft is complete. */
80
63
  function draftPackRole(index) {
@@ -5,6 +5,30 @@ 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
+ const first = (roles) => roles[0];
9
+ // Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
10
+ // off the flaky edge while still failing loudly if the skew were removed.
11
+ function median(values) {
12
+ const sorted = [...values].sort((a, b) => a - b);
13
+ return sorted[Math.floor(sorted.length / 2)];
14
+ }
15
+ function percentile(values, p) {
16
+ const sorted = [...values].sort((a, b) => a - b);
17
+ return sorted[Math.floor((sorted.length - 1) * p)];
18
+ }
19
+ /** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
20
+ function draftScores(rarity, role, n) {
21
+ const out = [];
22
+ while (out.length < n) {
23
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role, first))
24
+ out.push((0, player_1.calculateRoleScore)(p.stats, role));
25
+ }
26
+ return out.slice(0, n);
27
+ }
28
+ /** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
29
+ function uniformScores(rarity, role, n) {
30
+ return Array.from({ length: n }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
31
+ }
8
32
  (0, globals_1.describe)('DRAFT_PLAN', () => {
9
33
  (0, globals_1.it)('drafts a full 14-player squad, one pick per pack', () => {
10
34
  (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(14);
@@ -39,53 +63,56 @@ const COUNTRY = (0, test_helpers_1.makeCountry)();
39
63
  });
40
64
  });
41
65
  (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) => {
66
+ 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
67
  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);
68
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role, first);
47
69
  (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
48
70
  for (const player of pack) {
49
71
  (0, globals_1.expect)(player.rarity).toBe(rarity);
50
72
  (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
73
  }
55
74
  });
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);
65
- });
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);
75
+ (0, globals_1.it)('rolls a free (ANY) pack at the role it is handed', () => {
76
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, 'ANY', () => player_1.RoleEnum.LIBERO);
69
77
  (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
70
78
  for (const player of pack)
71
79
  (0, globals_1.expect)(player.roles).toContain(player_1.RoleEnum.LIBERO);
72
80
  });
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);
81
+ });
82
+ (0, globals_1.describe)('EVENT_DRAFT_TOP_SKEW', () => {
83
+ (0, globals_1.it)('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
84
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.COMMON]).toBeGreaterThan(0);
85
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.RARE]).toBeGreaterThan(0);
86
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.LEGENDARY]).toBe(0);
87
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.MYTHIC]).toBe(0);
88
+ (0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.SPECIAL]).toBe(0);
78
89
  });
79
90
  });
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);
91
+ (0, globals_1.describe)('draft skew behaviour', () => {
92
+ const N = 2500;
93
+ const role = player_1.RoleEnum.OUTSIDE_HITTER;
94
+ 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) => {
95
+ const uniform = uniformScores(rarity, role, N);
96
+ const drafted = draftScores(rarity, role, N);
97
+ // The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
98
+ const topFifthFloor = percentile(uniform, 0.80);
99
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
100
+ // A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
101
+ (0, globals_1.expect)(draftedInTopFifth).toBeGreaterThan(0.45);
102
+ // The typical drafted recruit clearly outscores the typical uniform one.
103
+ (0, globals_1.expect)(median(drafted)).toBeGreaterThan(median(uniform));
104
+ // But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
105
+ // below the best in the sample rather than everyone landing on the ceiling.
106
+ (0, globals_1.expect)(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
107
+ (0, globals_1.expect)(median(drafted)).toBeLessThan(Math.max(...drafted));
85
108
  });
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);
109
+ (0, globals_1.it)('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
110
+ const uniform = uniformScores(player_1.RarityEnum.LEGENDARY, role, N);
111
+ const drafted = draftScores(player_1.RarityEnum.LEGENDARY, role, N);
112
+ const topFifthFloor = percentile(uniform, 0.80);
113
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
114
+ // Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
115
+ // >45% the skewed lower rarities reach.
116
+ (0, globals_1.expect)(draftedInTopFifth).toBeLessThan(0.35);
90
117
  });
91
118
  });
@@ -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) => {
@@ -15,32 +15,26 @@ export declare const DRAFT_PLAN: readonly DraftRole[];
15
15
  /** The size of a drafted squad: one pick per pack. */
16
16
  export declare const DRAFT_SQUAD_SIZE: number;
17
17
  /**
18
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
18
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
19
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.
20
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
21
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
22
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
23
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
24
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
25
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
26
+ * material is comparable in one draw.
23
27
  */
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
- }
28
+ export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
30
29
  /**
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
- */
35
- export declare function packQualityBand(country: Country, rarity: Rarity, role: Role, sampleSize?: number): PackQualityBand;
36
- /**
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.
30
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
31
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
32
+ * are left uniform.
39
33
  *
40
34
  * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
41
35
  * randomness (and a test can make it deterministic).
42
36
  */
43
- export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, band: (role: Role) => PackQualityBand, pickRole: (roles: readonly Role[]) => Role): Player[];
37
+ export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, pickRole: (roles: readonly Role[]) => Role): Player[];
44
38
  /** The role of the pack at `index`, or undefined once the draft is complete. */
45
39
  export declare function draftPackRole(index: number): DraftRole | undefined;
46
40
  export declare function isDraftComplete(picks: number): boolean;
@@ -1,4 +1,4 @@
1
- import { PlayerGenerator, RoleEnum, calculateRoleScore } from '../player';
1
+ import { PlayerGenerator, RarityEnum, RoleEnum } from '../player';
2
2
  /** How many players an opened pack offers. */
3
3
  export const PACK_SIZE = 4;
4
4
  /**
@@ -23,51 +23,35 @@ const ANY_PACK_ROLES = [
23
23
  RoleEnum.SETTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.OPPOSITE_HITTER, RoleEnum.LIBERO
24
24
  ];
25
25
  /**
26
- * How far a pack's players may sit from the median for their rarity, as a fraction of the role score.
26
+ * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
27
27
  *
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.
28
+ * Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
29
+ * fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
30
+ * legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
31
+ * plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
32
+ * its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
33
+ * player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
34
+ * material is comparable in one draw.
31
35
  */
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;
36
+ export const EVENT_DRAFT_TOP_SKEW = {
37
+ [RarityEnum.COMMON]: 2,
38
+ [RarityEnum.RARE]: 2,
39
+ [RarityEnum.LEGENDARY]: 0,
40
+ [RarityEnum.MYTHIC]: 0,
41
+ [RarityEnum.SPECIAL]: 0
42
+ };
35
43
  /**
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
- }
49
- /**
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.
44
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
45
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
46
+ * are left uniform.
52
47
  *
53
48
  * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
54
49
  * randomness (and a test can make it deterministic).
55
50
  */
56
- export function generatePack(country, rarity, packRole, band, pickRole) {
51
+ export function generatePack(country, rarity, packRole, pickRole) {
57
52
  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;
53
+ const skew = EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
54
+ return Array.from({ length: PACK_SIZE }, () => PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew));
71
55
  }
72
56
  /** The role of the pack at `index`, or undefined once the draft is complete. */
73
57
  export function draftPackRole(index) {
@@ -1,8 +1,32 @@
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, PACK_SIZE, 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
+ const first = (roles) => roles[0];
7
+ // Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
8
+ // off the flaky edge while still failing loudly if the skew were removed.
9
+ function median(values) {
10
+ const sorted = [...values].sort((a, b) => a - b);
11
+ return sorted[Math.floor(sorted.length / 2)];
12
+ }
13
+ function percentile(values, p) {
14
+ const sorted = [...values].sort((a, b) => a - b);
15
+ return sorted[Math.floor((sorted.length - 1) * p)];
16
+ }
17
+ /** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
18
+ function draftScores(rarity, role, n) {
19
+ const out = [];
20
+ while (out.length < n) {
21
+ for (const p of generatePack(COUNTRY, rarity, role, first))
22
+ out.push(calculateRoleScore(p.stats, role));
23
+ }
24
+ return out.slice(0, n);
25
+ }
26
+ /** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
27
+ function uniformScores(rarity, role, n) {
28
+ return Array.from({ length: n }, () => calculateRoleScore(PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
29
+ }
6
30
  describe('DRAFT_PLAN', () => {
7
31
  it('drafts a full 14-player squad, one pick per pack', () => {
8
32
  expect(DRAFT_SQUAD_SIZE).toBe(14);
@@ -37,53 +61,56 @@ describe('DRAFT_PLAN', () => {
37
61
  });
38
62
  });
39
63
  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) => {
64
+ it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
42
65
  const role = RoleEnum.OUTSIDE_HITTER;
43
- const band = packQualityBand(COUNTRY, rarity, role);
44
- const pack = generatePack(COUNTRY, rarity, role, () => band, first);
66
+ const pack = generatePack(COUNTRY, rarity, role, first);
45
67
  expect(pack).toHaveLength(PACK_SIZE);
46
68
  for (const player of pack) {
47
69
  expect(player.rarity).toBe(rarity);
48
70
  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
71
  }
53
72
  });
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);
63
- });
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);
73
+ it('rolls a free (ANY) pack at the role it is handed', () => {
74
+ const pack = generatePack(COUNTRY, RarityEnum.COMMON, 'ANY', () => RoleEnum.LIBERO);
67
75
  expect(pack).toHaveLength(PACK_SIZE);
68
76
  for (const player of pack)
69
77
  expect(player.roles).toContain(RoleEnum.LIBERO);
70
78
  });
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);
79
+ });
80
+ describe('EVENT_DRAFT_TOP_SKEW', () => {
81
+ it('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
82
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.COMMON]).toBeGreaterThan(0);
83
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.RARE]).toBeGreaterThan(0);
84
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.LEGENDARY]).toBe(0);
85
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.MYTHIC]).toBe(0);
86
+ expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.SPECIAL]).toBe(0);
76
87
  });
77
88
  });
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);
89
+ describe('draft skew behaviour', () => {
90
+ const N = 2500;
91
+ const role = RoleEnum.OUTSIDE_HITTER;
92
+ 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) => {
93
+ const uniform = uniformScores(rarity, role, N);
94
+ const drafted = draftScores(rarity, role, N);
95
+ // The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
96
+ const topFifthFloor = percentile(uniform, 0.80);
97
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
98
+ // A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
99
+ expect(draftedInTopFifth).toBeGreaterThan(0.45);
100
+ // The typical drafted recruit clearly outscores the typical uniform one.
101
+ expect(median(drafted)).toBeGreaterThan(median(uniform));
102
+ // But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
103
+ // below the best in the sample rather than everyone landing on the ceiling.
104
+ expect(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
105
+ expect(median(drafted)).toBeLessThan(Math.max(...drafted));
83
106
  });
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);
107
+ it('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
108
+ const uniform = uniformScores(RarityEnum.LEGENDARY, role, N);
109
+ const drafted = draftScores(RarityEnum.LEGENDARY, role, N);
110
+ const topFifthFloor = percentile(uniform, 0.80);
111
+ const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
112
+ // Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
113
+ // >45% the skewed lower rarities reach.
114
+ expect(draftedInTopFifth).toBeLessThan(0.35);
88
115
  });
89
116
  });
@@ -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.493",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",