volleyballsimtypes 0.0.424 → 0.0.426

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.
@@ -24,6 +24,13 @@ export interface MatchCompetition {
24
24
  tier?: number;
25
25
  subdivision?: number;
26
26
  }
27
+ export interface MatchForm {
28
+ matchId: string;
29
+ result: 'WIN' | 'LOSE';
30
+ side: 'HOME' | 'AWAY';
31
+ opponent: string;
32
+ dR: number;
33
+ }
27
34
  export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | 'VPERs'> & {
28
35
  sets: MatchSet[];
29
36
  boxScores: BoxScore[];
@@ -37,6 +44,8 @@ export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | '
37
44
  results?: Results;
38
45
  competition?: MatchCompetition;
39
46
  rallyMetrics?: RallyMetrics;
47
+ homeForm?: MatchForm[];
48
+ awayForm?: MatchForm[];
40
49
  };
41
50
  export type RallyEvent = DataProps<_RallyEvent> & {
42
51
  team?: Team;
@@ -16,7 +16,9 @@ export interface RallyMetricsSet {
16
16
  export interface RotationMetrics {
17
17
  setterZone: number | null;
18
18
  rotation: number;
19
+ serves: number;
19
20
  servePoints: number;
21
+ receptions: number;
20
22
  sideoutPoints: number;
21
23
  points: number;
22
24
  }
@@ -26,17 +26,16 @@ function setterZoneAt(startZone, rotationIndex) {
26
26
  zone = (0, court_position_1.rotatePosition)(zone);
27
27
  return zone;
28
28
  }
29
- function addRotationPoint(acc, setterZone, rotation, kind) {
29
+ // Get (or create) a team's rotation bucket, keyed by setter zone when known (so the total sums by zone
30
+ // across sets), else by the rotation index.
31
+ function rotationEntry(acc, setterZone, rotation) {
30
32
  const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
31
33
  let entry = acc.rotations.get(key);
32
34
  if (entry == null) {
33
- entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
35
+ entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0, serves: 0, receptions: 0 };
34
36
  acc.rotations.set(key, entry);
35
37
  }
36
- if (kind === 'serve')
37
- entry.servePoints++;
38
- else
39
- entry.sideoutPoints++;
38
+ return entry;
40
39
  }
41
40
  // Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
42
41
  function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
@@ -53,24 +52,27 @@ function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
53
52
  const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
54
53
  const serving = accOf(servingId);
55
54
  const receiving = accOf(receivingId);
56
- const winner = accOf(winnerId);
57
- if (serving == null || receiving == null || winner == null)
55
+ if (serving == null || receiving == null || (winnerId !== servingId && winnerId !== receivingId))
58
56
  continue;
59
57
  serving.serves++;
60
58
  receiving.receptions++;
61
59
  if (isServiceError(rallies[i]))
62
60
  receiving.serviceErrorsReceived++;
63
- const rotIdx = rotation[winnerId];
64
- const zone = setterZoneAt(startZone[winnerId], rotIdx);
61
+ // Per-rotation attempts: both teams play this rally in their current rotation. The point below is
62
+ // attributed to the winner, whose bucket is the same serve (breakpoint) or reception (sideout) entry.
63
+ const serveEntry = rotationEntry(serving, setterZoneAt(startZone[servingId], rotation[servingId]), rotation[servingId]);
64
+ const recEntry = rotationEntry(receiving, setterZoneAt(startZone[receivingId], rotation[receivingId]), rotation[receivingId]);
65
+ serveEntry.serves++;
66
+ recEntry.receptions++;
65
67
  if (winnerId === servingId) {
66
68
  serving.breakpointWins++;
67
- addRotationPoint(winner, zone, rotIdx, 'serve');
69
+ serveEntry.servePoints++;
68
70
  }
69
71
  else {
70
72
  receiving.sideoutWins++;
71
73
  if (spikeCount(rallies[i]) === 1)
72
74
  receiving.fbsoWins++;
73
- addRotationPoint(winner, zone, rotIdx, 'sideout');
75
+ recEntry.sideoutPoints++;
74
76
  }
75
77
  // The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
76
78
  if (winnerId === receivingId)
@@ -48,12 +48,12 @@ describe('computeRallyMetrics()', () => {
48
48
  it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
49
49
  expect(m.sets).toHaveLength(1);
50
50
  expect(m.sets[0].home.rotations).toEqual([
51
- { setterZone: 1, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 },
52
- { setterZone: 2, rotation: 1, servePoints: 1, sideoutPoints: 1, points: 2 }
51
+ { setterZone: 1, rotation: 2, serves: 1, servePoints: 1, receptions: 0, sideoutPoints: 0, points: 1 },
52
+ { setterZone: 2, rotation: 1, serves: 2, servePoints: 1, receptions: 2, sideoutPoints: 1, points: 2 }
53
53
  ]);
54
54
  expect(m.sets[0].away.rotations).toEqual([
55
- { setterZone: 1, rotation: 1, servePoints: 0, sideoutPoints: 1, points: 1 },
56
- { setterZone: 6, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 }
55
+ { setterZone: 1, rotation: 1, serves: 0, servePoints: 0, receptions: 2, sideoutPoints: 1, points: 1 },
56
+ { setterZone: 6, rotation: 2, serves: 2, servePoints: 1, receptions: 1, sideoutPoints: 0, points: 1 }
57
57
  ]);
58
58
  });
59
59
  });
