volleyballsimtypes 0.0.494 → 0.0.496

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.
Files changed (27) hide show
  1. package/dist/cjs/src/service/competition/event-draft.d.ts +12 -27
  2. package/dist/cjs/src/service/competition/event-draft.js +16 -45
  3. package/dist/cjs/src/service/competition/event-draft.test.js +19 -35
  4. package/dist/cjs/src/service/player/improvement.d.ts +30 -2
  5. package/dist/cjs/src/service/player/improvement.js +119 -46
  6. package/dist/cjs/src/service/player/improvement.test.js +118 -41
  7. package/dist/cjs/src/service/team/schemas/designated-sub.z.js +5 -1
  8. package/dist/cjs/src/service/team/schemas/designated-sub.z.test.js +3 -1
  9. package/dist/cjs/src/stat-config/improvement-floors.json +394 -0
  10. package/dist/cjs/src/stat-config/improvement-floors.test.d.ts +1 -0
  11. package/dist/cjs/src/stat-config/improvement-floors.test.js +78 -0
  12. package/dist/cjs/src/stat-config/index.d.ts +2 -1
  13. package/dist/cjs/src/stat-config/index.js +5 -1
  14. package/dist/esm/src/service/competition/event-draft.d.ts +12 -27
  15. package/dist/esm/src/service/competition/event-draft.js +15 -44
  16. package/dist/esm/src/service/competition/event-draft.test.js +20 -36
  17. package/dist/esm/src/service/player/improvement.d.ts +30 -2
  18. package/dist/esm/src/service/player/improvement.js +117 -46
  19. package/dist/esm/src/service/player/improvement.test.js +120 -43
  20. package/dist/esm/src/service/team/schemas/designated-sub.z.js +5 -1
  21. package/dist/esm/src/service/team/schemas/designated-sub.z.test.js +3 -1
  22. package/dist/esm/src/stat-config/improvement-floors.json +394 -0
  23. package/dist/esm/src/stat-config/improvement-floors.test.d.ts +1 -0
  24. package/dist/esm/src/stat-config/improvement-floors.test.js +76 -0
  25. package/dist/esm/src/stat-config/index.d.ts +2 -1
  26. package/dist/esm/src/stat-config/index.js +4 -1
  27. package/package.json +7 -2
@@ -1,35 +1,19 @@
1
1
  import { Country } from '../country';
2
2
  import { Player, Rarity, Role } from '../player';
3
- /**
4
- * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
5
- * read by the view to label it, so it is a named constant rather than a bare string in three repos.
6
- */
7
- export declare const FREE_PACK_ROLE = "ANY";
8
- /** A pack targets one role, or the free pick. */
9
- export type DraftRole = Role | typeof FREE_PACK_ROLE;
10
- /** How many players a role-targeted pack offers. */
3
+ /** A pack targets one role, or ANY for the single free pick at the end. */
4
+ export type DraftRole = Role | 'ANY';
5
+ /** How many players an opened pack offers. */
11
6
  export declare const PACK_SIZE = 4;
12
7
  /**
13
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
14
- * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
15
- * fifteenth free pick on top of it.
8
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
9
+ * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
16
10
  *
17
11
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
18
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
19
- * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
20
- * slot the squad depends on.
12
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
21
13
  */
22
14
  export declare const DRAFT_PLAN: readonly DraftRole[];
23
15
  /** The size of a drafted squad: one pick per pack. */
24
16
  export declare const DRAFT_SQUAD_SIZE: number;
25
- /**
26
- * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
27
- * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
28
- * away from it.
29
- */
30
- export declare const TARGETED_PACK_COUNT: number;
31
- /** How many players the free pack offers: one per position. */
32
- export declare const FREE_PACK_SIZE: number;
33
17
  /**
34
18
  * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
35
19
  *
@@ -43,13 +27,14 @@ export declare const FREE_PACK_SIZE: number;
43
27
  */
44
28
  export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
45
29
  /**
46
- * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
47
- * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
30
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
31
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
32
+ * are left uniform.
48
33
  *
49
- * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
50
- * the best; legendaries are left uniform.
34
+ * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
35
+ * randomness (and a test can make it deterministic).
51
36
  */
52
- export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
37
+ export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole, pickRole: (roles: readonly Role[]) => Role): Player[];
53
38
  /** The role of the pack at `index`, or undefined once the draft is complete. */
54
39
  export declare function draftPackRole(index: number): DraftRole | undefined;
55
40
  export declare function isDraftComplete(picks: number): boolean;
