volleyballsimtypes 0.0.428 → 0.0.430
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/event/reception.d.ts +3 -2
- package/dist/cjs/src/service/event/reception.js +3 -0
- package/dist/cjs/src/service/event/schemas/reception.z.js +1 -1
- package/dist/cjs/src/service/event/schemas/reception.z.test.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/reception.z.test.js +29 -0
- package/dist/cjs/src/service/match/match-rating.d.ts +2 -4
- package/dist/cjs/src/service/match/match-rating.js +11 -21
- package/dist/cjs/src/service/match/match-rating.test.js +12 -14
- package/dist/cjs/src/service/player/improvement.d.ts +1 -0
- package/dist/cjs/src/service/player/improvement.js +33 -6
- package/dist/cjs/src/service/player/improvement.test.js +45 -14
- package/dist/esm/src/service/event/reception.d.ts +3 -2
- package/dist/esm/src/service/event/reception.js +3 -0
- package/dist/esm/src/service/event/schemas/reception.z.js +1 -1
- package/dist/esm/src/service/event/schemas/reception.z.test.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/reception.z.test.js +27 -0
- package/dist/esm/src/service/match/match-rating.d.ts +2 -4
- package/dist/esm/src/service/match/match-rating.js +11 -21
- package/dist/esm/src/service/match/match-rating.test.js +12 -14
- package/dist/esm/src/service/player/improvement.d.ts +1 -0
- package/dist/esm/src/service/player/improvement.js +32 -6
- package/dist/esm/src/service/player/improvement.test.js +46 -15
- package/package.json +1 -1
|
@@ -3,9 +3,10 @@ export declare enum ReceptionTypeEnum {
|
|
|
3
3
|
DIG = 0,
|
|
4
4
|
OVERHAND = 1,
|
|
5
5
|
ONE_HAND = 2,
|
|
6
|
-
OTHER_BODY_PART = 3
|
|
6
|
+
OTHER_BODY_PART = 3,
|
|
7
|
+
NO_TOUCH = 4
|
|
7
8
|
}
|
|
8
|
-
export type ReceptionType = ReceptionTypeEnum.DIG | ReceptionTypeEnum.OVERHAND | ReceptionTypeEnum.ONE_HAND | ReceptionTypeEnum.OTHER_BODY_PART;
|
|
9
|
+
export type ReceptionType = ReceptionTypeEnum.DIG | ReceptionTypeEnum.OVERHAND | ReceptionTypeEnum.ONE_HAND | ReceptionTypeEnum.OTHER_BODY_PART | ReceptionTypeEnum.NO_TOUCH;
|
|
9
10
|
export declare enum ReceptionFailureEnum {
|
|
10
11
|
NO_FAILURE = 0,
|
|
11
12
|
FAULT = 1,
|
|
@@ -10,6 +10,9 @@ var ReceptionTypeEnum;
|
|
|
10
10
|
ReceptionTypeEnum[ReceptionTypeEnum["OVERHAND"] = 1] = "OVERHAND";
|
|
11
11
|
ReceptionTypeEnum[ReceptionTypeEnum["ONE_HAND"] = 2] = "ONE_HAND";
|
|
12
12
|
ReceptionTypeEnum[ReceptionTypeEnum["OTHER_BODY_PART"] = 3] = "OTHER_BODY_PART";
|
|
13
|
+
// The ball was never touched: a serve so good (vs the receiver's reflex) it drops untouched for a
|
|
14
|
+
// clean ace. Booked as the server's ace only, with no reception attempt/error charged to the passer.
|
|
15
|
+
ReceptionTypeEnum[ReceptionTypeEnum["NO_TOUCH"] = 4] = "NO_TOUCH";
|
|
13
16
|
})(ReceptionTypeEnum || (exports.ReceptionTypeEnum = ReceptionTypeEnum = {}));
|
|
14
17
|
var ReceptionFailureEnum;
|
|
15
18
|
(function (ReceptionFailureEnum) {
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ReceptionInputSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const player_1 = require("../../player");
|
|
6
|
-
const receptionTypeValues = [0, 1, 2, 3];
|
|
6
|
+
const receptionTypeValues = [0, 1, 2, 3, 4];
|
|
7
7
|
const receptionFailureValues = [0, 1, 2, 3, 4];
|
|
8
8
|
const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
|
|
9
9
|
exports.ReceptionInputSchema = zod_1.z.object({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const globals_1 = require("@jest/globals");
|
|
4
|
+
const reception_z_1 = require("./reception.z");
|
|
5
|
+
const base = {
|
|
6
|
+
playerId: '11111111-1111-4111-8111-111111111111',
|
|
7
|
+
score: 10,
|
|
8
|
+
target: 5,
|
|
9
|
+
activeTraits: [],
|
|
10
|
+
failure: 2,
|
|
11
|
+
type: 0
|
|
12
|
+
};
|
|
13
|
+
(0, globals_1.describe)('ReceptionInputSchema', () => {
|
|
14
|
+
(0, globals_1.it)('accepts every reception type 0-4 (incl. NO_TOUCH = 4)', () => {
|
|
15
|
+
for (const type of [0, 1, 2, 3, 4]) {
|
|
16
|
+
(0, globals_1.expect)(reception_z_1.ReceptionInputSchema.safeParse({ ...base, type }).success).toBe(true);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
(0, globals_1.it)('rejects an out-of-range reception type', () => {
|
|
20
|
+
(0, globals_1.expect)(reception_z_1.ReceptionInputSchema.safeParse({ ...base, type: 5 }).success).toBe(false);
|
|
21
|
+
(0, globals_1.expect)(reception_z_1.ReceptionInputSchema.safeParse({ ...base, type: -1 }).success).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
(0, globals_1.it)('rejects an out-of-range reception failure', () => {
|
|
24
|
+
(0, globals_1.expect)(reception_z_1.ReceptionInputSchema.safeParse({ ...base, failure: 5 }).success).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
(0, globals_1.it)('rejects an out-of-range court target', () => {
|
|
27
|
+
(0, globals_1.expect)(reception_z_1.ReceptionInputSchema.safeParse({ ...base, target: 13 }).success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -17,16 +17,14 @@ export declare class MatchRating {
|
|
|
17
17
|
static readonly C4: number;
|
|
18
18
|
static readonly C5: number;
|
|
19
19
|
static readonly SSV: SetScoreVariant;
|
|
20
|
-
static readonly
|
|
21
|
-
static readonly
|
|
20
|
+
static readonly LEAGUE_WEIGHT: number;
|
|
21
|
+
static readonly NATIONAL_WEIGHT: number;
|
|
22
22
|
static readonly QUALIFIER_WEIGHT: number;
|
|
23
23
|
static readonly TOURNAMENT_WEIGHT: number;
|
|
24
|
-
static readonly NATIONAL_WEIGHT: number;
|
|
25
24
|
readonly match: Match;
|
|
26
25
|
readonly weight: number;
|
|
27
26
|
static create(input: unknown): MatchRating;
|
|
28
27
|
private constructor();
|
|
29
|
-
private static leagueWeight;
|
|
30
28
|
private static applyBotPenalty;
|
|
31
29
|
private static computeWeight;
|
|
32
30
|
static calculateProbability(z: number): number;
|
|
@@ -21,23 +21,16 @@ class MatchRating {
|
|
|
21
21
|
this.match = match;
|
|
22
22
|
this.weight = MatchRating.computeWeight(context);
|
|
23
23
|
}
|
|
24
|
-
static leagueWeight(tier) {
|
|
25
|
-
return Math.max(MatchRating.TIER_PENALTY, MatchRating.LEAGUE_BASE - MatchRating.TIER_PENALTY * tier);
|
|
26
|
-
}
|
|
27
24
|
static applyBotPenalty(weight, homeIsBot, awayIsBot) {
|
|
28
25
|
const bots = (homeIsBot ? 1 : 0) + (awayIsBot ? 1 : 0);
|
|
29
26
|
return weight * Math.pow(0.5, bots);
|
|
30
27
|
}
|
|
31
28
|
static computeWeight(context) {
|
|
32
29
|
switch (context.kind) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
case 'PROMOTION': {
|
|
38
|
-
const base = (MatchRating.leagueWeight(context.upperTier) + MatchRating.leagueWeight(context.lowerTier)) / 2;
|
|
39
|
-
return MatchRating.applyBotPenalty(base, context.homeIsBot, context.awayIsBot);
|
|
40
|
-
}
|
|
30
|
+
// Promotion plays at the league weight (it is a league-tier playoff).
|
|
31
|
+
case 'LEAGUE':
|
|
32
|
+
case 'PROMOTION':
|
|
33
|
+
return MatchRating.applyBotPenalty(MatchRating.LEAGUE_WEIGHT, context.homeIsBot, context.awayIsBot);
|
|
41
34
|
case 'QUALIFIER':
|
|
42
35
|
return MatchRating.applyBotPenalty(MatchRating.QUALIFIER_WEIGHT, context.homeIsBot, context.awayIsBot);
|
|
43
36
|
case 'TOURNAMENT':
|
|
@@ -89,13 +82,10 @@ MatchRating.SSV = {
|
|
|
89
82
|
'1-3': -1.5,
|
|
90
83
|
'0-3': -2
|
|
91
84
|
};
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
MatchRating.
|
|
96
|
-
|
|
97
|
-
MatchRating.QUALIFIER_WEIGHT =
|
|
98
|
-
// WCC
|
|
99
|
-
MatchRating.TOURNAMENT_WEIGHT = 55;
|
|
100
|
-
// National cup, between qualifier and tournament prestige
|
|
101
|
-
MatchRating.NATIONAL_WEIGHT = 50;
|
|
85
|
+
// FIVB tournament-importance weight: FIVB's "K" in S_after = S_before + K*(R-E)/8 (note the static `K`
|
|
86
|
+
// below is the formula's /8 divisor, NOT this). One flat value per competition, FIVB-style — no per-tier
|
|
87
|
+
// or per-stage scaling, since the rating-gap expected result already rewards beating stronger teams.
|
|
88
|
+
MatchRating.LEAGUE_WEIGHT = 20;
|
|
89
|
+
MatchRating.NATIONAL_WEIGHT = 30;
|
|
90
|
+
MatchRating.QUALIFIER_WEIGHT = 40;
|
|
91
|
+
MatchRating.TOURNAMENT_WEIGHT = 50; // WCC, top of the hierarchy
|
|
@@ -37,22 +37,21 @@ const number_utils_1 = require("../utils/number-utils");
|
|
|
37
37
|
const match = (0, test_helpers_1.makeMatch30)(home, away);
|
|
38
38
|
return match_rating_1.MatchRating.create({ match, context });
|
|
39
39
|
}
|
|
40
|
-
(0, globals_1.it)('LEAGUE
|
|
41
|
-
|
|
42
|
-
(0, globals_1.expect)(
|
|
40
|
+
(0, globals_1.it)('LEAGUE → flat LEAGUE_WEIGHT (20), independent of tier', () => {
|
|
41
|
+
(0, globals_1.expect)(makeRating({ kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }).weight).toBe(20);
|
|
42
|
+
(0, globals_1.expect)(makeRating({ kind: 'LEAGUE', tier: 7, homeIsBot: false, awayIsBot: false }).weight).toBe(20);
|
|
43
43
|
});
|
|
44
|
-
(0, globals_1.it)('
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
(0, globals_1.expect)(mr.weight).toBe(5);
|
|
44
|
+
(0, globals_1.it)('NATIONAL → weight = NATIONAL_WEIGHT (30)', () => {
|
|
45
|
+
const mr = makeRating({ kind: 'NATIONAL', homeIsBot: false, awayIsBot: false });
|
|
46
|
+
(0, globals_1.expect)(mr.weight).toBe(30);
|
|
48
47
|
});
|
|
49
|
-
(0, globals_1.it)('QUALIFIER → weight = QUALIFIER_WEIGHT (
|
|
48
|
+
(0, globals_1.it)('QUALIFIER → weight = QUALIFIER_WEIGHT (40)', () => {
|
|
50
49
|
const mr = makeRating({ kind: 'QUALIFIER', homeIsBot: false, awayIsBot: false });
|
|
51
|
-
(0, globals_1.expect)(mr.weight).toBe(
|
|
50
|
+
(0, globals_1.expect)(mr.weight).toBe(40);
|
|
52
51
|
});
|
|
53
|
-
(0, globals_1.it)('TOURNAMENT → weight = TOURNAMENT_WEIGHT (
|
|
52
|
+
(0, globals_1.it)('TOURNAMENT → weight = TOURNAMENT_WEIGHT (50)', () => {
|
|
54
53
|
const mr = makeRating({ kind: 'TOURNAMENT', homeIsBot: false, awayIsBot: false });
|
|
55
|
-
(0, globals_1.expect)(mr.weight).toBe(
|
|
54
|
+
(0, globals_1.expect)(mr.weight).toBe(50);
|
|
56
55
|
});
|
|
57
56
|
(0, globals_1.it)('each bot halves the weight', () => {
|
|
58
57
|
const noBots = makeRating({ kind: 'QUALIFIER', homeIsBot: false, awayIsBot: false });
|
|
@@ -61,10 +60,9 @@ const number_utils_1 = require("../utils/number-utils");
|
|
|
61
60
|
(0, globals_1.expect)(oneBot.weight).toBeCloseTo(noBots.weight * 0.5, 10);
|
|
62
61
|
(0, globals_1.expect)(twoBots.weight).toBeCloseTo(noBots.weight * 0.25, 10);
|
|
63
62
|
});
|
|
64
|
-
(0, globals_1.it)('PROMOTION
|
|
65
|
-
// upperTier=1 (35), lowerTier=2 (30) → avg = 32.5
|
|
63
|
+
(0, globals_1.it)('PROMOTION → league weight (20)', () => {
|
|
66
64
|
const mr = makeRating({ kind: 'PROMOTION', upperTier: 1, lowerTier: 2, homeIsBot: false, awayIsBot: false });
|
|
67
|
-
(0, globals_1.expect)(mr.weight).
|
|
65
|
+
(0, globals_1.expect)(mr.weight).toBe(20);
|
|
68
66
|
});
|
|
69
67
|
});
|
|
70
68
|
// ─── getExpectedResult ────────────────────────────────────────────────────────
|
|
@@ -4,6 +4,7 @@ import { DeclineProfile } from './decline';
|
|
|
4
4
|
export type TrainingFocus = Stats;
|
|
5
5
|
export declare function getStatCap(rarity: Rarity): number;
|
|
6
6
|
export declare function getRarityMultiplier(rarity: Rarity): number;
|
|
7
|
+
export declare function getImprovementFloor(rarity: Rarity): number;
|
|
7
8
|
export declare function getImprovementThreshold(statTotal: number, rarity: Rarity): number;
|
|
8
9
|
export declare function getCoachMultiplier(rarity: Rarity): number;
|
|
9
10
|
export declare function matchExperience(pointsPlayed: number): number;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getStatCap = getStatCap;
|
|
4
4
|
exports.getRarityMultiplier = getRarityMultiplier;
|
|
5
|
+
exports.getImprovementFloor = getImprovementFloor;
|
|
5
6
|
exports.getImprovementThreshold = getImprovementThreshold;
|
|
6
7
|
exports.getCoachMultiplier = getCoachMultiplier;
|
|
7
8
|
exports.matchExperience = matchExperience;
|
|
@@ -12,9 +13,26 @@ const rarity_1 = require("./rarity");
|
|
|
12
13
|
const performance_stats_1 = require("./performance-stats");
|
|
13
14
|
const stats_1 = require("./stats");
|
|
14
15
|
const decline_1 = require("./decline");
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// ── Improvement threshold curve (normalize-to-band) ───────────────────────────
|
|
17
|
+
// The exp a player needs to gain +1 stat. Every rarity improves on ONE shared curve, but measured against
|
|
18
|
+
// ITS OWN range: a freshly generated player sits at the floor (improves the fastest) and the stat cap is the
|
|
19
|
+
// ceiling (improves the slowest). `frac` is the player's position from floor to ceiling, so the same relative
|
|
20
|
+
// progress costs the same for every rarity -- rarer players are no longer penalised for their higher skill
|
|
21
|
+
// floor (which is what the old rarity-multiplier divisor failed to offset). Threshold runs from CURVE_BASE at
|
|
22
|
+
// the floor to CURVE_BASE + CURVE_SCALAR at the cap; CURVE_EXPONENT keeps players fast through most of the
|
|
23
|
+
// band and slows them sharply only near the cap.
|
|
24
|
+
const CURVE_BASE = 180;
|
|
25
|
+
const CURVE_SCALAR = 800;
|
|
26
|
+
const CURVE_EXPONENT = 2;
|
|
27
|
+
// Per-rarity floor of the curve: the mean generated core-6 total (attack + serve + block + reception +
|
|
28
|
+
// setting + stamina) for that rarity, measured from PlayerGenerator. The ceiling is 6 * cap.
|
|
29
|
+
const IMPROVEMENT_FLOOR = {
|
|
30
|
+
[rarity_1.RarityEnum.COMMON]: 180,
|
|
31
|
+
[rarity_1.RarityEnum.RARE]: 296,
|
|
32
|
+
[rarity_1.RarityEnum.LEGENDARY]: 346,
|
|
33
|
+
[rarity_1.RarityEnum.MYTHIC]: 432,
|
|
34
|
+
[rarity_1.RarityEnum.SPECIAL]: 501
|
|
35
|
+
};
|
|
18
36
|
const statCaps = stat_config_1.statCapsJSON;
|
|
19
37
|
function getStatCap(rarity) {
|
|
20
38
|
const entry = statCaps.find(e => e.rarity === rarity);
|
|
@@ -22,16 +40,25 @@ function getStatCap(rarity) {
|
|
|
22
40
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
23
41
|
return entry.cap;
|
|
24
42
|
}
|
|
43
|
+
// Retained for config/reference. No longer used by the improvement curve -- superseded by IMPROVEMENT_FLOOR,
|
|
44
|
+
// which is the per-rarity adjustment now.
|
|
25
45
|
function getRarityMultiplier(rarity) {
|
|
26
46
|
const entry = statCaps.find(e => e.rarity === rarity);
|
|
27
47
|
if (entry == null)
|
|
28
48
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
29
49
|
return entry.rarityMultiplier;
|
|
30
50
|
}
|
|
51
|
+
function getImprovementFloor(rarity) {
|
|
52
|
+
const floor = IMPROVEMENT_FLOOR[rarity];
|
|
53
|
+
if (floor == null)
|
|
54
|
+
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
55
|
+
return floor;
|
|
56
|
+
}
|
|
31
57
|
function getImprovementThreshold(statTotal, rarity) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
58
|
+
const floor = getImprovementFloor(rarity);
|
|
59
|
+
const span = 6 * getStatCap(rarity) - floor;
|
|
60
|
+
const frac = span <= 0 ? 1 : Math.max(0, Math.min(1, (statTotal - floor) / span));
|
|
61
|
+
return Math.round(CURVE_BASE + CURVE_SCALAR * Math.pow(frac, CURVE_EXPONENT));
|
|
35
62
|
}
|
|
36
63
|
const COACH_MULTIPLIERS = {
|
|
37
64
|
[rarity_1.RarityEnum.COMMON]: 2.0,
|
|
@@ -37,24 +37,55 @@ const decline_1 = require("./decline");
|
|
|
37
37
|
});
|
|
38
38
|
// ─── getImprovementThreshold ──────────────────────────────────────────────────
|
|
39
39
|
(0, globals_1.describe)('getImprovementThreshold()', () => {
|
|
40
|
-
(0, globals_1.it)('at
|
|
41
|
-
//
|
|
42
|
-
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
(0, globals_1.it)('at or below the floor the threshold is CURVE_BASE (180) — the fastest pace', () => {
|
|
41
|
+
// A freshly generated player sits ~at the floor → frac 0 → threshold = 180 for every rarity.
|
|
42
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)((0, improvement_1.getImprovementFloor)(rarity_1.RarityEnum.COMMON), rarity_1.RarityEnum.COMMON)).toBe(180);
|
|
43
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(0, rarity_1.RarityEnum.SPECIAL)).toBe(180);
|
|
44
|
+
});
|
|
45
|
+
(0, globals_1.it)('at the ceiling (6 × cap) the threshold is CURVE_BASE + CURVE_SCALAR (980) — the slowest pace', () => {
|
|
46
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(6 * (0, improvement_1.getStatCap)(rarity_1.RarityEnum.COMMON), rarity_1.RarityEnum.COMMON)).toBe(980);
|
|
47
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(6 * (0, improvement_1.getStatCap)(rarity_1.RarityEnum.SPECIAL), rarity_1.RarityEnum.SPECIAL)).toBe(980);
|
|
48
|
+
});
|
|
49
|
+
(0, globals_1.it)('threshold increases as statTotal climbs from the floor toward the cap', () => {
|
|
50
|
+
const floor = (0, improvement_1.getImprovementFloor)(rarity_1.RarityEnum.COMMON);
|
|
51
|
+
const low = (0, improvement_1.getImprovementThreshold)(floor + 10, rarity_1.RarityEnum.COMMON);
|
|
52
|
+
const high = (0, improvement_1.getImprovementThreshold)(floor + 120, rarity_1.RarityEnum.COMMON);
|
|
49
53
|
(0, globals_1.expect)(high).toBeGreaterThan(low);
|
|
50
54
|
});
|
|
51
|
-
(0, globals_1.it)('
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
+
(0, globals_1.it)('is equivalent across rarities at the same position within their band', () => {
|
|
56
|
+
// Halfway from floor to ceiling must cost the same for every rarity — the whole point of the rework.
|
|
57
|
+
const midThreshold = (rarity) => {
|
|
58
|
+
const floor = (0, improvement_1.getImprovementFloor)(rarity);
|
|
59
|
+
const mid = floor + (6 * (0, improvement_1.getStatCap)(rarity) - floor) / 2;
|
|
60
|
+
return (0, improvement_1.getImprovementThreshold)(mid, rarity);
|
|
61
|
+
};
|
|
62
|
+
const common = midThreshold(rarity_1.RarityEnum.COMMON);
|
|
63
|
+
(0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.RARE)).toBe(common);
|
|
64
|
+
(0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.LEGENDARY)).toBe(common);
|
|
65
|
+
(0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.SPECIAL)).toBe(common);
|
|
55
66
|
});
|
|
56
67
|
(0, globals_1.it)('returns an integer (Math.round applied)', () => {
|
|
57
|
-
(0, globals_1.expect)(Number.isInteger((0, improvement_1.getImprovementThreshold)(
|
|
68
|
+
(0, globals_1.expect)(Number.isInteger((0, improvement_1.getImprovementThreshold)(200, rarity_1.RarityEnum.RARE))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ─── getImprovementFloor ──────────────────────────────────────────────────────
|
|
72
|
+
(0, globals_1.describe)('getImprovementFloor()', () => {
|
|
73
|
+
globals_1.it.each([
|
|
74
|
+
[rarity_1.RarityEnum.COMMON, 180],
|
|
75
|
+
[rarity_1.RarityEnum.RARE, 296],
|
|
76
|
+
[rarity_1.RarityEnum.LEGENDARY, 346],
|
|
77
|
+
[rarity_1.RarityEnum.MYTHIC, 432],
|
|
78
|
+
[rarity_1.RarityEnum.SPECIAL, 501]
|
|
79
|
+
])('%s → floor %i', (rarity, expected) => {
|
|
80
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementFloor)(rarity)).toBe(expected);
|
|
81
|
+
});
|
|
82
|
+
(0, globals_1.it)('throws UNKNOWN_RARITY for an unrecognised rarity string', () => {
|
|
83
|
+
(0, globals_1.expect)(() => (0, improvement_1.getImprovementFloor)('DIVINE')).toThrow('UNKNOWN_RARITY');
|
|
84
|
+
});
|
|
85
|
+
(0, globals_1.it)('every floor is below its rarity ceiling (6 × cap)', () => {
|
|
86
|
+
for (const rarity of [rarity_1.RarityEnum.COMMON, rarity_1.RarityEnum.RARE, rarity_1.RarityEnum.LEGENDARY, rarity_1.RarityEnum.MYTHIC, rarity_1.RarityEnum.SPECIAL]) {
|
|
87
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementFloor)(rarity)).toBeLessThan(6 * (0, improvement_1.getStatCap)(rarity));
|
|
88
|
+
}
|
|
58
89
|
});
|
|
59
90
|
});
|
|
60
91
|
// ─── getCoachMultiplier ───────────────────────────────────────────────────────
|
|
@@ -3,9 +3,10 @@ export declare enum ReceptionTypeEnum {
|
|
|
3
3
|
DIG = 0,
|
|
4
4
|
OVERHAND = 1,
|
|
5
5
|
ONE_HAND = 2,
|
|
6
|
-
OTHER_BODY_PART = 3
|
|
6
|
+
OTHER_BODY_PART = 3,
|
|
7
|
+
NO_TOUCH = 4
|
|
7
8
|
}
|
|
8
|
-
export type ReceptionType = ReceptionTypeEnum.DIG | ReceptionTypeEnum.OVERHAND | ReceptionTypeEnum.ONE_HAND | ReceptionTypeEnum.OTHER_BODY_PART;
|
|
9
|
+
export type ReceptionType = ReceptionTypeEnum.DIG | ReceptionTypeEnum.OVERHAND | ReceptionTypeEnum.ONE_HAND | ReceptionTypeEnum.OTHER_BODY_PART | ReceptionTypeEnum.NO_TOUCH;
|
|
9
10
|
export declare enum ReceptionFailureEnum {
|
|
10
11
|
NO_FAILURE = 0,
|
|
11
12
|
FAULT = 1,
|
|
@@ -7,6 +7,9 @@ export var ReceptionTypeEnum;
|
|
|
7
7
|
ReceptionTypeEnum[ReceptionTypeEnum["OVERHAND"] = 1] = "OVERHAND";
|
|
8
8
|
ReceptionTypeEnum[ReceptionTypeEnum["ONE_HAND"] = 2] = "ONE_HAND";
|
|
9
9
|
ReceptionTypeEnum[ReceptionTypeEnum["OTHER_BODY_PART"] = 3] = "OTHER_BODY_PART";
|
|
10
|
+
// The ball was never touched: a serve so good (vs the receiver's reflex) it drops untouched for a
|
|
11
|
+
// clean ace. Booked as the server's ace only, with no reception attempt/error charged to the passer.
|
|
12
|
+
ReceptionTypeEnum[ReceptionTypeEnum["NO_TOUCH"] = 4] = "NO_TOUCH";
|
|
10
13
|
})(ReceptionTypeEnum || (ReceptionTypeEnum = {}));
|
|
11
14
|
export var ReceptionFailureEnum;
|
|
12
15
|
(function (ReceptionFailureEnum) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { TraitEnum } from '../../player';
|
|
3
|
-
const receptionTypeValues = [0, 1, 2, 3];
|
|
3
|
+
const receptionTypeValues = [0, 1, 2, 3, 4];
|
|
4
4
|
const receptionFailureValues = [0, 1, 2, 3, 4];
|
|
5
5
|
const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
|
|
6
6
|
export const ReceptionInputSchema = z.object({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { ReceptionInputSchema } from './reception.z';
|
|
3
|
+
const base = {
|
|
4
|
+
playerId: '11111111-1111-4111-8111-111111111111',
|
|
5
|
+
score: 10,
|
|
6
|
+
target: 5,
|
|
7
|
+
activeTraits: [],
|
|
8
|
+
failure: 2,
|
|
9
|
+
type: 0
|
|
10
|
+
};
|
|
11
|
+
describe('ReceptionInputSchema', () => {
|
|
12
|
+
it('accepts every reception type 0-4 (incl. NO_TOUCH = 4)', () => {
|
|
13
|
+
for (const type of [0, 1, 2, 3, 4]) {
|
|
14
|
+
expect(ReceptionInputSchema.safeParse({ ...base, type }).success).toBe(true);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('rejects an out-of-range reception type', () => {
|
|
18
|
+
expect(ReceptionInputSchema.safeParse({ ...base, type: 5 }).success).toBe(false);
|
|
19
|
+
expect(ReceptionInputSchema.safeParse({ ...base, type: -1 }).success).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it('rejects an out-of-range reception failure', () => {
|
|
22
|
+
expect(ReceptionInputSchema.safeParse({ ...base, failure: 5 }).success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it('rejects an out-of-range court target', () => {
|
|
25
|
+
expect(ReceptionInputSchema.safeParse({ ...base, target: 13 }).success).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -17,16 +17,14 @@ export declare class MatchRating {
|
|
|
17
17
|
static readonly C4: number;
|
|
18
18
|
static readonly C5: number;
|
|
19
19
|
static readonly SSV: SetScoreVariant;
|
|
20
|
-
static readonly
|
|
21
|
-
static readonly
|
|
20
|
+
static readonly LEAGUE_WEIGHT: number;
|
|
21
|
+
static readonly NATIONAL_WEIGHT: number;
|
|
22
22
|
static readonly QUALIFIER_WEIGHT: number;
|
|
23
23
|
static readonly TOURNAMENT_WEIGHT: number;
|
|
24
|
-
static readonly NATIONAL_WEIGHT: number;
|
|
25
24
|
readonly match: Match;
|
|
26
25
|
readonly weight: number;
|
|
27
26
|
static create(input: unknown): MatchRating;
|
|
28
27
|
private constructor();
|
|
29
|
-
private static leagueWeight;
|
|
30
28
|
private static applyBotPenalty;
|
|
31
29
|
private static computeWeight;
|
|
32
30
|
static calculateProbability(z: number): number;
|
|
@@ -18,23 +18,16 @@ export class MatchRating {
|
|
|
18
18
|
this.match = match;
|
|
19
19
|
this.weight = MatchRating.computeWeight(context);
|
|
20
20
|
}
|
|
21
|
-
static leagueWeight(tier) {
|
|
22
|
-
return Math.max(MatchRating.TIER_PENALTY, MatchRating.LEAGUE_BASE - MatchRating.TIER_PENALTY * tier);
|
|
23
|
-
}
|
|
24
21
|
static applyBotPenalty(weight, homeIsBot, awayIsBot) {
|
|
25
22
|
const bots = (homeIsBot ? 1 : 0) + (awayIsBot ? 1 : 0);
|
|
26
23
|
return weight * Math.pow(0.5, bots);
|
|
27
24
|
}
|
|
28
25
|
static computeWeight(context) {
|
|
29
26
|
switch (context.kind) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
case 'PROMOTION': {
|
|
35
|
-
const base = (MatchRating.leagueWeight(context.upperTier) + MatchRating.leagueWeight(context.lowerTier)) / 2;
|
|
36
|
-
return MatchRating.applyBotPenalty(base, context.homeIsBot, context.awayIsBot);
|
|
37
|
-
}
|
|
27
|
+
// Promotion plays at the league weight (it is a league-tier playoff).
|
|
28
|
+
case 'LEAGUE':
|
|
29
|
+
case 'PROMOTION':
|
|
30
|
+
return MatchRating.applyBotPenalty(MatchRating.LEAGUE_WEIGHT, context.homeIsBot, context.awayIsBot);
|
|
38
31
|
case 'QUALIFIER':
|
|
39
32
|
return MatchRating.applyBotPenalty(MatchRating.QUALIFIER_WEIGHT, context.homeIsBot, context.awayIsBot);
|
|
40
33
|
case 'TOURNAMENT':
|
|
@@ -85,13 +78,10 @@ MatchRating.SSV = {
|
|
|
85
78
|
'1-3': -1.5,
|
|
86
79
|
'0-3': -2
|
|
87
80
|
};
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
MatchRating.
|
|
92
|
-
|
|
93
|
-
MatchRating.QUALIFIER_WEIGHT =
|
|
94
|
-
// WCC
|
|
95
|
-
MatchRating.TOURNAMENT_WEIGHT = 55;
|
|
96
|
-
// National cup, between qualifier and tournament prestige
|
|
97
|
-
MatchRating.NATIONAL_WEIGHT = 50;
|
|
81
|
+
// FIVB tournament-importance weight: FIVB's "K" in S_after = S_before + K*(R-E)/8 (note the static `K`
|
|
82
|
+
// below is the formula's /8 divisor, NOT this). One flat value per competition, FIVB-style — no per-tier
|
|
83
|
+
// or per-stage scaling, since the rating-gap expected result already rewards beating stronger teams.
|
|
84
|
+
MatchRating.LEAGUE_WEIGHT = 20;
|
|
85
|
+
MatchRating.NATIONAL_WEIGHT = 30;
|
|
86
|
+
MatchRating.QUALIFIER_WEIGHT = 40;
|
|
87
|
+
MatchRating.TOURNAMENT_WEIGHT = 50; // WCC, top of the hierarchy
|
|
@@ -35,22 +35,21 @@ describe('MatchRating weight computation', () => {
|
|
|
35
35
|
const match = makeMatch30(home, away);
|
|
36
36
|
return MatchRating.create({ match, context });
|
|
37
37
|
}
|
|
38
|
-
it('LEAGUE
|
|
39
|
-
|
|
40
|
-
expect(
|
|
38
|
+
it('LEAGUE → flat LEAGUE_WEIGHT (20), independent of tier', () => {
|
|
39
|
+
expect(makeRating({ kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }).weight).toBe(20);
|
|
40
|
+
expect(makeRating({ kind: 'LEAGUE', tier: 7, homeIsBot: false, awayIsBot: false }).weight).toBe(20);
|
|
41
41
|
});
|
|
42
|
-
it('
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expect(mr.weight).toBe(5);
|
|
42
|
+
it('NATIONAL → weight = NATIONAL_WEIGHT (30)', () => {
|
|
43
|
+
const mr = makeRating({ kind: 'NATIONAL', homeIsBot: false, awayIsBot: false });
|
|
44
|
+
expect(mr.weight).toBe(30);
|
|
46
45
|
});
|
|
47
|
-
it('QUALIFIER → weight = QUALIFIER_WEIGHT (
|
|
46
|
+
it('QUALIFIER → weight = QUALIFIER_WEIGHT (40)', () => {
|
|
48
47
|
const mr = makeRating({ kind: 'QUALIFIER', homeIsBot: false, awayIsBot: false });
|
|
49
|
-
expect(mr.weight).toBe(
|
|
48
|
+
expect(mr.weight).toBe(40);
|
|
50
49
|
});
|
|
51
|
-
it('TOURNAMENT → weight = TOURNAMENT_WEIGHT (
|
|
50
|
+
it('TOURNAMENT → weight = TOURNAMENT_WEIGHT (50)', () => {
|
|
52
51
|
const mr = makeRating({ kind: 'TOURNAMENT', homeIsBot: false, awayIsBot: false });
|
|
53
|
-
expect(mr.weight).toBe(
|
|
52
|
+
expect(mr.weight).toBe(50);
|
|
54
53
|
});
|
|
55
54
|
it('each bot halves the weight', () => {
|
|
56
55
|
const noBots = makeRating({ kind: 'QUALIFIER', homeIsBot: false, awayIsBot: false });
|
|
@@ -59,10 +58,9 @@ describe('MatchRating weight computation', () => {
|
|
|
59
58
|
expect(oneBot.weight).toBeCloseTo(noBots.weight * 0.5, 10);
|
|
60
59
|
expect(twoBots.weight).toBeCloseTo(noBots.weight * 0.25, 10);
|
|
61
60
|
});
|
|
62
|
-
it('PROMOTION
|
|
63
|
-
// upperTier=1 (35), lowerTier=2 (30) → avg = 32.5
|
|
61
|
+
it('PROMOTION → league weight (20)', () => {
|
|
64
62
|
const mr = makeRating({ kind: 'PROMOTION', upperTier: 1, lowerTier: 2, homeIsBot: false, awayIsBot: false });
|
|
65
|
-
expect(mr.weight).
|
|
63
|
+
expect(mr.weight).toBe(20);
|
|
66
64
|
});
|
|
67
65
|
});
|
|
68
66
|
// ─── getExpectedResult ────────────────────────────────────────────────────────
|
|
@@ -4,6 +4,7 @@ import { DeclineProfile } from './decline';
|
|
|
4
4
|
export type TrainingFocus = Stats;
|
|
5
5
|
export declare function getStatCap(rarity: Rarity): number;
|
|
6
6
|
export declare function getRarityMultiplier(rarity: Rarity): number;
|
|
7
|
+
export declare function getImprovementFloor(rarity: Rarity): number;
|
|
7
8
|
export declare function getImprovementThreshold(statTotal: number, rarity: Rarity): number;
|
|
8
9
|
export declare function getCoachMultiplier(rarity: Rarity): number;
|
|
9
10
|
export declare function matchExperience(pointsPlayed: number): number;
|
|
@@ -3,9 +3,26 @@ import { RarityEnum } from './rarity';
|
|
|
3
3
|
import { performanceStatKeys } from './performance-stats';
|
|
4
4
|
import { getMultipliers } from './stats';
|
|
5
5
|
import { youthMultiplier } from './decline';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// ── Improvement threshold curve (normalize-to-band) ───────────────────────────
|
|
7
|
+
// The exp a player needs to gain +1 stat. Every rarity improves on ONE shared curve, but measured against
|
|
8
|
+
// ITS OWN range: a freshly generated player sits at the floor (improves the fastest) and the stat cap is the
|
|
9
|
+
// ceiling (improves the slowest). `frac` is the player's position from floor to ceiling, so the same relative
|
|
10
|
+
// progress costs the same for every rarity -- rarer players are no longer penalised for their higher skill
|
|
11
|
+
// floor (which is what the old rarity-multiplier divisor failed to offset). Threshold runs from CURVE_BASE at
|
|
12
|
+
// the floor to CURVE_BASE + CURVE_SCALAR at the cap; CURVE_EXPONENT keeps players fast through most of the
|
|
13
|
+
// band and slows them sharply only near the cap.
|
|
14
|
+
const CURVE_BASE = 180;
|
|
15
|
+
const CURVE_SCALAR = 800;
|
|
16
|
+
const CURVE_EXPONENT = 2;
|
|
17
|
+
// Per-rarity floor of the curve: the mean generated core-6 total (attack + serve + block + reception +
|
|
18
|
+
// setting + stamina) for that rarity, measured from PlayerGenerator. The ceiling is 6 * cap.
|
|
19
|
+
const IMPROVEMENT_FLOOR = {
|
|
20
|
+
[RarityEnum.COMMON]: 180,
|
|
21
|
+
[RarityEnum.RARE]: 296,
|
|
22
|
+
[RarityEnum.LEGENDARY]: 346,
|
|
23
|
+
[RarityEnum.MYTHIC]: 432,
|
|
24
|
+
[RarityEnum.SPECIAL]: 501
|
|
25
|
+
};
|
|
9
26
|
const statCaps = statCapsJSON;
|
|
10
27
|
export function getStatCap(rarity) {
|
|
11
28
|
const entry = statCaps.find(e => e.rarity === rarity);
|
|
@@ -13,16 +30,25 @@ export function getStatCap(rarity) {
|
|
|
13
30
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
14
31
|
return entry.cap;
|
|
15
32
|
}
|
|
33
|
+
// Retained for config/reference. No longer used by the improvement curve -- superseded by IMPROVEMENT_FLOOR,
|
|
34
|
+
// which is the per-rarity adjustment now.
|
|
16
35
|
export function getRarityMultiplier(rarity) {
|
|
17
36
|
const entry = statCaps.find(e => e.rarity === rarity);
|
|
18
37
|
if (entry == null)
|
|
19
38
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
20
39
|
return entry.rarityMultiplier;
|
|
21
40
|
}
|
|
41
|
+
export function getImprovementFloor(rarity) {
|
|
42
|
+
const floor = IMPROVEMENT_FLOOR[rarity];
|
|
43
|
+
if (floor == null)
|
|
44
|
+
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
45
|
+
return floor;
|
|
46
|
+
}
|
|
22
47
|
export function getImprovementThreshold(statTotal, rarity) {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
48
|
+
const floor = getImprovementFloor(rarity);
|
|
49
|
+
const span = 6 * getStatCap(rarity) - floor;
|
|
50
|
+
const frac = span <= 0 ? 1 : Math.max(0, Math.min(1, (statTotal - floor) / span));
|
|
51
|
+
return Math.round(CURVE_BASE + CURVE_SCALAR * Math.pow(frac, CURVE_EXPONENT));
|
|
26
52
|
}
|
|
27
53
|
const COACH_MULTIPLIERS = {
|
|
28
54
|
[RarityEnum.COMMON]: 2.0,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from '@jest/globals';
|
|
2
2
|
import { RarityEnum } from './rarity';
|
|
3
|
-
import { getStatCap, getRarityMultiplier, getImprovementThreshold, getCoachMultiplier, selectImprovableStat, matchExperience, experienceGained } from './improvement';
|
|
3
|
+
import { getStatCap, getRarityMultiplier, getImprovementFloor, getImprovementThreshold, getCoachMultiplier, selectImprovableStat, matchExperience, experienceGained } from './improvement';
|
|
4
4
|
import { StatsEnum } from './stats';
|
|
5
5
|
import { DeclineProfileEnum } from './decline';
|
|
6
6
|
// ─── getStatCap ───────────────────────────────────────────────────────────────
|
|
@@ -35,24 +35,55 @@ describe('getRarityMultiplier()', () => {
|
|
|
35
35
|
});
|
|
36
36
|
// ─── getImprovementThreshold ──────────────────────────────────────────────────
|
|
37
37
|
describe('getImprovementThreshold()', () => {
|
|
38
|
-
it('at
|
|
39
|
-
//
|
|
40
|
-
expect(getImprovementThreshold(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
it('at or below the floor the threshold is CURVE_BASE (180) — the fastest pace', () => {
|
|
39
|
+
// A freshly generated player sits ~at the floor → frac 0 → threshold = 180 for every rarity.
|
|
40
|
+
expect(getImprovementThreshold(getImprovementFloor(RarityEnum.COMMON), RarityEnum.COMMON)).toBe(180);
|
|
41
|
+
expect(getImprovementThreshold(0, RarityEnum.SPECIAL)).toBe(180);
|
|
42
|
+
});
|
|
43
|
+
it('at the ceiling (6 × cap) the threshold is CURVE_BASE + CURVE_SCALAR (980) — the slowest pace', () => {
|
|
44
|
+
expect(getImprovementThreshold(6 * getStatCap(RarityEnum.COMMON), RarityEnum.COMMON)).toBe(980);
|
|
45
|
+
expect(getImprovementThreshold(6 * getStatCap(RarityEnum.SPECIAL), RarityEnum.SPECIAL)).toBe(980);
|
|
46
|
+
});
|
|
47
|
+
it('threshold increases as statTotal climbs from the floor toward the cap', () => {
|
|
48
|
+
const floor = getImprovementFloor(RarityEnum.COMMON);
|
|
49
|
+
const low = getImprovementThreshold(floor + 10, RarityEnum.COMMON);
|
|
50
|
+
const high = getImprovementThreshold(floor + 120, RarityEnum.COMMON);
|
|
47
51
|
expect(high).toBeGreaterThan(low);
|
|
48
52
|
});
|
|
49
|
-
it('
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
+
it('is equivalent across rarities at the same position within their band', () => {
|
|
54
|
+
// Halfway from floor to ceiling must cost the same for every rarity — the whole point of the rework.
|
|
55
|
+
const midThreshold = (rarity) => {
|
|
56
|
+
const floor = getImprovementFloor(rarity);
|
|
57
|
+
const mid = floor + (6 * getStatCap(rarity) - floor) / 2;
|
|
58
|
+
return getImprovementThreshold(mid, rarity);
|
|
59
|
+
};
|
|
60
|
+
const common = midThreshold(RarityEnum.COMMON);
|
|
61
|
+
expect(midThreshold(RarityEnum.RARE)).toBe(common);
|
|
62
|
+
expect(midThreshold(RarityEnum.LEGENDARY)).toBe(common);
|
|
63
|
+
expect(midThreshold(RarityEnum.SPECIAL)).toBe(common);
|
|
53
64
|
});
|
|
54
65
|
it('returns an integer (Math.round applied)', () => {
|
|
55
|
-
expect(Number.isInteger(getImprovementThreshold(
|
|
66
|
+
expect(Number.isInteger(getImprovementThreshold(200, RarityEnum.RARE))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
// ─── getImprovementFloor ──────────────────────────────────────────────────────
|
|
70
|
+
describe('getImprovementFloor()', () => {
|
|
71
|
+
it.each([
|
|
72
|
+
[RarityEnum.COMMON, 180],
|
|
73
|
+
[RarityEnum.RARE, 296],
|
|
74
|
+
[RarityEnum.LEGENDARY, 346],
|
|
75
|
+
[RarityEnum.MYTHIC, 432],
|
|
76
|
+
[RarityEnum.SPECIAL, 501]
|
|
77
|
+
])('%s → floor %i', (rarity, expected) => {
|
|
78
|
+
expect(getImprovementFloor(rarity)).toBe(expected);
|
|
79
|
+
});
|
|
80
|
+
it('throws UNKNOWN_RARITY for an unrecognised rarity string', () => {
|
|
81
|
+
expect(() => getImprovementFloor('DIVINE')).toThrow('UNKNOWN_RARITY');
|
|
82
|
+
});
|
|
83
|
+
it('every floor is below its rarity ceiling (6 × cap)', () => {
|
|
84
|
+
for (const rarity of [RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL]) {
|
|
85
|
+
expect(getImprovementFloor(rarity)).toBeLessThan(6 * getStatCap(rarity));
|
|
86
|
+
}
|
|
56
87
|
});
|
|
57
88
|
});
|
|
58
89
|
// ─── getCoachMultiplier ───────────────────────────────────────────────────────
|