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.
- package/dist/cjs/src/service/competition/event-draft.d.ts +13 -19
- package/dist/cjs/src/service/competition/event-draft.js +22 -39
- package/dist/cjs/src/service/competition/event-draft.test.js +61 -34
- package/dist/cjs/src/service/player/player-generator.d.ts +1 -1
- package/dist/cjs/src/service/player/player-generator.js +21 -4
- package/dist/esm/src/service/competition/event-draft.d.ts +13 -19
- package/dist/esm/src/service/competition/event-draft.js +22 -38
- package/dist/esm/src/service/competition/event-draft.test.js +63 -36
- package/dist/esm/src/service/player/player-generator.d.ts +1 -1
- package/dist/esm/src/service/player/player-generator.js +21 -4
- package/package.json +1 -1
|
@@ -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
|
|
18
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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,
|
|
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.
|
|
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
|
|
32
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
34
33
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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,
|
|
57
|
+
function generatePack(country, rarity, packRole, pickRole) {
|
|
64
58
|
const role = packRole === 'ANY' ? pickRole(ANY_PACK_ROLES) : packRole;
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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)('
|
|
57
|
-
const
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
(0, globals_1.expect)(
|
|
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)('
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
(
|
|
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)('
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
(
|
|
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, (
|
|
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
|
|
18
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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,
|
|
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,
|
|
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
|
|
26
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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,
|
|
51
|
+
export function generatePack(country, rarity, packRole, pickRole) {
|
|
57
52
|
const role = packRole === 'ANY' ? pickRole(ANY_PACK_ROLES) : packRole;
|
|
58
|
-
const
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
55
|
-
const
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(
|
|
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('
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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('
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
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,
|
|
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) => {
|