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.
- package/dist/cjs/src/service/competition/event-draft.d.ts +23 -12
- package/dist/cjs/src/service/competition/event-draft.js +48 -18
- package/dist/cjs/src/service/competition/event-draft.test.js +107 -55
- package/dist/cjs/src/service/player/player-generator.d.ts +22 -0
- package/dist/cjs/src/service/player/player-generator.js +45 -0
- package/dist/cjs/src/service/player/rarity.d.ts +4 -0
- package/dist/cjs/src/service/player/rarity.js +11 -1
- package/dist/cjs/src/service/player/role.d.ts +19 -1
- package/dist/cjs/src/service/player/role.js +51 -0
- package/dist/esm/src/service/competition/event-draft.d.ts +23 -12
- package/dist/esm/src/service/competition/event-draft.js +47 -18
- package/dist/esm/src/service/competition/event-draft.test.js +109 -57
- package/dist/esm/src/service/player/player-generator.d.ts +22 -0
- package/dist/esm/src/service/player/player-generator.js +47 -2
- package/dist/esm/src/service/player/rarity.d.ts +4 -0
- package/dist/esm/src/service/player/rarity.js +9 -0
- package/dist/esm/src/service/player/role.d.ts +19 -1
- package/dist/esm/src/service/player/role.js +49 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
47
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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]:
|
|
72
|
-
[player_1.RarityEnum.RARE]:
|
|
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.
|
|
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
|
-
*
|
|
82
|
-
* the
|
|
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 = (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
(
|
|
104
|
-
|
|
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)('
|
|
108
|
-
const N =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
// below the
|
|
122
|
-
|
|
123
|
-
(0
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
47
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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]:
|
|
66
|
-
[RarityEnum.RARE]:
|
|
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.
|
|
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
|
-
*
|
|
76
|
-
* the
|
|
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 = (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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('
|
|
106
|
-
const N =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
// below the
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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) => ({
|