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.
@@ -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
- export declare function getImprovementFloor(rarity: Rarity): number;
7
- export declare function getImprovementThreshold(statTotal: number, rarity: Rarity): number;
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.getImprovementFloor = getImprovementFloor;
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 (normalize-to-band) ───────────────────────────
16
- // The exp a player needs to gain +1 stat. Every rarity improves on ONE shared curve, but measured against
17
- // ITS OWN range: a freshly generated player sits at the floor (improves the fastest) and the stat cap is the
18
- // ceiling (improves the slowest). `frac` is the player's position from floor to ceiling, so the same relative
19
- // progress costs the same for every rarity -- rarer players are no longer penalised for their higher skill
20
- // floor (which is what the old rarity-multiplier divisor failed to offset). Threshold runs from CURVE_BASE at
21
- // the floor to CURVE_BASE + CURVE_SCALAR at the cap; CURVE_EXPONENT keeps players fast through most of the
22
- // band and slows them sharply only near the cap.
23
- const CURVE_BASE = 180;
24
- const CURVE_SCALAR = 800;
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
- // Per-rarity floor of the curve: the mean generated core-6 total (attack + serve + block + reception +
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
- function getImprovementFloor(rarity) {
43
- const floor = IMPROVEMENT_FLOOR[rarity];
44
- if (floor == null)
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
- return floor;
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 getImprovementThreshold(statTotal, rarity) {
49
- const floor = getImprovementFloor(rarity);
50
- const span = 6 * getStatCap(rarity) - floor;
51
- const frac = span <= 0 ? 1 : Math.max(0, Math.min(1, (statTotal - floor) / span));
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 the floor the threshold is CURVE_BASE (180) the fastest pace', () => {
26
- // A freshly generated player sits ~at the floor frac 0 threshold = 180 for every rarity.
27
- (0, globals_1.expect)((0, improvement_1.getImprovementThreshold)((0, improvement_1.getImprovementFloor)(rarity_1.RarityEnum.COMMON), rarity_1.RarityEnum.COMMON)).toBe(180);
28
- (0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(0, rarity_1.RarityEnum.SPECIAL)).toBe(180);
29
- });
30
- (0, globals_1.it)('at the ceiling (6 × cap) the threshold is CURVE_BASE + CURVE_SCALAR (980) — the slowest pace', () => {
31
- (0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(6 * (0, improvement_1.getStatCap)(rarity_1.RarityEnum.COMMON), rarity_1.RarityEnum.COMMON)).toBe(980);
32
- (0, globals_1.expect)((0, improvement_1.getImprovementThreshold)(6 * (0, improvement_1.getStatCap)(rarity_1.RarityEnum.SPECIAL), rarity_1.RarityEnum.SPECIAL)).toBe(980);
33
- });
34
- (0, globals_1.it)('threshold increases as statTotal climbs from the floor toward the cap', () => {
35
- const floor = (0, improvement_1.getImprovementFloor)(rarity_1.RarityEnum.COMMON);
36
- const low = (0, improvement_1.getImprovementThreshold)(floor + 10, rarity_1.RarityEnum.COMMON);
37
- const high = (0, improvement_1.getImprovementThreshold)(floor + 120, rarity_1.RarityEnum.COMMON);
38
- (0, globals_1.expect)(high).toBeGreaterThan(low);
39
- });
40
- (0, globals_1.it)('is equivalent across rarities at the same position within their band', () => {
41
- // Halfway from floor to ceiling must cost the same for every rarity — the whole point of the rework.
42
- const midThreshold = (rarity) => {
43
- const floor = (0, improvement_1.getImprovementFloor)(rarity);
44
- const mid = floor + (6 * (0, improvement_1.getStatCap)(rarity) - floor) / 2;
45
- return (0, improvement_1.getImprovementThreshold)(mid, rarity);
46
- };
47
- const common = midThreshold(rarity_1.RarityEnum.COMMON);
48
- (0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.RARE)).toBe(common);
49
- (0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.LEGENDARY)).toBe(common);
50
- (0, globals_1.expect)(midThreshold(rarity_1.RarityEnum.SPECIAL)).toBe(common);
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)(200, rarity_1.RarityEnum.RARE))).toBe(true);
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
- // ─── getImprovementFloor ──────────────────────────────────────────────────────
57
- (0, globals_1.describe)('getImprovementFloor()', () => {
58
- globals_1.it.each([
59
- [rarity_1.RarityEnum.COMMON, 180],
60
- [rarity_1.RarityEnum.RARE, 296],
61
- [rarity_1.RarityEnum.LEGENDARY, 346],
62
- [rarity_1.RarityEnum.MYTHIC, 432],
63
- [rarity_1.RarityEnum.SPECIAL, 501]
64
- ])('%s floor %i', (rarity, expected) => {
65
- (0, globals_1.expect)((0, improvement_1.getImprovementFloor)(rarity)).toBe(expected);
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.getImprovementFloor)('DIVINE')).toThrow('UNKNOWN_RARITY');
134
+ (0, globals_1.expect)(() => (0, improvement_1.getImprovementFloors)('DIVINE', role_1.RoleEnum.SETTER)).toThrow('UNKNOWN_RARITY');
69
135
  });
70
- (0, globals_1.it)('every floor is below its rarity ceiling (6 × cap)', () => {
71
- for (const rarity of [rarity_1.RarityEnum.COMMON, rarity_1.RarityEnum.RARE, rarity_1.RarityEnum.LEGENDARY, rarity_1.RarityEnum.MYTHIC, rarity_1.RarityEnum.SPECIAL]) {
72
- (0, globals_1.expect)((0, improvement_1.getImprovementFloor)(rarity)).toBeLessThan(6 * (0, improvement_1.getStatCap)(rarity));
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
- zod_1.z.object({ type: zod_1.z.literal(pinch_condition_1.PinchConditionType.AFTER_ROTATIONS), rotations: zod_1.z.number().int().min(1).max(6) }),
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: 7 }).success).toBe(false);
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);