@@ -10,6 +10,7 @@ export declare const declineProfiles: DeclineProfileEnum[];
10
10
  interface DeclineProfileEntry {
11
11
  profile: string;
12
12
  startAge: number;
13
+ youthAge: number;
13
14
  initTicks: number;
14
15
  accel: number;
15
16
  peakTicks: number;
@@ -25,6 +26,7 @@ export declare const SKILL_POOL: PerformanceStatsKey[];
25
26
  */
26
27
  export declare const ENERGY_POOL: PerformanceStatsKey[];
27
28
  export declare function getDeclineProfile(profile: DeclineProfile): DeclineProfileEntry;
29
+ export declare function youthMultiplier(age: number, profile: DeclineProfile): number;
28
30
  export type DeclineStatus = 'ACTIVE' | 'DECLINING' | 'PEAK';
29
31
  export interface DeclineTick {
30
32
  /** Number of decline ticks to apply this iteration. */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ENERGY_POOL = exports.SKILL_POOL = exports.declineProfiles = exports.DeclineProfileEnum = void 0;
4
4
  exports.getDeclineProfile = getDeclineProfile;
5
+ exports.youthMultiplier = youthMultiplier;
5
6
  exports.computeDeclineTick = computeDeclineTick;
6
7
  const stat_config_1 = require("../../stat-config");
7
8
  var DeclineProfileEnum;
@@ -34,6 +35,12 @@ function getDeclineProfile(profile) {
34
35
  throw new Error(`UNKNOWN_DECLINE_PROFILE: ${profile}`);
35
36
  return entry;
36
37
  }
38
+ // Prospects below their profile's youthAge develop faster: a multiplier applied to the per-match improvement
39
+ // experience. At or above youthAge (and pre-decline) growth is at the normal rate.
40
+ const YOUTH_EXP_MULTIPLIER = 1.25;
41
+ function youthMultiplier(age, profile) {
42
+ return age < getDeclineProfile(profile).youthAge ? YOUTH_EXP_MULTIPLIER : 1;
43
+ }
37
44
  /**
38
45
  * Decline is fully derivable from age + profile — no running state is persisted.
39
46
  * tickRate = min(peakTicks, initTicks + (age - startAge) * accel)
@@ -100,3 +100,20 @@ const performance_stats_keys_1 = require("./performance-stats-keys");
100
100
  (0, globals_1.expect)((0, decline_1.computeDeclineTick)(33, decline_1.DeclineProfileEnum.STANDARD).atPeak).toBe(true);
101
101
  });
102
102
  });
103
+ // ─── youthMultiplier ───────────────────────────────────────────────────────────
104
+ (0, globals_1.describe)('youthMultiplier()', () => {
105
+ globals_1.it.each([
106
+ [decline_1.DeclineProfileEnum.PRODIGY, 20],
107
+ [decline_1.DeclineProfileEnum.STANDARD, 21],
108
+ [decline_1.DeclineProfileEnum.VETERAN, 22],
109
+ [decline_1.DeclineProfileEnum.IRONMAN, 23]
110
+ ])('%s youthAge is %i', (profile, youthAge) => {
111
+ (0, globals_1.expect)((0, decline_1.getDeclineProfile)(profile).youthAge).toBe(youthAge);
112
+ });
113
+ (0, globals_1.it)('boosts players below youthAge by 1.25 and leaves the rest at 1', () => {
114
+ (0, globals_1.expect)((0, decline_1.youthMultiplier)(20, decline_1.DeclineProfileEnum.STANDARD)).toBe(1.25); // youthAge 21
115
+ (0, globals_1.expect)((0, decline_1.youthMultiplier)(21, decline_1.DeclineProfileEnum.STANDARD)).toBe(1);
116
+ (0, globals_1.expect)((0, decline_1.youthMultiplier)(19, decline_1.DeclineProfileEnum.PRODIGY)).toBe(1.25); // youthAge 20
117
+ (0, globals_1.expect)((0, decline_1.youthMultiplier)(20, decline_1.DeclineProfileEnum.PRODIGY)).toBe(1);
118
+ });
119
+ });
@@ -1,8 +1,11 @@
1
1
  import { Rarity } from './rarity';
2
2
  import { Stats } from './stats';
3
+ import { DeclineProfile } from './decline';
3
4
  export type TrainingFocus = Stats;
4
5
  export declare function getStatCap(rarity: Rarity): number;
5
6
  export declare function getRarityMultiplier(rarity: Rarity): number;
6
7
  export declare function getImprovementThreshold(statTotal: number, rarity: Rarity): number;
7
8
  export declare function getCoachMultiplier(rarity: Rarity): number;
9
+ export declare function matchExperience(pointsPlayed: number): number;
10
+ export declare function experienceGained(pointsPlayed: number, age: number, profile: DeclineProfile): number;
8
11
  export declare function selectImprovableStat(rarity: Rarity, trainingFocus: TrainingFocus, currentStats: Record<string, number>): string | null;
@@ -4,11 +4,14 @@ exports.getStatCap = getStatCap;
4
4
  exports.getRarityMultiplier = getRarityMultiplier;
5
5
  exports.getImprovementThreshold = getImprovementThreshold;
6
6
  exports.getCoachMultiplier = getCoachMultiplier;
7
+ exports.matchExperience = matchExperience;
8
+ exports.experienceGained = experienceGained;
7
9
  exports.selectImprovableStat = selectImprovableStat;
8
10
  const stat_config_1 = require("../../stat-config");
9
11
  const rarity_1 = require("./rarity");
10
12
  const performance_stats_1 = require("./performance-stats");
11
13
  const stats_1 = require("./stats");
14
+ const decline_1 = require("./decline");
12
15
  const CURVE_BASE = 100;
13
16
  const CURVE_SCALAR = 0.098;
14
17
  const CURVE_EXPONENT = 1.5;
@@ -40,6 +43,26 @@ const COACH_MULTIPLIERS = {
40
43
  function getCoachMultiplier(rarity) {
41
44
  return COACH_MULTIPLIERS[rarity] ?? 1.0;
42
45
  }
46
+ // ── Per-match experience curve ──────────────────────────────────────────────
47
+ // Experience is no longer 1:1 with points played: each point is worth progressively less (a saturating
48
+ // curve), so high-volume matches (5-setters, never-subbed players) don't improve disproportionately fast.
49
+ // MATCH_EXP_K sets the long-match premium - a ~206-point 5-setter yields only ~13% more exp than a ~147-point
50
+ // 3-setter. MATCH_EXP_SCALE is the asymptote, calibrated so exp == points at ~60 points played (the league-
51
+ // average match): a normal match keeps roughly its old value and only longer matches are throttled. Tuning
52
+ // MATCH_EXP_SCALE shifts overall pace without changing the premium; MATCH_EXP_K changes the premium.
53
+ const MATCH_EXP_SCALE = 160;
54
+ const MATCH_EXP_K = 100;
55
+ function matchExperience(pointsPlayed) {
56
+ if (pointsPlayed <= 0)
57
+ return 0;
58
+ return (MATCH_EXP_SCALE * pointsPlayed) / (pointsPlayed + MATCH_EXP_K);
59
+ }
60
+ // Total improvement experience a player earns from a match: the saturating per-match curve, boosted for
61
+ // players still below their decline profile's youthAge (prospects develop faster). Rounded - the progress
62
+ // counter holds whole units.
63
+ function experienceGained(pointsPlayed, age, profile) {
64
+ return Math.round(matchExperience(pointsPlayed) * (0, decline_1.youthMultiplier)(age, profile));
65
+ }
43
66
  const KEY_TO_COLUMN = {
44
67
  setting: 'setting',
45
68
  serve: 'serve',
@@ -4,6 +4,7 @@ const globals_1 = require("@jest/globals");
4
4
  const rarity_1 = require("./rarity");
5
5
  const improvement_1 = require("./improvement");
6
6
  const stats_1 = require("./stats");
7
+ const decline_1 = require("./decline");
7
8
  // ─── getStatCap ───────────────────────────────────────────────────────────────
8
9
  (0, globals_1.describe)('getStatCap()', () => {
9
10
  globals_1.it.each([
@@ -142,3 +143,38 @@ function allStats(value) {
142
143
  }
143
144
  });
144
145
  });
146
+ // ─── matchExperience / experienceGained ───────────────────────────────────────
147
+ (0, globals_1.describe)('matchExperience()', () => {
148
+ (0, globals_1.it)('breaks even (~1:1) at the league-average match (~60 points)', () => {
149
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(60)).toBeCloseTo(60, 0);
150
+ });
151
+ (0, globals_1.it)('throttles high-volume matches well below their raw points', () => {
152
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(206)).toBeLessThan(206);
153
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(206)).toBeCloseTo(108, 0);
154
+ });
155
+ (0, globals_1.it)('gives a 5-set match (~206 pts) only ~13% more than a 3-set match (~147 pts)', () => {
156
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(206) / (0, improvement_1.matchExperience)(147)).toBeCloseTo(1.13, 2);
157
+ });
158
+ (0, globals_1.it)('is monotonic non-decreasing in points played', () => {
159
+ let prev = -1;
160
+ for (let p = 0; p <= 250; p += 10) {
161
+ const v = (0, improvement_1.matchExperience)(p);
162
+ (0, globals_1.expect)(v).toBeGreaterThanOrEqual(prev);
163
+ prev = v;
164
+ }
165
+ });
166
+ (0, globals_1.it)('zero or negative points → zero exp', () => {
167
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(0)).toBe(0);
168
+ (0, globals_1.expect)((0, improvement_1.matchExperience)(-5)).toBe(0);
169
+ });
170
+ });
171
+ (0, globals_1.describe)('experienceGained()', () => {
172
+ (0, globals_1.it)('young players (below their profile youthAge) gain 25% more than the base', () => {
173
+ const base = Math.round((0, improvement_1.matchExperience)(100));
174
+ (0, globals_1.expect)((0, improvement_1.experienceGained)(100, 18, decline_1.DeclineProfileEnum.STANDARD)).toBe(Math.round((0, improvement_1.matchExperience)(100) * 1.25));
175
+ (0, globals_1.expect)((0, improvement_1.experienceGained)(100, 25, decline_1.DeclineProfileEnum.STANDARD)).toBe(base);
176
+ });
177
+ (0, globals_1.it)('zero points → zero exp regardless of age', () => {
178
+ (0, globals_1.expect)((0, improvement_1.experienceGained)(0, 18, decline_1.DeclineProfileEnum.STANDARD)).toBe(0);
179
+ });
180
+ });
@@ -1,6 +1,6 @@
1
1
  [
2
- { "profile": "PRODIGY", "startAge": 27, "initTicks": 13.0, "accel": 1.00, "peakTicks": 16.0 },
3
- { "profile": "STANDARD", "startAge": 29, "initTicks": 9.0, "accel": 1.75, "peakTicks": 16.0 },
4
- { "profile": "VETERAN", "startAge": 32, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 },
5
- { "profile": "IRONMAN", "startAge": 34, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 }
2
+ { "profile": "PRODIGY", "startAge": 27, "youthAge": 20, "initTicks": 13.0, "accel": 1.00, "peakTicks": 16.0 },
3
+ { "profile": "STANDARD", "startAge": 29, "youthAge": 21, "initTicks": 9.0, "accel": 1.75, "peakTicks": 16.0 },
4
+ { "profile": "VETERAN", "startAge": 32, "youthAge": 22, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 },
5
+ { "profile": "IRONMAN", "startAge": 34, "youthAge": 23, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 }
6
6
  ]
@@ -24,6 +24,13 @@ export interface MatchCompetition {
24
24
  tier?: number;
25
25
  subdivision?: number;
26
26
  }
27
+ export interface MatchForm {
28
+ matchId: string;
29
+ result: 'WIN' | 'LOSE';
30
+ side: 'HOME' | 'AWAY';
31
+ opponent: string;
32
+ dR: number;
33
+ }
27
34
  export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | 'VPERs'> & {
28
35
  sets: MatchSet[];
29
36
  boxScores: BoxScore[];
@@ -37,6 +44,8 @@ export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | '
37
44
  results?: Results;
38
45
  competition?: MatchCompetition;
39
46
  rallyMetrics?: RallyMetrics;
47
+ homeForm?: MatchForm[];
48
+ awayForm?: MatchForm[];
40
49
  };
41
50
  export type RallyEvent = DataProps<_RallyEvent> & {
42
51
  team?: Team;
@@ -16,7 +16,9 @@ export interface RallyMetricsSet {
16
16
  export interface RotationMetrics {
17
17
  setterZone: number | null;
18
18
  rotation: number;
19
+ serves: number;
19
20
  servePoints: number;
21
+ receptions: number;
20
22
  sideoutPoints: number;
21
23
  points: number;
22
24
  }
@@ -23,17 +23,16 @@ function setterZoneAt(startZone, rotationIndex) {
23
23
  zone = rotatePosition(zone);
24
24
  return zone;
25
25
  }
26
- function addRotationPoint(acc, setterZone, rotation, kind) {
26
+ // Get (or create) a team's rotation bucket, keyed by setter zone when known (so the total sums by zone
27
+ // across sets), else by the rotation index.
28
+ function rotationEntry(acc, setterZone, rotation) {
27
29
  const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
28
30
  let entry = acc.rotations.get(key);
29
31
  if (entry == null) {
30
- entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
32
+ entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0, serves: 0, receptions: 0 };
31
33
  acc.rotations.set(key, entry);
32
34
  }
33
- if (kind === 'serve')
34
- entry.servePoints++;
35
- else
36
- entry.sideoutPoints++;
35
+ return entry;
37
36
  }
38
37
  // Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
39
38
  function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
@@ -50,24 +49,27 @@ function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
50
49
  const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
51
50
  const serving = accOf(servingId);
52
51
  const receiving = accOf(receivingId);
53
- const winner = accOf(winnerId);
54
- if (serving == null || receiving == null || winner == null)
52
+ if (serving == null || receiving == null || (winnerId !== servingId && winnerId !== receivingId))
55
53
  continue;
56
54
  serving.serves++;
57
55
  receiving.receptions++;
58
56
  if (isServiceError(rallies[i]))
59
57
  receiving.serviceErrorsReceived++;
60
- const rotIdx = rotation[winnerId];
61
- const zone = setterZoneAt(startZone[winnerId], rotIdx);
58
+ // Per-rotation attempts: both teams play this rally in their current rotation. The point below is
59
+ // attributed to the winner, whose bucket is the same serve (breakpoint) or reception (sideout) entry.
60
+ const serveEntry = rotationEntry(serving, setterZoneAt(startZone[servingId], rotation[servingId]), rotation[servingId]);
61
+ const recEntry = rotationEntry(receiving, setterZoneAt(startZone[receivingId], rotation[receivingId]), rotation[receivingId]);
62
+ serveEntry.serves++;
63
+ recEntry.receptions++;
62
64
  if (winnerId === servingId) {
63
65
  serving.breakpointWins++;
64
- addRotationPoint(winner, zone, rotIdx, 'serve');
66
+ serveEntry.servePoints++;
65
67
  }
66
68
  else {
67
69
  receiving.sideoutWins++;
68
70
  if (spikeCount(rallies[i]) === 1)
69
71
  receiving.fbsoWins++;
70
- addRotationPoint(winner, zone, rotIdx, 'sideout');
72
+ recEntry.sideoutPoints++;
71
73
  }
72
74
  // The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
73
75
  if (winnerId === receivingId)
@@ -46,12 +46,12 @@ describe('computeRallyMetrics()', () => {
46
46
  it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
47
47
  expect(m.sets).toHaveLength(1);
48
48
  expect(m.sets[0].home.rotations).toEqual([
49
- { setterZone: 1, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 },
50
- { setterZone: 2, rotation: 1, servePoints: 1, sideoutPoints: 1, points: 2 }
49
+ { setterZone: 1, rotation: 2, serves: 1, servePoints: 1, receptions: 0, sideoutPoints: 0, points: 1 },
50
+ { setterZone: 2, rotation: 1, serves: 2, servePoints: 1, receptions: 2, sideoutPoints: 1, points: 2 }
51
51
  ]);
52
52
  expect(m.sets[0].away.rotations).toEqual([
53
- { setterZone: 1, rotation: 1, servePoints: 0, sideoutPoints: 1, points: 1 },
54
- { setterZone: 6, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 }
53
+ { setterZone: 1, rotation: 1, serves: 0, servePoints: 0, receptions: 2, sideoutPoints: 1, points: 1 },
54
+ { setterZone: 6, rotation: 2, serves: 2, servePoints: 1, receptions: 1, sideoutPoints: 0, points: 1 }
55
55
  ]);
56
56
  });
57
57
  });
@@ -10,6 +10,7 @@ export declare const declineProfiles: DeclineProfileEnum[];
10
10
  interface DeclineProfileEntry {
11
11
  profile: string;
12
12
  startAge: number;
13
+ youthAge: number;
13
14
  initTicks: number;
14
15
  accel: number;
15
16
  peakTicks: number;
@@ -25,6 +26,7 @@ export declare const SKILL_POOL: PerformanceStatsKey[];
25
26
  */
26
27
  export declare const ENERGY_POOL: PerformanceStatsKey[];
27
28
  export declare function getDeclineProfile(profile: DeclineProfile): DeclineProfileEntry;
29
+ export declare function youthMultiplier(age: number, profile: DeclineProfile): number;
28
30
  export type DeclineStatus = 'ACTIVE' | 'DECLINING' | 'PEAK';
29
31
  export interface DeclineTick {
30
32
  /** Number of decline ticks to apply this iteration. */
@@ -29,6 +29,12 @@ export function getDeclineProfile(profile) {
29
29
  throw new Error(`UNKNOWN_DECLINE_PROFILE: ${profile}`);
30
30
  return entry;
31
31
  }
32
+ // Prospects below their profile's youthAge develop faster: a multiplier applied to the per-match improvement
33
+ // experience. At or above youthAge (and pre-decline) growth is at the normal rate.
34
+ const YOUTH_EXP_MULTIPLIER = 1.25;
35
+ export function youthMultiplier(age, profile) {
36
+ return age < getDeclineProfile(profile).youthAge ? YOUTH_EXP_MULTIPLIER : 1;
37
+ }
32
38
  /**
33
39
  * Decline is fully derivable from age + profile — no running state is persisted.
34
40
  * tickRate = min(peakTicks, initTicks + (age - startAge) * accel)
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
- import { DeclineProfileEnum, declineProfiles, getDeclineProfile, computeDeclineTick, SKILL_POOL, ENERGY_POOL } from './decline';
2
+ import { DeclineProfileEnum, declineProfiles, getDeclineProfile, computeDeclineTick, youthMultiplier, SKILL_POOL, ENERGY_POOL } from './decline';
3
3
  import { performanceStatKeys } from './performance-stats-keys';
4
4
  // ─── getDeclineProfile ─────────────────────────────────────────────────────────
5
5
  describe('getDeclineProfile()', () => {
@@ -98,3 +98,20 @@ describe('computeDeclineTick()', () => {
98
98
  expect(computeDeclineTick(33, DeclineProfileEnum.STANDARD).atPeak).toBe(true);
99
99
  });
100
100
  });
101
+ // ─── youthMultiplier ───────────────────────────────────────────────────────────
102
+ describe('youthMultiplier()', () => {
103
+ it.each([
104
+ [DeclineProfileEnum.PRODIGY, 20],
105
+ [DeclineProfileEnum.STANDARD, 21],
106
+ [DeclineProfileEnum.VETERAN, 22],
107
+ [DeclineProfileEnum.IRONMAN, 23]
108
+ ])('%s youthAge is %i', (profile, youthAge) => {
109
+ expect(getDeclineProfile(profile).youthAge).toBe(youthAge);
110
+ });
111
+ it('boosts players below youthAge by 1.25 and leaves the rest at 1', () => {
112
+ expect(youthMultiplier(20, DeclineProfileEnum.STANDARD)).toBe(1.25); // youthAge 21
113
+ expect(youthMultiplier(21, DeclineProfileEnum.STANDARD)).toBe(1);
114
+ expect(youthMultiplier(19, DeclineProfileEnum.PRODIGY)).toBe(1.25); // youthAge 20
115
+ expect(youthMultiplier(20, DeclineProfileEnum.PRODIGY)).toBe(1);
116
+ });
117
+ });
@@ -1,8 +1,11 @@
1
1
  import { Rarity } from './rarity';
2
2
  import { Stats } from './stats';
3
+ import { DeclineProfile } from './decline';
3
4
  export type TrainingFocus = Stats;
4
5
  export declare function getStatCap(rarity: Rarity): number;
5
6
  export declare function getRarityMultiplier(rarity: Rarity): number;
6
7
  export declare function getImprovementThreshold(statTotal: number, rarity: Rarity): number;
7
8
  export declare function getCoachMultiplier(rarity: Rarity): number;
9
+ export declare function matchExperience(pointsPlayed: number): number;
10
+ export declare function experienceGained(pointsPlayed: number, age: number, profile: DeclineProfile): number;
8
11
  export declare function selectImprovableStat(rarity: Rarity, trainingFocus: TrainingFocus, currentStats: Record<string, number>): string | null;
@@ -2,6 +2,7 @@ import { statCapsJSON } from '../../stat-config';
2
2
  import { RarityEnum } from './rarity';
3
3
  import { performanceStatKeys } from './performance-stats';
4
4
  import { getMultipliers } from './stats';
5
+ import { youthMultiplier } from './decline';
5
6
  const CURVE_BASE = 100;
6
7
  const CURVE_SCALAR = 0.098;
7
8
  const CURVE_EXPONENT = 1.5;
@@ -33,6 +34,26 @@ const COACH_MULTIPLIERS = {
33
34
  export function getCoachMultiplier(rarity) {
34
35
  return COACH_MULTIPLIERS[rarity] ?? 1.0;
35
36
  }
37
+ // ── Per-match experience curve ──────────────────────────────────────────────
38
+ // Experience is no longer 1:1 with points played: each point is worth progressively less (a saturating
39
+ // curve), so high-volume matches (5-setters, never-subbed players) don't improve disproportionately fast.
40
+ // MATCH_EXP_K sets the long-match premium - a ~206-point 5-setter yields only ~13% more exp than a ~147-point
41
+ // 3-setter. MATCH_EXP_SCALE is the asymptote, calibrated so exp == points at ~60 points played (the league-
42
+ // average match): a normal match keeps roughly its old value and only longer matches are throttled. Tuning
43
+ // MATCH_EXP_SCALE shifts overall pace without changing the premium; MATCH_EXP_K changes the premium.
44
+ const MATCH_EXP_SCALE = 160;
45
+ const MATCH_EXP_K = 100;
46
+ export function matchExperience(pointsPlayed) {
47
+ if (pointsPlayed <= 0)
48
+ return 0;
49
+ return (MATCH_EXP_SCALE * pointsPlayed) / (pointsPlayed + MATCH_EXP_K);
50
+ }
51
+ // Total improvement experience a player earns from a match: the saturating per-match curve, boosted for
52
+ // players still below their decline profile's youthAge (prospects develop faster). Rounded - the progress
53
+ // counter holds whole units.
54
+ export function experienceGained(pointsPlayed, age, profile) {
55
+ return Math.round(matchExperience(pointsPlayed) * youthMultiplier(age, profile));
56
+ }
36
57
  const KEY_TO_COLUMN = {
37
58
  setting: 'setting',
38
59
  serve: 'serve',
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
2
  import { RarityEnum } from './rarity';
3
- import { getStatCap, getRarityMultiplier, getImprovementThreshold, getCoachMultiplier, selectImprovableStat } from './improvement';
3
+ import { getStatCap, getRarityMultiplier, getImprovementThreshold, getCoachMultiplier, selectImprovableStat, matchExperience, experienceGained } from './improvement';
4
4
  import { StatsEnum } from './stats';
5
+ import { DeclineProfileEnum } from './decline';
5
6
  // ─── getStatCap ───────────────────────────────────────────────────────────────
6
7
  describe('getStatCap()', () => {
7
8
  it.each([
@@ -140,3 +141,38 @@ describe('selectImprovableStat()', () => {
140
141
  }
141
142
  });
142
143
  });
144
+ // ─── matchExperience / experienceGained ───────────────────────────────────────
145
+ describe('matchExperience()', () => {
146
+ it('breaks even (~1:1) at the league-average match (~60 points)', () => {
147
+ expect(matchExperience(60)).toBeCloseTo(60, 0);
148
+ });
149
+ it('throttles high-volume matches well below their raw points', () => {
150
+ expect(matchExperience(206)).toBeLessThan(206);
151
+ expect(matchExperience(206)).toBeCloseTo(108, 0);
152
+ });
153
+ it('gives a 5-set match (~206 pts) only ~13% more than a 3-set match (~147 pts)', () => {
154
+ expect(matchExperience(206) / matchExperience(147)).toBeCloseTo(1.13, 2);
155
+ });
156
+ it('is monotonic non-decreasing in points played', () => {
157
+ let prev = -1;
158
+ for (let p = 0; p <= 250; p += 10) {
159
+ const v = matchExperience(p);
160
+ expect(v).toBeGreaterThanOrEqual(prev);
161
+ prev = v;
162
+ }
163
+ });
164
+ it('zero or negative points → zero exp', () => {
165
+ expect(matchExperience(0)).toBe(0);
166
+ expect(matchExperience(-5)).toBe(0);
167
+ });
168
+ });
169
+ describe('experienceGained()', () => {
170
+ it('young players (below their profile youthAge) gain 25% more than the base', () => {
171
+ const base = Math.round(matchExperience(100));
172
+ expect(experienceGained(100, 18, DeclineProfileEnum.STANDARD)).toBe(Math.round(matchExperience(100) * 1.25));
173
+ expect(experienceGained(100, 25, DeclineProfileEnum.STANDARD)).toBe(base);
174
+ });
175
+ it('zero points → zero exp regardless of age', () => {
176
+ expect(experienceGained(0, 18, DeclineProfileEnum.STANDARD)).toBe(0);
177
+ });
178
+ });
@@ -1,6 +1,6 @@
1
1
  [
2
- { "profile": "PRODIGY", "startAge": 27, "initTicks": 13.0, "accel": 1.00, "peakTicks": 16.0 },
3
- { "profile": "STANDARD", "startAge": 29, "initTicks": 9.0, "accel": 1.75, "peakTicks": 16.0 },
4
- { "profile": "VETERAN", "startAge": 32, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 },
5
- { "profile": "IRONMAN", "startAge": 34, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 }
2
+ { "profile": "PRODIGY", "startAge": 27, "youthAge": 20, "initTicks": 13.0, "accel": 1.00, "peakTicks": 16.0 },
3
+ { "profile": "STANDARD", "startAge": 29, "youthAge": 21, "initTicks": 9.0, "accel": 1.75, "peakTicks": 16.0 },
4
+ { "profile": "VETERAN", "startAge": 32, "youthAge": 22, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 },
5
+ { "profile": "IRONMAN", "startAge": 34, "youthAge": 23, "initTicks": 10.0, "accel": 1.50, "peakTicks": 16.0 }
6
6
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.424",
3
+ "version": "0.0.426",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",