@@ -1,61 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EVENT_DRAFT_TOP_SKEW = exports.FREE_PACK_SIZE = exports.TARGETED_PACK_COUNT = exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = exports.FREE_PACK_ROLE = void 0;
3
+ exports.EVENT_DRAFT_TOP_SKEW = exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = void 0;
4
4
  exports.generatePack = generatePack;
5
5
  exports.draftPackRole = draftPackRole;
6
6
  exports.isDraftComplete = isDraftComplete;
7
7
  const player_1 = require("../player");
8
- // The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
9
- // packs, keep one player from each, and the resulting team is thrown away when the event ends.
10
- //
11
- // This module is pure: the pack sequence and pack generation. Persistence (which packs a user has opened, what
12
- // they picked) lives in the API, because the draft is user-driven rather than scheduled.
13
- /**
14
- * The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
15
- * read by the view to label it, so it is a named constant rather than a bare string in three repos.
16
- */
17
- exports.FREE_PACK_ROLE = 'ANY';
18
- /** How many players a role-targeted pack offers. */
8
+ /** How many players an opened pack offers. */
19
9
  exports.PACK_SIZE = 4;
20
10
  /**
21
- * The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
22
- * 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
23
- * fifteenth free pick on top of it.
11
+ * The pack sequence, in the order they are opened. One pick each, so this is also the squad: 2 setters,
12
+ * 4 outsides, 3 middles, 2 opposites, 2 liberos, plus one free pick, for a squad of 14 like every other team.
24
13
  *
25
14
  * Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
26
- * wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
27
- * stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
28
- * slot the squad depends on.
15
+ * wrong, so nobody drafts six liberos and then cannot field a legal lineup.
29
16
  */
30
17
  exports.DRAFT_PLAN = [
31
18
  player_1.RoleEnum.SETTER, player_1.RoleEnum.SETTER,
32
19
  player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER,
33
- player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
20
+ player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
34
21
  player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OPPOSITE_HITTER,
35
22
  player_1.RoleEnum.LIBERO, player_1.RoleEnum.LIBERO,
36
- exports.FREE_PACK_ROLE
23
+ 'ANY'
37
24
  ];
38
25
  /** The size of a drafted squad: one pick per pack. */
39
26
  exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN.length;
40
- /**
41
- * How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
42
- * out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
43
- * away from it.
44
- */
45
- exports.TARGETED_PACK_COUNT = exports.DRAFT_PLAN.filter(role => role !== exports.FREE_PACK_ROLE).length;
46
- /**
47
- * The free pack offers one player per position, in this order, so the entrant chooses which position to
48
- * strengthen rather than being handed a position at random.
49
- *
50
- * It used to roll ONE role at random and offer four of it, which meant a 1-in-5 lottery decided whether an
51
- * entrant ended up with a third setter or a fifth outside. The squad is complete without this pick now, so
52
- * offering the whole board is both fairer and the point of calling it free.
53
- */
27
+ /** The role a free pack rolls its choices at, so an ANY pack still offers a coherent set. */
54
28
  const ANY_PACK_ROLES = [
55
29
  player_1.RoleEnum.SETTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.LIBERO
56
30
  ];
57
- /** How many players the free pack offers: one per position. */
58
- exports.FREE_PACK_SIZE = ANY_PACK_ROLES.length;
59
31
  /**
60
32
  * How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
61
33
  *
@@ -75,18 +47,17 @@ exports.EVENT_DRAFT_TOP_SKEW = {
75
47
  [player_1.RarityEnum.SPECIAL]: 0
76
48
  };
77
49
  /**
78
- * The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
79
- * the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
50
+ * The players a pack offers: `PACK_SIZE` of the pack's role at the event's rarity. The lower rarities are
51
+ * generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near the best; legendaries
52
+ * are left uniform.
80
53
  *
81
- * The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
82
- * the best; legendaries are left uniform.
54
+ * `pickRole` decides what an ANY pack rolls; it is passed in rather than chosen here so the caller controls the
55
+ * randomness (and a test can make it deterministic).
83
56
  */
84
- function generatePack(country, rarity, packRole) {
57
+ function generatePack(country, rarity, packRole, pickRole) {
58
+ const role = packRole === 'ANY' ? pickRole(ANY_PACK_ROLES) : packRole;
85
59
  const skew = exports.EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
86
- const roll = (role) => player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew);
87
- if (packRole === exports.FREE_PACK_ROLE)
88
- return ANY_PACK_ROLES.map(roll);
89
- return Array.from({ length: exports.PACK_SIZE }, () => roll(packRole));
60
+ return Array.from({ length: exports.PACK_SIZE }, () => player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew));
90
61
  }
