volleyballsimtypes 0.0.493 → 0.0.495
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/models/competition.d.ts +7 -1
- package/dist/cjs/src/service/player/improvement.d.ts +30 -2
- package/dist/cjs/src/service/player/improvement.js +113 -48
- package/dist/cjs/src/service/player/improvement.test.js +118 -41
- package/dist/cjs/src/service/team/schemas/designated-sub.z.js +5 -1
- package/dist/cjs/src/service/team/schemas/designated-sub.z.test.js +3 -1
- package/dist/cjs/src/stat-config/improvement-floors.json +394 -0
- package/dist/cjs/src/stat-config/improvement-floors.test.d.ts +1 -0
- package/dist/cjs/src/stat-config/improvement-floors.test.js +78 -0
- package/dist/cjs/src/stat-config/index.d.ts +2 -1
- package/dist/cjs/src/stat-config/index.js +5 -1
- package/dist/esm/src/data/models/competition.d.ts +7 -1
- package/dist/esm/src/service/player/improvement.d.ts +30 -2
- package/dist/esm/src/service/player/improvement.js +111 -48
- package/dist/esm/src/service/player/improvement.test.js +120 -43
- package/dist/esm/src/service/team/schemas/designated-sub.z.js +5 -1
- package/dist/esm/src/service/team/schemas/designated-sub.z.test.js +3 -1
- package/dist/esm/src/stat-config/improvement-floors.json +394 -0
- package/dist/esm/src/stat-config/improvement-floors.test.d.ts +1 -0
- package/dist/esm/src/stat-config/improvement-floors.test.js +76 -0
- package/dist/esm/src/stat-config/index.d.ts +2 -1
- package/dist/esm/src/stat-config/index.js +4 -1
- package/package.json +7 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Sequelize from 'sequelize';
|
|
2
2
|
import { Model } from 'sequelize';
|
|
3
|
-
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
3
|
+
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, EventCompetitionAttributes, EventCompetitionId, EventCompetitionModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
4
4
|
import { Status } from '../common';
|
|
5
5
|
export interface CompetitionAttributes {
|
|
6
6
|
competition_id: string;
|
|
@@ -15,6 +15,8 @@ export interface CompetitionAttributes {
|
|
|
15
15
|
CompetitionTeams?: CompetitionTeamsAttributes[];
|
|
16
16
|
RegionQualifier?: RegionQualifierAttributes;
|
|
17
17
|
NationalCountry?: NationalCountryAttributes;
|
|
18
|
+
/** Set only on an EVENT competition: which event it belongs to, and which stage of it. */
|
|
19
|
+
EventCompetition?: EventCompetitionAttributes;
|
|
18
20
|
UpperPromotionMatches?: PromotionMatchAttributes[];
|
|
19
21
|
LowerPromotionMatches?: PromotionMatchAttributes[];
|
|
20
22
|
}
|
|
@@ -72,6 +74,10 @@ export declare class CompetitionModel extends Model<CompetitionAttributes, Compe
|
|
|
72
74
|
getNationalCountry: Sequelize.HasOneGetAssociationMixin<NationalCountryModel>;
|
|
73
75
|
setNationalCountry: Sequelize.HasOneSetAssociationMixin<NationalCountryModel, NationalCountryId>;
|
|
74
76
|
createNationalCountry: Sequelize.HasOneCreateAssociationMixin<NationalCountryModel>;
|
|
77
|
+
EventCompetition: EventCompetitionModel;
|
|
78
|
+
getEventCompetition: Sequelize.HasOneGetAssociationMixin<EventCompetitionModel>;
|
|
79
|
+
setEventCompetition: Sequelize.HasOneSetAssociationMixin<EventCompetitionModel, EventCompetitionId>;
|
|
80
|
+
createEventCompetition: Sequelize.HasOneCreateAssociationMixin<EventCompetitionModel>;
|
|
75
81
|
UpperPromotionMatches: PromotionMatchModel[];
|
|
76
82
|
getUpperPromotionMatches: Sequelize.HasManyGetAssociationsMixin<PromotionMatchModel>;
|
|
77
83
|
setUpperPromotionMatches: Sequelize.HasManySetAssociationsMixin<PromotionMatchModel, PromotionMatchId>;
|
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
import { Rarity } from './rarity';
|
|
2
|
+
import { Role } from './role';
|
|
2
3
|
import { Stats } from './stats';
|
|
3
4
|
import { DeclineProfile } from './decline';
|
|
4
5
|
export type TrainingFocus = Stats;
|
|
6
|
+
export declare const FOCUS_WEIGHT = 0.5;
|
|
7
|
+
interface ImprovementFloors {
|
|
8
|
+
/** Mean generated sum of all 18 performance stats. */
|
|
9
|
+
all: number;
|
|
10
|
+
/** Mean generated sum of each training focus's pool, keyed by focus name. */
|
|
11
|
+
focus: Record<string, number>;
|
|
12
|
+
}
|
|
5
13
|
export declare function getStatCap(rarity: Rarity): number;
|
|
6
|
-
|
|
7
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The DB column names of a training focus's pool: exactly the stats `selectImprovableStat` can raise under
|
|
16
|
+
* that focus, so the band and the pool are the same set by construction.
|
|
17
|
+
*/
|
|
18
|
+
export declare function focusPoolColumns(focus: TrainingFocus): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Where a freshly generated player of this rarity and role starts, in both bands.
|
|
21
|
+
*
|
|
22
|
+
* Roles matter: at LEGENDARY the mean generated 18-stat sum runs 846 for an outside hitter against 1099 for a
|
|
23
|
+
* libero, a fifth of the band, so one floor per rarity would hand some roles a free head start. An unknown
|
|
24
|
+
* role falls back to the mean across that rarity's roles rather than throwing, because a player whose role
|
|
25
|
+
* enum has changed underneath us should still be able to train.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getImprovementFloors(rarity: Rarity, role: Role): ImprovementFloors;
|
|
28
|
+
/**
|
|
29
|
+
* Exp needed for this player's next +1, before the coach multiplier.
|
|
30
|
+
*
|
|
31
|
+
* `stats` is keyed by DB COLUMN name (back_attack, not backAttack), the same shape `selectImprovableStat`
|
|
32
|
+
* takes, so a caller passes one object to both.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getImprovementThreshold(stats: Record<string, number>, rarity: Rarity, role: Role, focus: TrainingFocus): number;
|
|
8
35
|
export declare function getCoachMultiplier(rarity: Rarity): number;
|
|
9
36
|
export declare function matchExperience(pointsPlayed: number): number;
|
|
10
37
|
export declare function experienceGained(pointsPlayed: number, age: number, profile: DeclineProfile): number;
|
|
11
38
|
export declare function selectImprovableStat(rarity: Rarity, trainingFocus: TrainingFocus, currentStats: Record<string, number>): string | null;
|
|
39
|
+
export {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FOCUS_WEIGHT = void 0;
|
|
3
4
|
exports.getStatCap = getStatCap;
|
|
4
|
-
exports.
|
|
5
|
+
exports.focusPoolColumns = focusPoolColumns;
|
|
6
|
+
exports.getImprovementFloors = getImprovementFloors;
|
|
5
7
|
exports.getImprovementThreshold = getImprovementThreshold;
|
|
6
8
|
exports.getCoachMultiplier = getCoachMultiplier;
|
|
7
9
|
exports.matchExperience = matchExperience;
|
|
@@ -12,43 +14,126 @@ const rarity_1 = require("./rarity");
|
|
|
12
14
|
const performance_stats_1 = require("./performance-stats");
|
|
13
15
|
const stats_1 = require("./stats");
|
|
14
16
|
const decline_1 = require("./decline");
|
|
15
|
-
// ── Improvement threshold curve (
|
|
16
|
-
// The exp a player needs to gain +1 stat
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// ── Improvement threshold curve (two normalized bands) ────────────────────────
|
|
18
|
+
// The exp a player needs to gain +1 stat, from their position in TWO bands measured as raw stat sums:
|
|
19
|
+
//
|
|
20
|
+
// focus band the stats in the coach's training-focus pool [floor, poolSize * cap]
|
|
21
|
+
// all-18 band every performance stat [floor, 18 * cap]
|
|
22
|
+
//
|
|
23
|
+
// Both floors are the mean a freshly generated player of that rarity AND role spawns with (see
|
|
24
|
+
// improvement-floors.json), so a fresh player sits at the bottom of both and improves fastest.
|
|
25
|
+
//
|
|
26
|
+
// Why the focus band: the price must respond to what is actually being trained. The previous curve read only
|
|
27
|
+
// the core-6 sum (attack + serve + block + reception + setting + stamina), but the stat that goes up is picked
|
|
28
|
+
// from the focus pool, and most pools barely touch those six. Production measured 74% of all improvements
|
|
29
|
+
// landing on stats the price could not see, so a typical player gained ~4.5 stat points per point of price
|
|
30
|
+
// escalation while a STAMINA-focus player paid full price every time. A focus pool is exactly the keys
|
|
31
|
+
// getMultipliers gives that focus, the same set selectImprovableStat can raise, so the two cannot drift apart
|
|
32
|
+
// and every upgrade a focus can produce now moves its band by exactly 1.
|
|
33
|
+
//
|
|
34
|
+
// Why sums and not the weighted composite (calculateStatScore): with a sum every upgrade moves the band by
|
|
35
|
+
// exactly 1, so exp per raw stat point comes out equal across focuses (measured 443/445/446/443/444/448). The
|
|
36
|
+
// weighted composite put those 16x apart, because a 6-stat pool moves a weighted average six times slower than
|
|
37
|
+
// a 1-stat pool. Sums are also integers, so no rounding step and no dependence on the conditional power/quick
|
|
38
|
+
// and read/commit weight swap.
|
|
39
|
+
//
|
|
40
|
+
// Why the all-18 band on top: the focus band alone is resettable by switching focus, so rotating the coach
|
|
41
|
+
// focus would be strictly dominant (measured 294 lifetime stat points against a specialist's 176). The all-18
|
|
42
|
+
// sum is monotone under every strategy and cannot be reset. FOCUS_WEIGHT blends the two, trading "training a
|
|
43
|
+
// strength is expensive" against "the focus strategy should not decide a career"; they are the same lever and
|
|
44
|
+
// cannot be separated. At 0.5 the strategy spread is 1.67x and the focus price ratio 1.57x.
|
|
45
|
+
//
|
|
46
|
+
// Threshold runs from CURVE_BASE at both floors to CURVE_BASE + CURVE_SCALAR at both ceilings; CURVE_EXPONENT
|
|
47
|
+
// keeps players fast through most of the band and slows them sharply only near the top.
|
|
48
|
+
//
|
|
49
|
+
// BASE and SCALAR were retuned from 180/800 to 130/400 when the second band was added, to hold career-long
|
|
50
|
+
// development at its previous level. Pricing every focus-pool upgrade instead of only the ~26% that touched
|
|
51
|
+
// the core 6 makes the average player climb their curve far faster, and at the old 180/800 that cut career
|
|
52
|
+
// stat gains by ~32% across every rarity (measured: ~253 lifetime ups down to ~176). That would have been a
|
|
53
|
+
// large balance change nobody asked for on top of the fix. At 130/400 lifetime ups land at ~247, within a few
|
|
54
|
+
// percent of the old curve, so the only behaviour that actually changes is WHICH stats drive the price.
|
|
55
|
+
const CURVE_BASE = 130;
|
|
56
|
+
const CURVE_SCALAR = 400;
|
|
25
57
|
const CURVE_EXPONENT = 2;
|
|
26
|
-
|
|
27
|
-
// setting + stamina) for that rarity, measured from PlayerGenerator. The ceiling is 6 * cap.
|
|
28
|
-
const IMPROVEMENT_FLOOR = {
|
|
29
|
-
[rarity_1.RarityEnum.COMMON]: 180,
|
|
30
|
-
[rarity_1.RarityEnum.RARE]: 296,
|
|
31
|
-
[rarity_1.RarityEnum.LEGENDARY]: 346,
|
|
32
|
-
[rarity_1.RarityEnum.MYTHIC]: 432,
|
|
33
|
-
[rarity_1.RarityEnum.SPECIAL]: 501
|
|
34
|
-
};
|
|
58
|
+
exports.FOCUS_WEIGHT = 0.5;
|
|
35
59
|
const statCaps = stat_config_1.statCapsJSON;
|
|
60
|
+
const floorEntries = stat_config_1.improvementFloorsJSON.floors;
|
|
61
|
+
const KEY_TO_COLUMN = {
|
|
62
|
+
setting: 'setting',
|
|
63
|
+
serve: 'serve',
|
|
64
|
+
spike: 'spike',
|
|
65
|
+
quick: 'quick',
|
|
66
|
+
power: 'power',
|
|
67
|
+
awareness: 'awareness',
|
|
68
|
+
attack: 'attack',
|
|
69
|
+
backAttack: 'back_attack',
|
|
70
|
+
reception: 'reception',
|
|
71
|
+
overhand: 'overhand',
|
|
72
|
+
bump: 'bump',
|
|
73
|
+
block: 'block',
|
|
74
|
+
read: 'read',
|
|
75
|
+
commit: 'commit',
|
|
76
|
+
focus: 'focus',
|
|
77
|
+
defense: 'defense',
|
|
78
|
+
stamina: 'stamina',
|
|
79
|
+
reflex: 'reflex'
|
|
80
|
+
};
|
|
81
|
+
const ALL_COLUMNS = performance_stats_1.performanceStatKeys.map(key => KEY_TO_COLUMN[key]);
|
|
36
82
|
function getStatCap(rarity) {
|
|
37
83
|
const entry = statCaps.find(e => e.rarity === rarity);
|
|
38
84
|
if (entry == null)
|
|
39
85
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
40
86
|
return entry.cap;
|
|
41
87
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The DB column names of a training focus's pool: exactly the stats `selectImprovableStat` can raise under
|
|
90
|
+
* that focus, so the band and the pool are the same set by construction.
|
|
91
|
+
*/
|
|
92
|
+
function focusPoolColumns(focus) {
|
|
93
|
+
return Object.keys((0, stats_1.getMultipliers)(focus)).map(key => KEY_TO_COLUMN[key]);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Where a freshly generated player of this rarity and role starts, in both bands.
|
|
97
|
+
*
|
|
98
|
+
* Roles matter: at LEGENDARY the mean generated 18-stat sum runs 846 for an outside hitter against 1099 for a
|
|
99
|
+
* libero, a fifth of the band, so one floor per rarity would hand some roles a free head start. An unknown
|
|
100
|
+
* role falls back to the mean across that rarity's roles rather than throwing, because a player whose role
|
|
101
|
+
* enum has changed underneath us should still be able to train.
|
|
102
|
+
*/
|
|
103
|
+
function getImprovementFloors(rarity, role) {
|
|
104
|
+
const exact = floorEntries.find(e => e.rarity === rarity && e.role === role);
|
|
105
|
+
if (exact != null)
|
|
106
|
+
return { all: exact.all, focus: exact.focus };
|
|
107
|
+
const forRarity = floorEntries.filter(e => e.rarity === rarity);
|
|
108
|
+
if (forRarity.length === 0)
|
|
45
109
|
throw new Error(`UNKNOWN_RARITY: ${rarity}`);
|
|
46
|
-
|
|
110
|
+
const focus = {};
|
|
111
|
+
for (const key of Object.keys(forRarity[0].focus)) {
|
|
112
|
+
focus[key] = forRarity.reduce((sum, e) => sum + e.focus[key], 0) / forRarity.length;
|
|
113
|
+
}
|
|
114
|
+
return { all: forRarity.reduce((sum, e) => sum + e.all, 0) / forRarity.length, focus };
|
|
47
115
|
}
|
|
48
|
-
function
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
116
|
+
function bandFraction(value, floor, ceiling) {
|
|
117
|
+
const span = ceiling - floor;
|
|
118
|
+
if (span <= 0)
|
|
119
|
+
return 1;
|
|
120
|
+
return Math.max(0, Math.min(1, (value - floor) / span));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Exp needed for this player's next +1, before the coach multiplier.
|
|
124
|
+
*
|
|
125
|
+
* `stats` is keyed by DB COLUMN name (back_attack, not backAttack), the same shape `selectImprovableStat`
|
|
126
|
+
* takes, so a caller passes one object to both.
|
|
127
|
+
*/
|
|
128
|
+
function getImprovementThreshold(stats, rarity, role, focus) {
|
|
129
|
+
const cap = getStatCap(rarity);
|
|
130
|
+
const floors = getImprovementFloors(rarity, role);
|
|
131
|
+
const poolColumns = focusPoolColumns(focus);
|
|
132
|
+
const focusSum = poolColumns.reduce((sum, column) => sum + (stats[column] ?? 0), 0);
|
|
133
|
+
const focusFrac = bandFraction(focusSum, floors.focus[focus] ?? 0, poolColumns.length * cap);
|
|
134
|
+
const allSum = ALL_COLUMNS.reduce((sum, column) => sum + (stats[column] ?? 0), 0);
|
|
135
|
+
const allFrac = bandFraction(allSum, floors.all, ALL_COLUMNS.length * cap);
|
|
136
|
+
const frac = exports.FOCUS_WEIGHT * focusFrac + (1 - exports.FOCUS_WEIGHT) * allFrac;
|
|
52
137
|
return Math.round(CURVE_BASE + CURVE_SCALAR * Math.pow(frac, CURVE_EXPONENT));
|
|
53
138
|
}
|
|
54
139
|
const COACH_MULTIPLIERS = {
|
|
@@ -81,26 +166,6 @@ function matchExperience(pointsPlayed) {
|
|
|
81
166
|
function experienceGained(pointsPlayed, age, profile) {
|
|
82
167
|
return Math.round(matchExperience(pointsPlayed) * (0, decline_1.youthMultiplier)(age, profile));
|
|
83
168
|
}
|
|
84
|
-
const KEY_TO_COLUMN = {
|
|
85
|
-
setting: 'setting',
|
|
86
|
-
serve: 'serve',
|
|
87
|
-
spike: 'spike',
|
|
88
|
-
quick: 'quick',
|
|
89
|
-
power: 'power',
|
|
90
|
-
awareness: 'awareness',
|
|
91
|
-
attack: 'attack',
|
|
92
|
-
backAttack: 'back_attack',
|
|
93
|
-
reception: 'reception',
|
|
94
|
-
overhand: 'overhand',
|
|
95
|
-
bump: 'bump',
|
|
96
|
-
block: 'block',
|
|
97
|
-
read: 'read',
|
|
98
|
-
commit: 'commit',
|
|
99
|
-
focus: 'focus',
|
|
100
|
-
defense: 'defense',
|
|
101
|
-
stamina: 'stamina',
|
|
102
|
-
reflex: 'reflex'
|
|
103
|
-
};
|
|
104
169
|
function selectImprovableStat(rarity, trainingFocus, currentStats) {
|
|
105
170
|
const cap = getStatCap(rarity);
|
|
106
171
|
const multipliers = (0, stats_1.getMultipliers)(trainingFocus);
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const globals_1 = require("@jest/globals");
|
|
4
4
|
const rarity_1 = require("./rarity");
|
|
5
|
+
const role_1 = require("./role");
|
|
5
6
|
const improvement_1 = require("./improvement");
|
|
6
7
|
const stats_1 = require("./stats");
|
|
7
8
|
const decline_1 = require("./decline");
|
|
9
|
+
const ALL_RARITIES = [rarity_1.RarityEnum.COMMON, rarity_1.RarityEnum.RARE, rarity_1.RarityEnum.LEGENDARY, rarity_1.RarityEnum.MYTHIC, rarity_1.RarityEnum.SPECIAL];
|
|
10
|
+
const ALL_FOCI = [stats_1.StatsEnum.ATTACK, stats_1.StatsEnum.SET, stats_1.StatsEnum.RECEIVE, stats_1.StatsEnum.SERVE, stats_1.StatsEnum.BLOCK, stats_1.StatsEnum.STAMINA];
|
|
11
|
+
const ALL_ROLES = [role_1.RoleEnum.SETTER, role_1.RoleEnum.OUTSIDE_HITTER, role_1.RoleEnum.MIDDLE_BLOCKER, role_1.RoleEnum.OPPOSITE_HITTER, role_1.RoleEnum.LIBERO];
|
|
8
12
|
// ─── getStatCap ───────────────────────────────────────────────────────────────
|
|
9
13
|
(0, globals_1.describe)('getStatCap()', () => {
|
|
10
14
|
globals_1.it.each([
|
|
@@ -22,56 +26,129 @@ const decline_1 = require("./decline");
|
|
|
22
26
|
});
|
|
23
27
|
// ─── getImprovementThreshold ──────────────────────────────────────────────────
|
|
24
28
|
(0, globals_1.describe)('getImprovementThreshold()', () => {
|
|
25
|
-
(0, globals_1.it)('at or below
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
34
|
-
(0, globals_1.it)('
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
(
|
|
49
|
-
(0, globals_1.expect)(
|
|
50
|
-
|
|
29
|
+
(0, globals_1.it)('at or below both floors the threshold is CURVE_BASE (130), the fastest pace, for every rarity and focus', () => {
|
|
30
|
+
// Zeros sit under both floors, so both fracs clamp to 0. A freshly generated player is at ~this point.
|
|
31
|
+
for (const rarity of ALL_RARITIES) {
|
|
32
|
+
for (const focus of ALL_FOCI) {
|
|
33
|
+
(0, globals_1.expect)(`${rarity}/${focus}: ${(0, improvement_1.getImprovementThreshold)(allStats(0), rarity, role_1.RoleEnum.SETTER, focus)}`)
|
|
34
|
+
.toBe(`${rarity}/${focus}: 130`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
(0, globals_1.it)('at both ceilings the threshold is CURVE_BASE + CURVE_SCALAR (530), the slowest pace', () => {
|
|
39
|
+
// Every stat at the cap puts the focus pool at poolSize * cap and the sheet at 18 * cap: both fracs = 1.
|
|
40
|
+
for (const rarity of ALL_RARITIES) {
|
|
41
|
+
for (const focus of ALL_FOCI) {
|
|
42
|
+
(0, globals_1.expect)(`${rarity}/${focus}: ${(0, improvement_1.getImprovementThreshold)(allStats((0, improvement_1.getStatCap)(rarity)), rarity, role_1.RoleEnum.SETTER, focus)}`)
|
|
43
|
+
.toBe(`${rarity}/${focus}: 530`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
(0, globals_1.it)('weights the focus band by FOCUS_WEIGHT and the all-18 band by the remainder', () => {
|
|
48
|
+
// STAMINA's pool is the single `stamina` stat, so capping it alone maxes the focus band while leaving the
|
|
49
|
+
// 18-stat sum (= cap) far under the all-18 floor. frac = FOCUS_WEIGHT * 1 + (1 - FOCUS_WEIGHT) * 0.
|
|
50
|
+
const cap = (0, improvement_1.getStatCap)(rarity_1.RarityEnum.LEGENDARY);
|
|
51
|
+
const stats = { ...allStats(0), stamina: cap };
|
|
52
|
+
const expected = Math.round(130 + 400 * Math.pow(improvement_1.FOCUS_WEIGHT, 2));
|
|
53
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(stats, rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.SETTER, stats_1.StatsEnum.STAMINA)).toBe(expected);
|
|
54
|
+
});
|
|
55
|
+
(0, globals_1.it)('consults the all-18 band too, not only the focus pool', () => {
|
|
56
|
+
// Focus pool parked at zero, everything else at the cap: the focus band contributes nothing, so any rise
|
|
57
|
+
// above CURVE_BASE can only have come from the all-18 band.
|
|
58
|
+
const cap = (0, improvement_1.getStatCap)(rarity_1.RarityEnum.LEGENDARY);
|
|
59
|
+
const stats = { ...allStats(cap), stamina: 0 };
|
|
60
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(stats, rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.SETTER, stats_1.StatsEnum.STAMINA))
|
|
61
|
+
.toBeGreaterThan(130);
|
|
62
|
+
});
|
|
63
|
+
(0, globals_1.it)('never decreases after a +1 on any stat, so training can never make the next step cheaper', () => {
|
|
64
|
+
// A non-monotone curve would be a free-exp loop. Both bands are plain sums, so this must hold everywhere.
|
|
65
|
+
const columns = Object.keys(allStats(0));
|
|
66
|
+
for (const rarity of ALL_RARITIES) {
|
|
67
|
+
const cap = (0, improvement_1.getStatCap)(rarity);
|
|
68
|
+
for (const focus of ALL_FOCI) {
|
|
69
|
+
for (let trial = 0; trial < 40; trial++) {
|
|
70
|
+
const stats = {};
|
|
71
|
+
for (const column of columns)
|
|
72
|
+
stats[column] = 1 + Math.floor(Math.random() * cap);
|
|
73
|
+
const before = (0, improvement_1.getImprovementThreshold)(stats, rarity, role_1.RoleEnum.LIBERO, focus);
|
|
74
|
+
for (const column of columns) {
|
|
75
|
+
const old = stats[column];
|
|
76
|
+
stats[column] = old + 1;
|
|
77
|
+
const after = (0, improvement_1.getImprovementThreshold)(stats, rarity, role_1.RoleEnum.LIBERO, focus);
|
|
78
|
+
stats[column] = old;
|
|
79
|
+
(0, globals_1.expect)(`${rarity}/${focus}/${column}: ${after >= before ? 'ok' : `DROPPED ${before} -> ${after}`}`)
|
|
80
|
+
.toBe(`${rarity}/${focus}/${column}: ok`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
(0, globals_1.it)('prices the same stats differently by role, because the floors are per role', () => {
|
|
87
|
+
// At LEGENDARY an outside hitter's all-18 floor is far below a libero's, so identical stats leave the OH
|
|
88
|
+
// further up their band and therefore more expensive to train.
|
|
89
|
+
const stats = allStats(50);
|
|
90
|
+
const outsideHitter = (0, improvement_1.getImprovementThreshold)(stats, rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.OUTSIDE_HITTER, stats_1.StatsEnum.ATTACK);
|
|
91
|
+
const libero = (0, improvement_1.getImprovementThreshold)(stats, rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.LIBERO, stats_1.StatsEnum.ATTACK);
|
|
92
|
+
(0, globals_1.expect)((0, improvement_1.getImprovementFloors)(rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.OUTSIDE_HITTER).all)
|
|
93
|
+
.toBeLessThan((0, improvement_1.getImprovementFloors)(rarity_1.RarityEnum.LEGENDARY, role_1.RoleEnum.LIBERO).all);
|
|
94
|
+
(0, globals_1.expect)(outsideHitter).toBeGreaterThan(libero);
|
|
51
95
|
});
|
|
52
96
|
(0, globals_1.it)('returns an integer (Math.round applied)', () => {
|
|
53
|
-
(0, globals_1.expect)(Number.isInteger((0, improvement_1.getImprovementThreshold)(
|
|
97
|
+
(0, globals_1.expect)(Number.isInteger((0, improvement_1.getImprovementThreshold)(allStats(40), rarity_1.RarityEnum.RARE, role_1.RoleEnum.SETTER, stats_1.StatsEnum.ATTACK))).toBe(true);
|
|
54
98
|
});
|
|
55
99
|
});
|
|
56
|
-
// ───
|
|
57
|
-
(0, globals_1.describe)('
|
|
58
|
-
globals_1.it
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
100
|
+
// ─── getImprovementFloors ─────────────────────────────────────────────────────
|
|
101
|
+
(0, globals_1.describe)('getImprovementFloors()', () => {
|
|
102
|
+
(0, globals_1.it)('returns an all-18 floor and one floor per training focus for every rarity and role', () => {
|
|
103
|
+
for (const rarity of ALL_RARITIES) {
|
|
104
|
+
for (const role of ALL_ROLES) {
|
|
105
|
+
const floors = (0, improvement_1.getImprovementFloors)(rarity, role);
|
|
106
|
+
(0, globals_1.expect)(floors.all).toBeGreaterThan(0);
|
|
107
|
+
for (const focus of ALL_FOCI) {
|
|
108
|
+
(0, globals_1.expect)(`${rarity}/${role}/${focus}: ${floors.focus[focus] > 0 ? 'present' : 'MISSING'}`)
|
|
109
|
+
.toBe(`${rarity}/${role}/${focus}: present`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
(0, globals_1.it)('keeps every floor below its ceiling, so no band is degenerate', () => {
|
|
115
|
+
for (const rarity of ALL_RARITIES) {
|
|
116
|
+
const cap = (0, improvement_1.getStatCap)(rarity);
|
|
117
|
+
for (const role of ALL_ROLES) {
|
|
118
|
+
const floors = (0, improvement_1.getImprovementFloors)(rarity, role);
|
|
119
|
+
(0, globals_1.expect)(floors.all).toBeLessThan(18 * cap);
|
|
120
|
+
for (const focus of ALL_FOCI) {
|
|
121
|
+
(0, globals_1.expect)(`${rarity}/${role}/${focus}`).toBe(`${rarity}/${role}/${focus}`);
|
|
122
|
+
(0, globals_1.expect)(floors.focus[focus]).toBeLessThan((0, improvement_1.focusPoolColumns)(focus).length * cap);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
(0, globals_1.it)('falls back to the rarity mean for an unknown role rather than throwing', () => {
|
|
128
|
+
// A player whose role enum changed underneath us must still be able to train.
|
|
129
|
+
const fallback = (0, improvement_1.getImprovementFloors)(rarity_1.RarityEnum.LEGENDARY, 'WING_SPIKER');
|
|
130
|
+
const known = ALL_ROLES.map(role => (0, improvement_1.getImprovementFloors)(rarity_1.RarityEnum.LEGENDARY, role).all);
|
|
131
|
+
(0, globals_1.expect)(fallback.all).toBeCloseTo(known.reduce((a, b) => a + b, 0) / known.length, 6);
|
|
66
132
|
});
|
|
67
133
|
(0, globals_1.it)('throws UNKNOWN_RARITY for an unrecognised rarity string', () => {
|
|
68
|
-
(0, globals_1.expect)(() => (0, improvement_1.
|
|
134
|
+
(0, globals_1.expect)(() => (0, improvement_1.getImprovementFloors)('DIVINE', role_1.RoleEnum.SETTER)).toThrow('UNKNOWN_RARITY');
|
|
69
135
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
});
|
|
137
|
+
// ─── focusPoolColumns ─────────────────────────────────────────────────────────
|
|
138
|
+
(0, globals_1.describe)('focusPoolColumns()', () => {
|
|
139
|
+
(0, globals_1.it)('is exactly the set of stats that focus can raise, so band and pool cannot drift apart', () => {
|
|
140
|
+
for (const focus of ALL_FOCI) {
|
|
141
|
+
const fromMultipliers = Object.keys((0, stats_1.getMultipliers)(focus))
|
|
142
|
+
.map(key => (key === 'backAttack' ? 'back_attack' : key))
|
|
143
|
+
.sort();
|
|
144
|
+
(0, globals_1.expect)(`${focus}: ${(0, improvement_1.focusPoolColumns)(focus).slice().sort().join(',')}`)
|
|
145
|
+
.toBe(`${focus}: ${fromMultipliers.join(',')}`);
|
|
73
146
|
}
|
|
74
147
|
});
|
|
148
|
+
(0, globals_1.it)('uses DB column names, not PerformanceStats keys', () => {
|
|
149
|
+
(0, globals_1.expect)((0, improvement_1.focusPoolColumns)(stats_1.StatsEnum.ATTACK)).toContain('back_attack');
|
|
150
|
+
(0, globals_1.expect)((0, improvement_1.focusPoolColumns)(stats_1.StatsEnum.ATTACK)).not.toContain('backAttack');
|
|
151
|
+
});
|
|
75
152
|
});
|
|
76
153
|
// ─── getCoachMultiplier ───────────────────────────────────────────────────────
|
|
77
154
|
(0, globals_1.describe)('getCoachMultiplier()', () => {
|
|
@@ -17,7 +17,11 @@ exports.PinchConditionSchema = zod_1.z.discriminatedUnion('type', [
|
|
|
17
17
|
// SCORE_DIFF / MIN_OWN_SCORE / MIN_OPPONENT_SCORE carry an optional comparison direction ("Or more" AT_LEAST,
|
|
18
18
|
// the default; "Or less" AT_MOST). Absent = AT_LEAST so existing conditions keep their `>=` behavior.
|
|
19
19
|
zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.SCORE_DIFF), direction: zod_1.z.nativeEnum(pinch_condition_1.ScoreDirection), points: zod_1.z.number().int().min(1).max(25), comparison: zod_1.z.nativeEnum(pinch_condition_1.Comparison).optional() }),
|
|
20
|
-
|
|
20
|
+
// `rotations` counts the team's SERVE TURNS in the set (CourtTeam.gainServe, incremented once per sideout
|
|
21
|
+
// won), not a rotation position, so it is NOT bounded by the six rotation slots. Prod: a team averages 14.1
|
|
22
|
+
// serve turns per set, p99 22, max 31, and 99.4% of team-sets exceed 6. The old max of 6 made the condition
|
|
23
|
+
// unable to express anything past the first third of a set.
|
|
24
|
+
zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.AFTER_ROTATIONS), rotations: zod_1.z.number().int().min(1).max(99) }),
|
|
21
25
|
zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.FROM_SET), set: zod_1.z.number().int().min(1).max(5) }),
|
|
22
26
|
zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.MIN_OWN_SCORE), score: zod_1.z.number().int().min(0).max(40), comparison: zod_1.z.nativeEnum(pinch_condition_1.Comparison).optional() }),
|
|
23
27
|
zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.MIN_OPPONENT_SCORE), score: zod_1.z.number().int().min(0).max(40), comparison: zod_1.z.nativeEnum(pinch_condition_1.Comparison).optional() }),
|
|
@@ -32,6 +32,8 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
32
32
|
{ type: pinch_condition_1.PinchConditionType.OPPONENT_SET_POINT, margin: 1 },
|
|
33
33
|
{ type: pinch_condition_1.PinchConditionType.SCORE_DIFF, direction: pinch_condition_1.ScoreDirection.TRAILING, points: 3 },
|
|
34
34
|
{ type: pinch_condition_1.PinchConditionType.AFTER_ROTATIONS, rotations: 1 },
|
|
35
|
+
// A team averages 14 serve turns per set, so a realistic late-set value must validate.
|
|
36
|
+
{ type: pinch_condition_1.PinchConditionType.AFTER_ROTATIONS, rotations: 22 },
|
|
35
37
|
{ type: pinch_condition_1.PinchConditionType.FROM_SET, set: 5 },
|
|
36
38
|
{ type: pinch_condition_1.PinchConditionType.MIN_OWN_SCORE, score: 20 },
|
|
37
39
|
{ type: pinch_condition_1.PinchConditionType.MIN_OPPONENT_SCORE, score: 20 }
|
|
@@ -51,7 +53,7 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
51
53
|
(0, globals_1.it)('enforces numeric bounds', () => {
|
|
52
54
|
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.TEAM_SET_POINT, margin: 0, opponent: pinch_condition_1.OpponentRelation.AHEAD }).success).toBe(false);
|
|
53
55
|
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.TEAM_SET_POINT, margin: 26, opponent: pinch_condition_1.OpponentRelation.AHEAD }).success).toBe(false);
|
|
54
|
-
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.AFTER_ROTATIONS, rotations:
|
|
56
|
+
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.AFTER_ROTATIONS, rotations: 100 }).success).toBe(false);
|
|
55
57
|
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.FROM_SET, set: 6 }).success).toBe(false);
|
|
56
58
|
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.MIN_OWN_SCORE, score: 41 }).success).toBe(false);
|
|
57
59
|
(0, globals_1.expect)(designated_sub_z_1.PinchConditionSchema.safeParse({ type: pinch_condition_1.PinchConditionType.SCORE_DIFF, direction: pinch_condition_1.ScoreDirection.TRAILING, points: 0 }).success).toBe(false);
|