volleyballsimtypes 0.0.444 → 0.0.447
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/data/transformers/tactics.js +18 -2
- package/dist/cjs/src/service/player/stats.js +2 -2
- package/dist/cjs/src/service/player/stats.test.js +63 -0
- package/dist/cjs/src/stat-config/traits.json +5 -4
- package/dist/esm/src/data/transformers/tactics.js +18 -2
- package/dist/esm/src/service/player/stats.js +2 -2
- package/dist/esm/src/service/player/stats.test.js +63 -0
- package/dist/esm/src/stat-config/traits.json +5 -4
- package/package.json +1 -1
|
@@ -105,6 +105,16 @@ function transformToObject(model, roster) {
|
|
|
105
105
|
const secondLibero = (hasStartingLibero && model.second_libero != null && !inLineupIds.has(model.second_libero))
|
|
106
106
|
? roster.find((p) => p.id === model.second_libero)
|
|
107
107
|
: undefined;
|
|
108
|
+
// A persisted `libero_replacement_sets` whose entries are ALL empty (a stale [[],[],[],[],[]] left by an
|
|
109
|
+
// older "configure each set independently" session that a later "apply to all sets" save could not clear --
|
|
110
|
+
// the pre-fix write omitted the column instead of nulling it) silently benches the libero: the sim picks
|
|
111
|
+
// `sets?.[i] ?? liberoReplacements`, and an empty array is not nullish, so every set gets an empty target
|
|
112
|
+
// list and the libero never enters the court. Treat an all-empty per-set list as absent so the sim falls
|
|
113
|
+
// back to the flat `liberoReplacements`; the next tactics save rewrites the column cleanly.
|
|
114
|
+
const mappedLiberoReplacementSets = model.libero_replacement_sets?.map(set => set.map((id) => findPlayer(id, roster)));
|
|
115
|
+
const liberoReplacementSets = mappedLiberoReplacementSets?.some(set => set.length > 0) === true
|
|
116
|
+
? mappedLiberoReplacementSets
|
|
117
|
+
: undefined;
|
|
108
118
|
return service_1.Tactics.create({
|
|
109
119
|
receiveRotationOffset: model.receive_rotation_offset,
|
|
110
120
|
lineup,
|
|
@@ -112,7 +122,7 @@ function transformToObject(model, roster) {
|
|
|
112
122
|
substitutionBand: model.substitution_band,
|
|
113
123
|
secondLibero,
|
|
114
124
|
liberoSub: secondLibero != null ? (model.libero_sub ?? undefined) : undefined,
|
|
115
|
-
liberoReplacementSets
|
|
125
|
+
liberoReplacementSets,
|
|
116
126
|
designatedSubs: (model.designated_subs ?? []).map(d => ({
|
|
117
127
|
starter: findPlayer(d.starterId, roster),
|
|
118
128
|
bench: d.benchId != null ? findPlayer(d.benchId, roster) : undefined,
|
|
@@ -160,6 +170,12 @@ function tacticsColumnsToApiDto(model) {
|
|
|
160
170
|
const validSecondLibero = (model.second_libero != null && model.lineup[service_1.CourtPosition.LIBERO_ZONE] != null && !lineupIds.has(model.second_libero))
|
|
161
171
|
? model.second_libero
|
|
162
172
|
: undefined;
|
|
173
|
+
// Same all-empty heal as transformToObject: an all-empty per-set `libero_replacement_sets` is a stale
|
|
174
|
+
// artifact that shadows the flat `liberoReplacements` in the sim, so don't surface it to the API/preset
|
|
175
|
+
// shape -- otherwise the UI would reload it as "configure each set independently" (all blank) and re-emit it.
|
|
176
|
+
const validLiberoReplacementSets = model.libero_replacement_sets?.some(set => set.length > 0) === true
|
|
177
|
+
? model.libero_replacement_sets
|
|
178
|
+
: undefined;
|
|
163
179
|
return {
|
|
164
180
|
lineup: model.lineup,
|
|
165
181
|
liberoReplacements: model.libero_replacements,
|
|
@@ -167,7 +183,7 @@ function tacticsColumnsToApiDto(model) {
|
|
|
167
183
|
substitutionBand: model.substitution_band,
|
|
168
184
|
secondLibero: validSecondLibero,
|
|
169
185
|
liberoSub: validSecondLibero != null ? model.libero_sub : undefined,
|
|
170
|
-
liberoReplacementSets:
|
|
186
|
+
liberoReplacementSets: validLiberoReplacementSets,
|
|
171
187
|
designatedSubs: (model.designated_subs ?? []).map(d => ({
|
|
172
188
|
starterId: d.starterId,
|
|
173
189
|
benchId: d.benchId,
|
|
@@ -24,7 +24,7 @@ function getMultipliers(statName, performanceStats) {
|
|
|
24
24
|
return {
|
|
25
25
|
spike: 0.55,
|
|
26
26
|
backAttack: 0.1,
|
|
27
|
-
power: performanceStats == null ? 0.125 : performanceStats.power
|
|
27
|
+
power: performanceStats == null ? 0.125 : performanceStats.power >= performanceStats.quick ? 0.2 : 0.05,
|
|
28
28
|
quick: performanceStats == null ? 0.125 : performanceStats.quick > performanceStats.power ? 0.2 : 0.05,
|
|
29
29
|
awareness: 0.05,
|
|
30
30
|
attack: 0.05
|
|
@@ -54,7 +54,7 @@ function getMultipliers(statName, performanceStats) {
|
|
|
54
54
|
case StatsEnum.BLOCK:
|
|
55
55
|
return {
|
|
56
56
|
block: 0.65,
|
|
57
|
-
read: performanceStats == null ? 0.125 : performanceStats.read
|
|
57
|
+
read: performanceStats == null ? 0.125 : performanceStats.read >= performanceStats.commit ? 0.2 : 0.05,
|
|
58
58
|
commit: performanceStats == null ? 0.125 : performanceStats.commit > performanceStats.read ? 0.2 : 0.05,
|
|
59
59
|
focus: 0.04,
|
|
60
60
|
defense: 0.04,
|
|
@@ -33,6 +33,18 @@ function makeStats(base = 50, overrides = {}) {
|
|
|
33
33
|
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
34
34
|
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
35
35
|
});
|
|
36
|
+
// Regression: on a tie the `>` comparisons used to give BOTH sides 0.05 (pair = 0.10), dropping the stat's
|
|
37
|
+
// weights to 0.85. With `>=` the higher-or-equal side keeps 0.2 so the pair is 0.25 and the total stays 1.
|
|
38
|
+
(0, globals_1.it)('on a power === quick tie, power keeps 0.2 and quick 0.05 (pair sums to 0.25)', () => {
|
|
39
|
+
const m = (0, stats_1.getMultipliers)(stats_1.StatsEnum.ATTACK, makeStats(50, { power: 50, quick: 50 }));
|
|
40
|
+
(0, globals_1.expect)(m.power).toBe(0.2);
|
|
41
|
+
(0, globals_1.expect)(m.quick).toBe(0.05);
|
|
42
|
+
});
|
|
43
|
+
(0, globals_1.it)('multipliers sum to 1 on a power === quick tie', () => {
|
|
44
|
+
const m = (0, stats_1.getMultipliers)(stats_1.StatsEnum.ATTACK, makeStats(50, { power: 50, quick: 50 }));
|
|
45
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
46
|
+
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
47
|
+
});
|
|
36
48
|
});
|
|
37
49
|
(0, globals_1.describe)('SET', () => {
|
|
38
50
|
(0, globals_1.it)('multipliers sum to 1', () => {
|
|
@@ -91,6 +103,17 @@ function makeStats(base = 50, overrides = {}) {
|
|
|
91
103
|
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
92
104
|
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
93
105
|
});
|
|
106
|
+
// Regression: same tie bug as ATTACK's power/quick. On read === commit both used to fall to 0.05.
|
|
107
|
+
(0, globals_1.it)('on a read === commit tie, read keeps 0.2 and commit 0.05 (pair sums to 0.25)', () => {
|
|
108
|
+
const m = (0, stats_1.getMultipliers)(stats_1.StatsEnum.BLOCK, makeStats(50, { read: 50, commit: 50 }));
|
|
109
|
+
(0, globals_1.expect)(m.read).toBe(0.2);
|
|
110
|
+
(0, globals_1.expect)(m.commit).toBe(0.05);
|
|
111
|
+
});
|
|
112
|
+
(0, globals_1.it)('multipliers sum to 1 on a read === commit tie', () => {
|
|
113
|
+
const m = (0, stats_1.getMultipliers)(stats_1.StatsEnum.BLOCK, makeStats(50, { read: 50, commit: 50 }));
|
|
114
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
115
|
+
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
116
|
+
});
|
|
94
117
|
});
|
|
95
118
|
(0, globals_1.describe)('STAMINA', () => {
|
|
96
119
|
(0, globals_1.it)('returns exactly { stamina: 1 }', () => {
|
|
@@ -131,3 +154,43 @@ function makeStats(base = 50, overrides = {}) {
|
|
|
131
154
|
(0, globals_1.expect)((0, stats_1.calculateStatScore)(stats, stats_1.StatsEnum.SERVE)).toBe(84);
|
|
132
155
|
});
|
|
133
156
|
});
|
|
157
|
+
// ─── invariants (every stat, every branch) ────────────────────────────────────
|
|
158
|
+
// These are the catch-all guards. The per-stat suites above each test one branch by hand; the original
|
|
159
|
+
// power===quick / read===commit weight-sum bug slipped through precisely because no suite swept ALL branches.
|
|
160
|
+
(0, globals_1.describe)('multiplier + score invariants (every stat)', () => {
|
|
161
|
+
const ALL_STATS = Object.values(stats_1.StatsEnum);
|
|
162
|
+
// Profiles that exercise the conditional ATTACK/BLOCK branches: the omitted-stats default, the equal tie (the
|
|
163
|
+
// edge the `>` comparisons mishandled), and both `>` orderings. Non-conditional stats are weight-stable across
|
|
164
|
+
// all four, which is itself worth pinning. Every branch MUST keep the weights summing to 1 or the general stat
|
|
165
|
+
// silently mis-scales.
|
|
166
|
+
const profiles = [
|
|
167
|
+
['omitted', undefined],
|
|
168
|
+
['uniform (power===quick, read===commit)', makeStats(50)],
|
|
169
|
+
['power & read dominant', makeStats(50, { power: 80, quick: 20, read: 80, commit: 20 })],
|
|
170
|
+
['quick & commit dominant', makeStats(50, { quick: 80, power: 20, commit: 80, read: 20 })]
|
|
171
|
+
];
|
|
172
|
+
for (const stat of ALL_STATS) {
|
|
173
|
+
for (const [label, ps] of profiles) {
|
|
174
|
+
(0, globals_1.it)(`${stat}: multipliers sum to 1 (${label})`, () => {
|
|
175
|
+
const m = (0, stats_1.getMultipliers)(stat, ps);
|
|
176
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
177
|
+
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// The strongest single guard: because the weights sum to 1, a uniformly-rated player scores that exact rating.
|
|
182
|
+
// Uniform stats also create the power===quick / read===commit ties, so this fails loudly on any mis-summed branch
|
|
183
|
+
// (pre-fix, ATTACK/BLOCK would have scored ~54 here, not 63).
|
|
184
|
+
for (const stat of ALL_STATS) {
|
|
185
|
+
(0, globals_1.it)(`${stat}: a uniformly-rated player scores that exact rating`, () => {
|
|
186
|
+
(0, globals_1.expect)((0, stats_1.calculateStatScore)(makeStats(63), stat)).toBe(63);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// The general stat shares the substat scale, so an all-99 (SPECIAL-cap) player must not exceed 99 either way:
|
|
190
|
+
// this catches the opposite failure of weights summing to MORE than 1.
|
|
191
|
+
for (const stat of ALL_STATS) {
|
|
192
|
+
(0, globals_1.it)(`${stat}: an all-99 player does not exceed the 99 cap`, () => {
|
|
193
|
+
(0, globals_1.expect)((0, stats_1.calculateStatScore)(makeStats(99), stat)).toBeLessThanOrEqual(99);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"chance": 0.25,
|
|
6
6
|
"roles": [
|
|
7
7
|
"SETTER",
|
|
8
|
-
"LIBERO"
|
|
8
|
+
"LIBERO",
|
|
9
|
+
"OUTSIDE_HITTER"
|
|
9
10
|
]
|
|
10
11
|
},
|
|
11
12
|
{
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
"roles": [
|
|
26
27
|
"MIDDLE_BLOCKER",
|
|
27
28
|
"OPPOSITE_HITTER",
|
|
28
|
-
"SETTER"
|
|
29
|
+
"SETTER",
|
|
30
|
+
"OUTSIDE_HITTER"
|
|
29
31
|
]
|
|
30
32
|
},
|
|
31
33
|
{
|
|
@@ -35,8 +37,7 @@
|
|
|
35
37
|
"roles": [
|
|
36
38
|
"SETTER",
|
|
37
39
|
"OPPOSITE_HITTER",
|
|
38
|
-
"OUTSIDE_HITTER"
|
|
39
|
-
"MIDDLE_BLOCKER"
|
|
40
|
+
"OUTSIDE_HITTER"
|
|
40
41
|
]
|
|
41
42
|
},
|
|
42
43
|
{
|
|
@@ -100,6 +100,16 @@ function transformToObject(model, roster) {
|
|
|
100
100
|
const secondLibero = (hasStartingLibero && model.second_libero != null && !inLineupIds.has(model.second_libero))
|
|
101
101
|
? roster.find((p) => p.id === model.second_libero)
|
|
102
102
|
: undefined;
|
|
103
|
+
// A persisted `libero_replacement_sets` whose entries are ALL empty (a stale [[],[],[],[],[]] left by an
|
|
104
|
+
// older "configure each set independently" session that a later "apply to all sets" save could not clear --
|
|
105
|
+
// the pre-fix write omitted the column instead of nulling it) silently benches the libero: the sim picks
|
|
106
|
+
// `sets?.[i] ?? liberoReplacements`, and an empty array is not nullish, so every set gets an empty target
|
|
107
|
+
// list and the libero never enters the court. Treat an all-empty per-set list as absent so the sim falls
|
|
108
|
+
// back to the flat `liberoReplacements`; the next tactics save rewrites the column cleanly.
|
|
109
|
+
const mappedLiberoReplacementSets = model.libero_replacement_sets?.map(set => set.map((id) => findPlayer(id, roster)));
|
|
110
|
+
const liberoReplacementSets = mappedLiberoReplacementSets?.some(set => set.length > 0) === true
|
|
111
|
+
? mappedLiberoReplacementSets
|
|
112
|
+
: undefined;
|
|
103
113
|
return Tactics.create({
|
|
104
114
|
receiveRotationOffset: model.receive_rotation_offset,
|
|
105
115
|
lineup,
|
|
@@ -107,7 +117,7 @@ function transformToObject(model, roster) {
|
|
|
107
117
|
substitutionBand: model.substitution_band,
|
|
108
118
|
secondLibero,
|
|
109
119
|
liberoSub: secondLibero != null ? (model.libero_sub ?? undefined) : undefined,
|
|
110
|
-
liberoReplacementSets
|
|
120
|
+
liberoReplacementSets,
|
|
111
121
|
designatedSubs: (model.designated_subs ?? []).map(d => ({
|
|
112
122
|
starter: findPlayer(d.starterId, roster),
|
|
113
123
|
bench: d.benchId != null ? findPlayer(d.benchId, roster) : undefined,
|
|
@@ -155,6 +165,12 @@ function tacticsColumnsToApiDto(model) {
|
|
|
155
165
|
const validSecondLibero = (model.second_libero != null && model.lineup[CourtPosition.LIBERO_ZONE] != null && !lineupIds.has(model.second_libero))
|
|
156
166
|
? model.second_libero
|
|
157
167
|
: undefined;
|
|
168
|
+
// Same all-empty heal as transformToObject: an all-empty per-set `libero_replacement_sets` is a stale
|
|
169
|
+
// artifact that shadows the flat `liberoReplacements` in the sim, so don't surface it to the API/preset
|
|
170
|
+
// shape -- otherwise the UI would reload it as "configure each set independently" (all blank) and re-emit it.
|
|
171
|
+
const validLiberoReplacementSets = model.libero_replacement_sets?.some(set => set.length > 0) === true
|
|
172
|
+
? model.libero_replacement_sets
|
|
173
|
+
: undefined;
|
|
158
174
|
return {
|
|
159
175
|
lineup: model.lineup,
|
|
160
176
|
liberoReplacements: model.libero_replacements,
|
|
@@ -162,7 +178,7 @@ function tacticsColumnsToApiDto(model) {
|
|
|
162
178
|
substitutionBand: model.substitution_band,
|
|
163
179
|
secondLibero: validSecondLibero,
|
|
164
180
|
liberoSub: validSecondLibero != null ? model.libero_sub : undefined,
|
|
165
|
-
liberoReplacementSets:
|
|
181
|
+
liberoReplacementSets: validLiberoReplacementSets,
|
|
166
182
|
designatedSubs: (model.designated_subs ?? []).map(d => ({
|
|
167
183
|
starterId: d.starterId,
|
|
168
184
|
benchId: d.benchId,
|
|
@@ -19,7 +19,7 @@ export function getMultipliers(statName, performanceStats) {
|
|
|
19
19
|
return {
|
|
20
20
|
spike: 0.55,
|
|
21
21
|
backAttack: 0.1,
|
|
22
|
-
power: performanceStats == null ? 0.125 : performanceStats.power
|
|
22
|
+
power: performanceStats == null ? 0.125 : performanceStats.power >= performanceStats.quick ? 0.2 : 0.05,
|
|
23
23
|
quick: performanceStats == null ? 0.125 : performanceStats.quick > performanceStats.power ? 0.2 : 0.05,
|
|
24
24
|
awareness: 0.05,
|
|
25
25
|
attack: 0.05
|
|
@@ -49,7 +49,7 @@ export function getMultipliers(statName, performanceStats) {
|
|
|
49
49
|
case StatsEnum.BLOCK:
|
|
50
50
|
return {
|
|
51
51
|
block: 0.65,
|
|
52
|
-
read: performanceStats == null ? 0.125 : performanceStats.read
|
|
52
|
+
read: performanceStats == null ? 0.125 : performanceStats.read >= performanceStats.commit ? 0.2 : 0.05,
|
|
53
53
|
commit: performanceStats == null ? 0.125 : performanceStats.commit > performanceStats.read ? 0.2 : 0.05,
|
|
54
54
|
focus: 0.04,
|
|
55
55
|
defense: 0.04,
|
|
@@ -31,6 +31,18 @@ describe('getMultipliers()', () => {
|
|
|
31
31
|
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
32
32
|
expect(total).toBeCloseTo(1, 10);
|
|
33
33
|
});
|
|
34
|
+
// Regression: on a tie the `>` comparisons used to give BOTH sides 0.05 (pair = 0.10), dropping the stat's
|
|
35
|
+
// weights to 0.85. With `>=` the higher-or-equal side keeps 0.2 so the pair is 0.25 and the total stays 1.
|
|
36
|
+
it('on a power === quick tie, power keeps 0.2 and quick 0.05 (pair sums to 0.25)', () => {
|
|
37
|
+
const m = getMultipliers(StatsEnum.ATTACK, makeStats(50, { power: 50, quick: 50 }));
|
|
38
|
+
expect(m.power).toBe(0.2);
|
|
39
|
+
expect(m.quick).toBe(0.05);
|
|
40
|
+
});
|
|
41
|
+
it('multipliers sum to 1 on a power === quick tie', () => {
|
|
42
|
+
const m = getMultipliers(StatsEnum.ATTACK, makeStats(50, { power: 50, quick: 50 }));
|
|
43
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
44
|
+
expect(total).toBeCloseTo(1, 10);
|
|
45
|
+
});
|
|
34
46
|
});
|
|
35
47
|
describe('SET', () => {
|
|
36
48
|
it('multipliers sum to 1', () => {
|
|
@@ -89,6 +101,17 @@ describe('getMultipliers()', () => {
|
|
|
89
101
|
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
90
102
|
expect(total).toBeCloseTo(1, 10);
|
|
91
103
|
});
|
|
104
|
+
// Regression: same tie bug as ATTACK's power/quick. On read === commit both used to fall to 0.05.
|
|
105
|
+
it('on a read === commit tie, read keeps 0.2 and commit 0.05 (pair sums to 0.25)', () => {
|
|
106
|
+
const m = getMultipliers(StatsEnum.BLOCK, makeStats(50, { read: 50, commit: 50 }));
|
|
107
|
+
expect(m.read).toBe(0.2);
|
|
108
|
+
expect(m.commit).toBe(0.05);
|
|
109
|
+
});
|
|
110
|
+
it('multipliers sum to 1 on a read === commit tie', () => {
|
|
111
|
+
const m = getMultipliers(StatsEnum.BLOCK, makeStats(50, { read: 50, commit: 50 }));
|
|
112
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
113
|
+
expect(total).toBeCloseTo(1, 10);
|
|
114
|
+
});
|
|
92
115
|
});
|
|
93
116
|
describe('STAMINA', () => {
|
|
94
117
|
it('returns exactly { stamina: 1 }', () => {
|
|
@@ -129,3 +152,43 @@ describe('calculateStatScore()', () => {
|
|
|
129
152
|
expect(calculateStatScore(stats, StatsEnum.SERVE)).toBe(84);
|
|
130
153
|
});
|
|
131
154
|
});
|
|
155
|
+
// ─── invariants (every stat, every branch) ────────────────────────────────────
|
|
156
|
+
// These are the catch-all guards. The per-stat suites above each test one branch by hand; the original
|
|
157
|
+
// power===quick / read===commit weight-sum bug slipped through precisely because no suite swept ALL branches.
|
|
158
|
+
describe('multiplier + score invariants (every stat)', () => {
|
|
159
|
+
const ALL_STATS = Object.values(StatsEnum);
|
|
160
|
+
// Profiles that exercise the conditional ATTACK/BLOCK branches: the omitted-stats default, the equal tie (the
|
|
161
|
+
// edge the `>` comparisons mishandled), and both `>` orderings. Non-conditional stats are weight-stable across
|
|
162
|
+
// all four, which is itself worth pinning. Every branch MUST keep the weights summing to 1 or the general stat
|
|
163
|
+
// silently mis-scales.
|
|
164
|
+
const profiles = [
|
|
165
|
+
['omitted', undefined],
|
|
166
|
+
['uniform (power===quick, read===commit)', makeStats(50)],
|
|
167
|
+
['power & read dominant', makeStats(50, { power: 80, quick: 20, read: 80, commit: 20 })],
|
|
168
|
+
['quick & commit dominant', makeStats(50, { quick: 80, power: 20, commit: 80, read: 20 })]
|
|
169
|
+
];
|
|
170
|
+
for (const stat of ALL_STATS) {
|
|
171
|
+
for (const [label, ps] of profiles) {
|
|
172
|
+
it(`${stat}: multipliers sum to 1 (${label})`, () => {
|
|
173
|
+
const m = getMultipliers(stat, ps);
|
|
174
|
+
const total = Object.values(m).reduce((s, v) => s + (v ?? 0), 0);
|
|
175
|
+
expect(total).toBeCloseTo(1, 10);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// The strongest single guard: because the weights sum to 1, a uniformly-rated player scores that exact rating.
|
|
180
|
+
// Uniform stats also create the power===quick / read===commit ties, so this fails loudly on any mis-summed branch
|
|
181
|
+
// (pre-fix, ATTACK/BLOCK would have scored ~54 here, not 63).
|
|
182
|
+
for (const stat of ALL_STATS) {
|
|
183
|
+
it(`${stat}: a uniformly-rated player scores that exact rating`, () => {
|
|
184
|
+
expect(calculateStatScore(makeStats(63), stat)).toBe(63);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// The general stat shares the substat scale, so an all-99 (SPECIAL-cap) player must not exceed 99 either way:
|
|
188
|
+
// this catches the opposite failure of weights summing to MORE than 1.
|
|
189
|
+
for (const stat of ALL_STATS) {
|
|
190
|
+
it(`${stat}: an all-99 player does not exceed the 99 cap`, () => {
|
|
191
|
+
expect(calculateStatScore(makeStats(99), stat)).toBeLessThanOrEqual(99);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"chance": 0.25,
|
|
6
6
|
"roles": [
|
|
7
7
|
"SETTER",
|
|
8
|
-
"LIBERO"
|
|
8
|
+
"LIBERO",
|
|
9
|
+
"OUTSIDE_HITTER"
|
|
9
10
|
]
|
|
10
11
|
},
|
|
11
12
|
{
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
"roles": [
|
|
26
27
|
"MIDDLE_BLOCKER",
|
|
27
28
|
"OPPOSITE_HITTER",
|
|
28
|
-
"SETTER"
|
|
29
|
+
"SETTER",
|
|
30
|
+
"OUTSIDE_HITTER"
|
|
29
31
|
]
|
|
30
32
|
},
|
|
31
33
|
{
|
|
@@ -35,8 +37,7 @@
|
|
|
35
37
|
"roles": [
|
|
36
38
|
"SETTER",
|
|
37
39
|
"OPPOSITE_HITTER",
|
|
38
|
-
"OUTSIDE_HITTER"
|
|
39
|
-
"MIDDLE_BLOCKER"
|
|
40
|
+
"OUTSIDE_HITTER"
|
|
40
41
|
]
|
|
41
42
|
},
|
|
42
43
|
{
|