91
62
  /** The role of the pack at `index`, or undefined once the draft is complete. */
92
63
  function draftPackRole(index) {
@@ -5,6 +5,7 @@ const event_draft_1 = require("./event-draft");
5
5
  const player_1 = require("../player");
6
6
  const test_helpers_1 = require("../test-helpers");
7
7
  const COUNTRY = (0, test_helpers_1.makeCountry)();
8
+ const first = (roles) => roles[0];
8
9
  // Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
9
10
  // off the flaky edge while still failing loudly if the skew were removed.
10
11
  function median(values) {
@@ -19,7 +20,7 @@ function percentile(values, p) {
19
20
  function draftScores(rarity, role, n) {
20
21
  const out = [];
21
22
  while (out.length < n) {
22
- for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role))
23
+ for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role, first))
23
24
  out.push((0, player_1.calculateRoleScore)(p.stats, role));
24
25
  }
25
26
  return out.slice(0, n);
@@ -29,31 +30,28 @@ function uniformScores(rarity, role, n) {
29
30
  return Array.from({ length: n }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
30
31
  }
31
32
  (0, globals_1.describe)('DRAFT_PLAN', () => {
32
- (0, globals_1.it)('drafts a 15-player squad, one pick per pack', () => {
33
- (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(15);
34
- (0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(15);
33
+ (0, globals_1.it)('drafts a full 14-player squad, one pick per pack', () => {
34
+ (0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(14);
35
+ (0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(14);
35
36
  });
36
- (0, globals_1.it)('fills every position outright, then adds the free pick on top', () => {
37
+ (0, globals_1.it)('guarantees a positionally valid squad', () => {
37
38
  const counts = event_draft_1.DRAFT_PLAN.reduce((acc, role) => {
38
39
  acc[role] = (acc[role] ?? 0) + 1;
39
40
  return acc;
40
41
  }, {});
41
42
  (0, globals_1.expect)(counts[player_1.RoleEnum.SETTER]).toBe(2);
42
43
  (0, globals_1.expect)(counts[player_1.RoleEnum.OUTSIDE_HITTER]).toBe(4);
43
- (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(4);
44
+ (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(3);
44
45
  (0, globals_1.expect)(counts[player_1.RoleEnum.OPPOSITE_HITTER]).toBe(2);
45
46
  (0, globals_1.expect)(counts[player_1.RoleEnum.LIBERO]).toBe(2);
46
47
  (0, globals_1.expect)(counts.ANY).toBe(1);
47
- // The targeted packs alone are a complete 14-player squad, so the free pick adds to a squad that is
48
- // already legal rather than filling a hole in it.
49
- const targeted = event_draft_1.DRAFT_PLAN.filter(role => role !== event_draft_1.FREE_PACK_ROLE);
50
- (0, globals_1.expect)(targeted).toHaveLength(14);
51
- (0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(14);
52
- (0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(event_draft_1.DRAFT_SQUAD_SIZE - 1);
53
- });
54
- (0, globals_1.it)('puts the free pick last, so nothing after it depends on what was taken', () => {
55
- (0, globals_1.expect)(event_draft_1.DRAFT_PLAN[event_draft_1.DRAFT_PLAN.length - 1]).toBe(event_draft_1.FREE_PACK_ROLE);
56
- (0, globals_1.expect)(event_draft_1.DRAFT_PLAN.filter(role => role === event_draft_1.FREE_PACK_ROLE)).toHaveLength(1);
48
+ // Enough for a legal starting six plus a libero, whatever the free pack is spent on: a 5-1 needs a setter,
49
+ // two middles, two outsides and an opposite on court, and a libero off it.
50
+ (0, globals_1.expect)(counts[player_1.RoleEnum.SETTER]).toBeGreaterThanOrEqual(1);
51
+ (0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBeGreaterThanOrEqual(2);
52
+ (0, globals_1.expect)(counts[player_1.RoleEnum.OUTSIDE_HITTER]).toBeGreaterThanOrEqual(2);
53
+ (0, globals_1.expect)(counts[player_1.RoleEnum.OPPOSITE_HITTER]).toBeGreaterThanOrEqual(1);
54
+ (0, globals_1.expect)(counts[player_1.RoleEnum.LIBERO]).toBeGreaterThanOrEqual(1);
57
55
  });
58
56
  (0, globals_1.it)('walks pack by pack and then reports the draft complete', () => {
59
57
  for (let i = 0; i < event_draft_1.DRAFT_SQUAD_SIZE; i++) {
@@ -67,32 +65,18 @@ function uniformScores(rarity, role, n) {
67
65
  (0, globals_1.describe)('generatePack()', () => {
68
66
  globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
69
67
  const role = player_1.RoleEnum.OUTSIDE_HITTER;
70
- const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role);
68
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role, first);
71
69
  (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
72
70
  for (const player of pack) {
73
71
  (0, globals_1.expect)(player.rarity).toBe(rarity);
74
72
  (0, globals_1.expect)(player.roles).toContain(role);
75
73
  }
76
74
  });
77
- (0, globals_1.it)('offers one player per position in the free pack, so every position is on the board', () => {
78
- const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, event_draft_1.FREE_PACK_ROLE);
79
- (0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
80
- (0, globals_1.expect)(event_draft_1.FREE_PACK_SIZE).toBe(5);
81
- // The primary role is the one the pack was rolled at, and all five positions appear exactly once.
82
- const primary = pack.map(p => p.roles[0]);
83
- (0, globals_1.expect)([...primary].sort()).toEqual([
84
- player_1.RoleEnum.LIBERO, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.SETTER
85
- ].sort());
86
- });
87
- globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('rolls the free pack at the event rarity (%s), like every other pack', (rarity) => {
88
- const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, event_draft_1.FREE_PACK_ROLE);
89
- (0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
75
+ (0, globals_1.it)('rolls a free (ANY) pack at the role it is handed', () => {
76
+ const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, 'ANY', () => player_1.RoleEnum.LIBERO);
77
+ (0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
90
78
  for (const player of pack)
91
- (0, globals_1.expect)(player.rarity).toBe(rarity);
92
- });
93
- (0, globals_1.it)('never offers the same player twice in a free pack', () => {
94
- const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.RARE, event_draft_1.FREE_PACK_ROLE);
95
- (0, globals_1.expect)(new Set(pack.map(p => p.id)).size).toBe(event_draft_1.FREE_PACK_SIZE);
79
+ (0, globals_1.expect)(player.roles).toContain(player_1.RoleEnum.LIBERO);
96
80
  });
97
81
  });
98
82
  (0, globals_1.describe)('EVENT_DRAFT_TOP_SKEW', () => {
@@ -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,134 @@ 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.
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 are deliberately UNCHANGED from the pre-focus-band curve. They were briefly retuned to
50
+ // 130/400 to hold career-long development flat, but that was the wrong objective: measured against the actual
51
+ // design goals (2026-07-24) a flatter curve is worse on both. Lower scalar means stats are EASIER to max and
52
+ // there is LESS pressure to rotate the training focus, which is the opposite of what the rework is for.
53
+ //
54
+ // base/scalar stats within 5 of cap rotator vs fixed live threshold vs old
55
+ // 180/800 3.6 1.28x 1.00
56
+ // 130/400 4.9 1.19x 0.72
57
+ // 180/275 5.2 1.15x 1.00
58
+ //
59
+ // 180/800 also leaves today's live players paying exactly what they paid before (median ratio 1.00), so the
60
+ // only thing this rework changes is WHICH stats drive the price, not how fast anyone develops on day one.
61
+ // Career totals do fall (~253 lifetime stat points to ~175) and that is intended: maxing a focus is supposed
62
+ // to be hard, and staying on one focus forever is supposed to stall.
23
63
  const CURVE_BASE = 180;
24
64
  const CURVE_SCALAR = 800;
25
65
  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
- };
66
+ exports.FOCUS_WEIGHT = 0.5;
35
67
  const statCaps = stat_config_1.statCapsJSON;
68
+ const floorEntries = stat_config_1.improvementFloorsJSON.floors;
69
+ const KEY_TO_COLUMN = {
70
+ setting: 'setting',
71
+ serve: 'serve',
72
+ spike: 'spike',
73
+ quick: 'quick',
74
+ power: 'power',
75
+ awareness: 'awareness',
76
+ attack: 'attack',
77
+ backAttack: 'back_attack',
78
+ reception: 'reception',
79
+ overhand: 'overhand',
80
+ bump: 'bump',
81
+ block: 'block',
82
+ read: 'read',
83
+ commit: 'commit',
84
+ focus: 'focus',
85
+ defense: 'defense',
86
+ stamina: 'stamina',
87
+ reflex: 'reflex'
88
+ };
89
+ const ALL_COLUMNS = performance_stats_1.performanceStatKeys.map(key => KEY_TO_COLUMN[key]);
36
90
  function getStatCap(rarity) {
37
91
  const entry = statCaps.find(e => e.rarity === rarity);
38
92
  if (entry == null)
39
93
  throw new Error(`UNKNOWN_RARITY: ${rarity}`);
40
94
  return entry.cap;
41
95
  }
42
- function getImprovementFloor(rarity) {
43
- const floor = IMPROVEMENT_FLOOR[rarity];
44
- if (floor == null)
96
+ /**
97
+ * The DB column names of a training focus's pool: exactly the stats `selectImprovableStat` can raise under
98
+ * that focus, so the band and the pool are the same set by construction.
99
+ */
100
+ function focusPoolColumns(focus) {
101
+ return Object.keys((0, stats_1.getMultipliers)(focus)).map(key => KEY_TO_COLUMN[key]);
102
+ }
103
+ /**
104
+ * Where a freshly generated player of this rarity and role starts, in both bands.
105
+ *
106
+ * Roles matter: at LEGENDARY the mean generated 18-stat sum runs 846 for an outside hitter against 1099 for a
107
+ * libero, a fifth of the band, so one floor per rarity would hand some roles a free head start. An unknown
108
+ * role falls back to the mean across that rarity's roles rather than throwing, because a player whose role
109
+ * enum has changed underneath us should still be able to train.
110
+ */
111
+ function getImprovementFloors(rarity, role) {
112
+ const exact = floorEntries.find(e => e.rarity === rarity && e.role === role);
113
+ if (exact != null)
114
+ return { all: exact.all, focus: exact.focus };
115
+ const forRarity = floorEntries.filter(e => e.rarity === rarity);
116
+ if (forRarity.length === 0)
45
117
  throw new Error(`UNKNOWN_RARITY: ${rarity}`);
46
- return floor;
118
+ const focus = {};
119
+ for (const key of Object.keys(forRarity[0].focus)) {
120
+ focus[key] = forRarity.reduce((sum, e) => sum + e.focus[key], 0) / forRarity.length;
121
+ }
122
+ return { all: forRarity.reduce((sum, e) => sum + e.all, 0) / forRarity.length, focus };
47
123
  }
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));
124
+ function bandFraction(value, floor, ceiling) {
125
+ const span = ceiling - floor;
126
+ if (span <= 0)
127
+ return 1;
128
+ return Math.max(0, Math.min(1, (value - floor) / span));
129
+ }
130
+ /**
131
+ * Exp needed for this player's next +1, before the coach multiplier.
132
+ *
133
+ * `stats` is keyed by DB COLUMN name (back_attack, not backAttack), the same shape `selectImprovableStat`
134
+ * takes, so a caller passes one object to both.
135
+ */
136
+ function getImprovementThreshold(stats, rarity, role, focus) {
137
+ const cap = getStatCap(rarity);
138
+ const floors = getImprovementFloors(rarity, role);
139
+ const poolColumns = focusPoolColumns(focus);
140
+ const focusSum = poolColumns.reduce((sum, column) => sum + (stats[column] ?? 0), 0);
141
+ const focusFrac = bandFraction(focusSum, floors.focus[focus] ?? 0, poolColumns.length * cap);
142
+ const allSum = ALL_COLUMNS.reduce((sum, column) => sum + (stats[column] ?? 0), 0);
143
+ const allFrac = bandFraction(allSum, floors.all, ALL_COLUMNS.length * cap);
144
+ const frac = exports.FOCUS_WEIGHT * focusFrac + (1 - exports.FOCUS_WEIGHT) * allFrac;
52
145
  return Math.round(CURVE_BASE + CURVE_SCALAR * Math.pow(frac, CURVE_EXPONENT));
53
146
  }
54
147
  const COACH_MULTIPLIERS = {
@@ -81,26 +174,6 @@ function matchExperience(pointsPlayed) {
81
174
  function experienceGained(pointsPlayed, age, profile) {
82
175
  return Math.round(matchExperience(pointsPlayed) * (0, decline_1.youthMultiplier)(age, profile));
83
176
  }
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
177
  function selectImprovableStat(rarity, trainingFocus, currentStats) {
105
178
  const cap = getStatCap(rarity);
106
179
  const multipliers = (0, stats_1.getMultipliers)(trainingFocus);