volleyballsimtypes 0.0.463 → 0.0.465

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 (129) hide show
  1. package/dist/cjs/src/api/index.d.ts +13 -1
  2. package/dist/cjs/src/data/init-models.js +8 -0
  3. package/dist/cjs/src/data/models/currency-transaction.d.ts +4 -2
  4. package/dist/cjs/src/data/models/currency-transaction.js +2 -0
  5. package/dist/cjs/src/data/models/index.d.ts +2 -0
  6. package/dist/cjs/src/data/models/index.js +2 -0
  7. package/dist/cjs/src/data/models/match-set.d.ts +2 -0
  8. package/dist/cjs/src/data/models/match-set.js +5 -0
  9. package/dist/cjs/src/data/models/match.d.ts +3 -1
  10. package/dist/cjs/src/data/models/match.js +4 -0
  11. package/dist/cjs/src/data/models/physician.d.ts +25 -0
  12. package/dist/cjs/src/data/models/physician.js +57 -0
  13. package/dist/cjs/src/data/models/physiotherapist.d.ts +25 -0
  14. package/dist/cjs/src/data/models/physiotherapist.js +57 -0
  15. package/dist/cjs/src/data/models/player.d.ts +8 -0
  16. package/dist/cjs/src/data/models/player.js +17 -0
  17. package/dist/cjs/src/data/models/tactics.d.ts +10 -1
  18. package/dist/cjs/src/data/models/tactics.js +9 -0
  19. package/dist/cjs/src/data/transformers/lineup-preset.js +7 -1
  20. package/dist/cjs/src/data/transformers/lineup-preset.test.js +15 -2
  21. package/dist/cjs/src/data/transformers/match-set.js +2 -0
  22. package/dist/cjs/src/data/transformers/match.js +6 -2
  23. package/dist/cjs/src/data/transformers/player.js +15 -1
  24. package/dist/cjs/src/data/transformers/rally-event-injury.test.d.ts +1 -0
  25. package/dist/cjs/src/data/transformers/rally-event-injury.test.js +33 -0
  26. package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
  27. package/dist/cjs/src/data/transformers/rally-event.js +4 -1
  28. package/dist/cjs/src/data/transformers/tactics.js +83 -4
  29. package/dist/cjs/src/service/event/schemas/substitution.z.d.ts +1 -0
  30. package/dist/cjs/src/service/event/schemas/substitution.z.js +2 -1
  31. package/dist/cjs/src/service/event/substitution.d.ts +1 -0
  32. package/dist/cjs/src/service/event/substitution.js +4 -2
  33. package/dist/cjs/src/service/match/match-set-forfeit.test.d.ts +1 -0
  34. package/dist/cjs/src/service/match/match-set-forfeit.test.js +76 -0
  35. package/dist/cjs/src/service/match/match-set.d.ts +8 -0
  36. package/dist/cjs/src/service/match/match-set.js +23 -1
  37. package/dist/cjs/src/service/match/match.d.ts +2 -0
  38. package/dist/cjs/src/service/match/match.js +2 -1
  39. package/dist/cjs/src/service/match/schemas/match-set.z.d.ts +1 -0
  40. package/dist/cjs/src/service/match/schemas/match-set.z.js +30 -2
  41. package/dist/cjs/src/service/match/schemas/match.z.d.ts +4 -0
  42. package/dist/cjs/src/service/match/schemas/match.z.js +4 -1
  43. package/dist/cjs/src/service/player/index.d.ts +1 -0
  44. package/dist/cjs/src/service/player/index.js +1 -0
  45. package/dist/cjs/src/service/player/injury.d.ts +14 -0
  46. package/dist/cjs/src/service/player/injury.js +39 -0
  47. package/dist/cjs/src/service/player/injury.test.d.ts +1 -0
  48. package/dist/cjs/src/service/player/injury.test.js +78 -0
  49. package/dist/cjs/src/service/player/player-generator.js +3 -1
  50. package/dist/cjs/src/service/player/player.d.ts +3 -0
  51. package/dist/cjs/src/service/player/player.js +3 -1
  52. package/dist/cjs/src/service/player/schemas/player.z.d.ts +6 -0
  53. package/dist/cjs/src/service/player/schemas/player.z.js +7 -0
  54. package/dist/cjs/src/service/team/index.d.ts +1 -0
  55. package/dist/cjs/src/service/team/index.js +1 -0
  56. package/dist/cjs/src/service/team/injury-replacement.d.ts +6 -0
  57. package/dist/cjs/src/service/team/injury-replacement.js +2 -0
  58. package/dist/cjs/src/service/team/schemas/injury-replacement.z.test.d.ts +1 -0
  59. package/dist/cjs/src/service/team/schemas/injury-replacement.z.test.js +142 -0
  60. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +6 -0
  61. package/dist/cjs/src/service/team/schemas/tactics.z.js +47 -1
  62. package/dist/cjs/src/service/team/schemas/team.z.d.ts +6 -0
  63. package/dist/cjs/src/service/team/tactics.d.ts +5 -0
  64. package/dist/cjs/src/service/team/tactics.js +3 -1
  65. package/dist/esm/src/api/index.d.ts +13 -1
  66. package/dist/esm/src/data/init-models.js +9 -1
  67. package/dist/esm/src/data/models/currency-transaction.d.ts +4 -2
  68. package/dist/esm/src/data/models/currency-transaction.js +2 -0
  69. package/dist/esm/src/data/models/index.d.ts +2 -0
  70. package/dist/esm/src/data/models/index.js +2 -0
  71. package/dist/esm/src/data/models/match-set.d.ts +2 -0
  72. package/dist/esm/src/data/models/match-set.js +5 -0
  73. package/dist/esm/src/data/models/match.d.ts +3 -1
  74. package/dist/esm/src/data/models/match.js +4 -0
  75. package/dist/esm/src/data/models/physician.d.ts +25 -0
  76. package/dist/esm/src/data/models/physician.js +53 -0
  77. package/dist/esm/src/data/models/physiotherapist.d.ts +25 -0
  78. package/dist/esm/src/data/models/physiotherapist.js +53 -0
  79. package/dist/esm/src/data/models/player.d.ts +8 -0
  80. package/dist/esm/src/data/models/player.js +17 -0
  81. package/dist/esm/src/data/models/tactics.d.ts +10 -1
  82. package/dist/esm/src/data/models/tactics.js +9 -0
  83. package/dist/esm/src/data/transformers/lineup-preset.js +7 -1
  84. package/dist/esm/src/data/transformers/lineup-preset.test.js +15 -2
  85. package/dist/esm/src/data/transformers/match-set.js +2 -0
  86. package/dist/esm/src/data/transformers/match.js +6 -2
  87. package/dist/esm/src/data/transformers/player.js +15 -1
  88. package/dist/esm/src/data/transformers/rally-event-injury.test.d.ts +1 -0
  89. package/dist/esm/src/data/transformers/rally-event-injury.test.js +31 -0
  90. package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
  91. package/dist/esm/src/data/transformers/rally-event.js +4 -1
  92. package/dist/esm/src/data/transformers/tactics.js +83 -4
  93. package/dist/esm/src/service/event/schemas/substitution.z.d.ts +1 -0
  94. package/dist/esm/src/service/event/schemas/substitution.z.js +2 -1
  95. package/dist/esm/src/service/event/substitution.d.ts +1 -0
  96. package/dist/esm/src/service/event/substitution.js +4 -2
  97. package/dist/esm/src/service/match/match-set-forfeit.test.d.ts +1 -0
  98. package/dist/esm/src/service/match/match-set-forfeit.test.js +74 -0
  99. package/dist/esm/src/service/match/match-set.d.ts +8 -0
  100. package/dist/esm/src/service/match/match-set.js +23 -1
  101. package/dist/esm/src/service/match/match.d.ts +2 -0
  102. package/dist/esm/src/service/match/match.js +2 -1
  103. package/dist/esm/src/service/match/schemas/match-set.z.d.ts +1 -0
  104. package/dist/esm/src/service/match/schemas/match-set.z.js +30 -2
  105. package/dist/esm/src/service/match/schemas/match.z.d.ts +4 -0
  106. package/dist/esm/src/service/match/schemas/match.z.js +4 -1
  107. package/dist/esm/src/service/player/index.d.ts +1 -0
  108. package/dist/esm/src/service/player/index.js +1 -0
  109. package/dist/esm/src/service/player/injury.d.ts +14 -0
  110. package/dist/esm/src/service/player/injury.js +33 -0
  111. package/dist/esm/src/service/player/injury.test.d.ts +1 -0
  112. package/dist/esm/src/service/player/injury.test.js +76 -0
  113. package/dist/esm/src/service/player/player-generator.js +3 -1
  114. package/dist/esm/src/service/player/player.d.ts +3 -0
  115. package/dist/esm/src/service/player/player.js +3 -1
  116. package/dist/esm/src/service/player/schemas/player.z.d.ts +6 -0
  117. package/dist/esm/src/service/player/schemas/player.z.js +7 -0
  118. package/dist/esm/src/service/team/index.d.ts +1 -0
  119. package/dist/esm/src/service/team/index.js +1 -0
  120. package/dist/esm/src/service/team/injury-replacement.d.ts +6 -0
  121. package/dist/esm/src/service/team/injury-replacement.js +1 -0
  122. package/dist/esm/src/service/team/schemas/injury-replacement.z.test.d.ts +1 -0
  123. package/dist/esm/src/service/team/schemas/injury-replacement.z.test.js +140 -0
  124. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +6 -0
  125. package/dist/esm/src/service/team/schemas/tactics.z.js +47 -1
  126. package/dist/esm/src/service/team/schemas/team.z.d.ts +6 -0
  127. package/dist/esm/src/service/team/tactics.d.ts +5 -0
  128. package/dist/esm/src/service/team/tactics.js +3 -1
  129. package/package.json +1 -1
@@ -14,7 +14,7 @@ export class Match {
14
14
  }
15
15
  return new Match(result.data);
16
16
  }
17
- constructor({ id, homeTeam, awayTeam, scheduledDate, sets, status, homeScore, awayScore, winner, VPERs }) {
17
+ constructor({ id, homeTeam, awayTeam, scheduledDate, sets, status, homeScore, awayScore, winner, VPERs, forfeitType }) {
18
18
  if (homeTeam.id === awayTeam.id)
19
19
  throw new Error(`FAILED_TO_CREATE_MATCH. TEAMS_WITH_SAME_ID: ${homeTeam.id}`);
20
20
  this.id = id;
@@ -27,6 +27,7 @@ export class Match {
27
27
  this.awayScore = awayScore;
28
28
  this.winner = winner;
29
29
  this.VPERs = VPERs ?? [];
30
+ this.forfeitType = forfeitType;
30
31
  }
31
32
  addSet(set) {
32
33
  if (this.sets.length >= 5)
@@ -11,5 +11,6 @@ export declare const MatchSetInputSchema: z.ZodObject<{
11
11
  awayScore: z.ZodNumber;
12
12
  boxScores: z.ZodArray<z.ZodCustom<BoxScore, BoxScore>>;
13
13
  rallies: z.ZodArray<z.ZodCustom<Rally, Rally>>;
14
+ forfeited: z.ZodDefault<z.ZodBoolean>;
14
15
  }, z.core.$strip>;
15
16
  export type MatchSetInput = z.infer<typeof MatchSetInputSchema>;
@@ -24,7 +24,10 @@ export const MatchSetInputSchema = z.object({
24
24
  })),
25
25
  rallies: z.array(z.custom((v) => v instanceof Rally, {
26
26
  message: 'INVALID_RALLY_INSTANCE'
27
- }))
27
+ })),
28
+ // FIVB 6.4.3 set forfeit (injuries: team could not field six). The awarding rule (winner granted the points
29
+ // needed to win) keeps the threshold rule below satisfied; only the rallies-vs-score equality is relaxed.
30
+ forfeited: z.boolean().default(false)
28
31
  }).superRefine((data, ctx) => {
29
32
  const { order, isTieBreak, rallies, homeScore, awayScore } = data;
30
33
  // isTieBreak <-> order === 4
@@ -53,6 +56,20 @@ export const MatchSetInputSchema = z.object({
53
56
  path: ['homeScore']
54
57
  });
55
58
  }
59
+ // A forfeited score must be exactly what forfeit() awards: winner = max(threshold, loser + 2). This rejects
60
+ // over-awarded scores (30-10), missing-award states (0-0 or 25-24 forfeited) and any hand-rolled corruption
61
+ // at the validation boundary instead of letting it flip winners downstream.
62
+ if (data.forfeited) {
63
+ const winner = Math.max(homeScore, awayScore);
64
+ const loser = Math.min(homeScore, awayScore);
65
+ if (winner !== Math.max(scoreThreshold, loser + 2)) {
66
+ ctx.addIssue({
67
+ code: 'custom',
68
+ message: 'FORFEITED_SCORE_MUST_MATCH_AWARDING_RULE',
69
+ path: ['homeScore']
70
+ });
71
+ }
72
+ }
56
73
  // If rallies exist, score cannot be 0-0 and must match the number of rallies.
57
74
  if (hasRallies) {
58
75
  if (isZeroZero) {
@@ -62,12 +79,23 @@ export const MatchSetInputSchema = z.object({
62
79
  path: ['rallies']
63
80
  });
64
81
  }
65
- if (homeScore + awayScore !== rallies.length) {
82
+ // A forfeited set keeps the rallies actually played and is topped up with awarded points, so the sum
83
+ // exceeds the rally count; anything else (more rallies than points) is still invalid. The loser's points
84
+ // were all EARNED, so when rallies are present they must at least cover them (rally-less persisted sets,
85
+ // the bot-vs-bot path, skip this whole block).
86
+ if (data.forfeited ? rallies.length > homeScore + awayScore : homeScore + awayScore !== rallies.length) {
66
87
  ctx.addIssue({
67
88
  code: 'custom',
68
89
  message: 'RALLIES_LENGTH_MUST_EQUAL_SCORE_SUM',
69
90
  path: ['rallies']
70
91
  });
71
92
  }
93
+ if (data.forfeited && rallies.length < Math.min(homeScore, awayScore)) {
94
+ ctx.addIssue({
95
+ code: 'custom',
96
+ message: 'FORFEITED_LOSER_POINTS_REQUIRE_RALLIES',
97
+ path: ['rallies']
98
+ });
99
+ }
72
100
  }
73
101
  });
@@ -18,5 +18,9 @@ export declare const MatchInputSchema: z.ZodObject<{
18
18
  homeScore: z.ZodOptional<z.ZodNumber>;
19
19
  awayScore: z.ZodOptional<z.ZodNumber>;
20
20
  winner: z.ZodOptional<z.ZodCustom<Team, Team>>;
21
+ forfeitType: z.ZodOptional<z.ZodEnum<{
22
+ INCOMPLETE: "INCOMPLETE";
23
+ DEFAULT: "DEFAULT";
24
+ }>>;
21
25
  }, z.core.$strip>;
22
26
  export type MatchInput = z.infer<typeof MatchInputSchema>;
@@ -20,7 +20,10 @@ export const MatchInputSchema = z.object({
20
20
  })).optional(),
21
21
  homeScore: z.number().int().optional(),
22
22
  awayScore: z.number().int().optional(),
23
- winner: teamSchema.optional()
23
+ winner: teamSchema.optional(),
24
+ // Abnormal ending marker (see MatchForfeitType). The match still validates as a normal COMPLETE result
25
+ // because forfeited sets carry FIVB-awarded scores.
26
+ forfeitType: z.enum(['INCOMPLETE', 'DEFAULT']).optional()
24
27
  }).superRefine((data, ctx) => {
25
28
  if (data.homeTeam.id === data.awayTeam.id) {
26
29
  ctx.addIssue({
@@ -1,6 +1,7 @@
1
1
  export * from './player';
2
2
  export * from './improvement';
3
3
  export * from './decline';
4
+ export * from './injury';
4
5
  export * from './player-generator';
5
6
  export * from './performance-stats';
6
7
  export * from './trait';
@@ -1,6 +1,7 @@
1
1
  export * from './player';
2
2
  export * from './improvement';
3
3
  export * from './decline';
4
+ export * from './injury';
4
5
  export * from './player-generator';
5
6
  export * from './performance-stats';
6
7
  export * from './trait';
@@ -0,0 +1,14 @@
1
+ import { Rarity } from './rarity';
2
+ export declare enum InjurySeverityEnum {
3
+ MINOR = 1,
4
+ MODERATE = 2,
5
+ SEVERE = 3
6
+ }
7
+ export interface PlayerInjury {
8
+ readonly severity: number;
9
+ readonly injuredUntil: Date;
10
+ readonly debuff?: number;
11
+ }
12
+ export declare function hasActiveInjury(injury: PlayerInjury | undefined, at: Date): boolean;
13
+ export declare function getPhysiotherapistRiskMultiplier(rarity: Rarity): number;
14
+ export declare function getPhysicianRecoveryMultiplier(rarity: Rarity): number;
@@ -0,0 +1,33 @@
1
+ import { RarityEnum } from './rarity';
2
+ export var InjurySeverityEnum;
3
+ (function (InjurySeverityEnum) {
4
+ InjurySeverityEnum[InjurySeverityEnum["MINOR"] = 1] = "MINOR";
5
+ InjurySeverityEnum[InjurySeverityEnum["MODERATE"] = 2] = "MODERATE";
6
+ InjurySeverityEnum[InjurySeverityEnum["SEVERE"] = 3] = "SEVERE";
7
+ })(InjurySeverityEnum || (InjurySeverityEnum = {}));
8
+ // A long-term injury is active while `at` is before injuredUntil.
9
+ export function hasActiveInjury(injury, at) {
10
+ return injury != null && injury.injuredUntil.getTime() > at.getTime();
11
+ }
12
+ // Staff effect curves (same Record-plus-accessor shape as getCoachMultiplier). COMMON = neutral baseline.
13
+ // Physiotherapist scales the per-action injury CHANCE; physician scales long-term recovery TIME.
14
+ const PHYSIOTHERAPIST_RISK_MULTIPLIERS = {
15
+ [RarityEnum.COMMON]: 1.0,
16
+ [RarityEnum.RARE]: 0.92,
17
+ [RarityEnum.LEGENDARY]: 0.84,
18
+ [RarityEnum.MYTHIC]: 0.75,
19
+ [RarityEnum.SPECIAL]: 0.65
20
+ };
21
+ const PHYSICIAN_RECOVERY_MULTIPLIERS = {
22
+ [RarityEnum.COMMON]: 1.0,
23
+ [RarityEnum.RARE]: 0.9,
24
+ [RarityEnum.LEGENDARY]: 0.8,
25
+ [RarityEnum.MYTHIC]: 0.65,
26
+ [RarityEnum.SPECIAL]: 0.5
27
+ };
28
+ export function getPhysiotherapistRiskMultiplier(rarity) {
29
+ return PHYSIOTHERAPIST_RISK_MULTIPLIERS[rarity] ?? 1.0;
30
+ }
31
+ export function getPhysicianRecoveryMultiplier(rarity) {
32
+ return PHYSICIAN_RECOVERY_MULTIPLIERS[rarity] ?? 1.0;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { RarityEnum } from './rarity';
3
+ import { getPhysicianRecoveryMultiplier, getPhysiotherapistRiskMultiplier, hasActiveInjury } from './injury';
4
+ import { PlayerInputSchema } from './schemas/player.z';
5
+ import { makeCountry, makePlayer } from '../test-helpers';
6
+ describe('Player injury fields', () => {
7
+ const country = makeCountry();
8
+ it('defaults injuryProneProfile to 0.5 for legacy Player.create callers', () => {
9
+ const player = makePlayer(country);
10
+ expect(player.injuryProneProfile).toBe(0.5);
11
+ expect(player.injury).toBeUndefined();
12
+ });
13
+ it('accepts an injury object and coerces injuredUntil from an ISO string', () => {
14
+ const base = PlayerInputSchema.safeParse({
15
+ ...validInput(),
16
+ injuryProneProfile: 0.8,
17
+ injury: { severity: 2, injuredUntil: '2026-07-04T10:00:00.000Z', debuff: 0.75 }
18
+ });
19
+ expect(base.success).toBe(true);
20
+ if (base.success) {
21
+ expect(base.data.injuryProneProfile).toBe(0.8);
22
+ expect(base.data.injury?.injuredUntil).toBeInstanceOf(Date);
23
+ expect(base.data.injury?.severity).toBe(2);
24
+ }
25
+ });
26
+ it('rejects out-of-range severity and profile', () => {
27
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 4, injuredUntil: new Date() } }).success).toBe(false);
28
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 0, injuredUntil: new Date() } }).success).toBe(false);
29
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: 1.2 }).success).toBe(false);
30
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: -0.1 }).success).toBe(false);
31
+ });
32
+ function validInput() {
33
+ const p = makePlayer(country);
34
+ return {
35
+ id: p.id,
36
+ name: { first: p.name.first, last: p.name.last },
37
+ country,
38
+ stats: p.stats,
39
+ roles: p.roles,
40
+ traits: p.traits,
41
+ rarity: p.rarity,
42
+ age: p.age,
43
+ birthAge: p.birthAge,
44
+ birthIteration: p.birthIteration,
45
+ declineProfile: p.declineProfile
46
+ };
47
+ }
48
+ });
49
+ describe('hasActiveInjury', () => {
50
+ const at = new Date('2026-07-03T12:00:00.000Z');
51
+ it('is false for a healthy player', () => {
52
+ expect(hasActiveInjury(undefined, at)).toBe(false);
53
+ });
54
+ it('is true while injuredUntil is in the future', () => {
55
+ expect(hasActiveInjury({ severity: 1, injuredUntil: new Date('2026-07-03T18:00:00.000Z') }, at)).toBe(true);
56
+ });
57
+ it('is false once injuredUntil has passed (lazy expiry)', () => {
58
+ expect(hasActiveInjury({ severity: 3, injuredUntil: new Date('2026-07-03T11:59:59.000Z') }, at)).toBe(false);
59
+ });
60
+ });
61
+ describe('staff multipliers', () => {
62
+ it('physiotherapist reduces risk monotonically from COMMON to SPECIAL', () => {
63
+ expect(getPhysiotherapistRiskMultiplier(RarityEnum.COMMON)).toBe(1.0);
64
+ expect(getPhysiotherapistRiskMultiplier(RarityEnum.SPECIAL)).toBe(0.65);
65
+ const ladder = [RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL];
66
+ const values = ladder.map(getPhysiotherapistRiskMultiplier);
67
+ expect([...values].sort((a, b) => b - a)).toEqual(values);
68
+ });
69
+ it('physician reduces recovery monotonically and halves it at SPECIAL', () => {
70
+ expect(getPhysicianRecoveryMultiplier(RarityEnum.COMMON)).toBe(1.0);
71
+ expect(getPhysicianRecoveryMultiplier(RarityEnum.SPECIAL)).toBe(0.5);
72
+ const ladder = [RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY, RarityEnum.MYTHIC, RarityEnum.SPECIAL];
73
+ const values = ladder.map(getPhysicianRecoveryMultiplier);
74
+ expect([...values].sort((a, b) => b - a)).toEqual(values);
75
+ });
76
+ });
@@ -302,7 +302,9 @@ export class PlayerGenerator {
302
302
  const traits = assignTraits(roles, rarity, maxTraits);
303
303
  const birthAge = randomInt(15, 21);
304
304
  const declineProfile = declineProfiles[randomInt(0, declineProfiles.length)];
305
- return Player.create({ id: uuidv4(), name, country, stats, roles, traits, rarity, age: birthAge, birthAge, birthIteration, declineProfile });
305
+ // Uniform 0-1 (mean-neutral: the sim maps it to a 0.6-1.4 risk factor centered on 1.0).
306
+ const injuryProneProfile = randomInt(0, 101) / 100;
307
+ return Player.create({ id: uuidv4(), name, country, stats, roles, traits, rarity, age: birthAge, birthAge, birthIteration, declineProfile, injuryProneProfile });
306
308
  }
307
309
  static generatePlayers(count, countries) {
308
310
  const players = [];
@@ -6,6 +6,7 @@ import { Stats } from './stats';
6
6
  import { Role } from './role';
7
7
  import { BoxScore, BoxScoreTotals } from '../match';
8
8
  import { DeclineProfile } from './decline';
9
+ import { PlayerInjury } from './injury';
9
10
  export declare class Stat {
10
11
  readonly name: Stats;
11
12
  readonly value: number;
@@ -36,6 +37,8 @@ export declare class Player {
36
37
  readonly birthAge: number;
37
38
  readonly birthIteration: number;
38
39
  readonly declineProfile: DeclineProfile;
40
+ readonly injuryProneProfile: number;
41
+ readonly injury?: PlayerInjury;
39
42
  readonly boxScores?: BoxScore[];
40
43
  readonly boxScoreTotals?: BoxScoreTotals;
41
44
  static create(input: unknown): Player;
@@ -49,7 +49,7 @@ export class Player {
49
49
  stats: PerformanceStats.create(result.data.stats)
50
50
  });
51
51
  }
52
- constructor({ id, name, nickname, country, stats, roles, traits, rarity, age, birthAge, birthIteration, declineProfile, boxScores, boxScoreTotals }) {
52
+ constructor({ id, name, nickname, country, stats, roles, traits, rarity, age, birthAge, birthIteration, declineProfile, injuryProneProfile, injury, boxScores, boxScoreTotals }) {
53
53
  this.id = id;
54
54
  this.name = name;
55
55
  this.nickname = nickname;
@@ -62,6 +62,8 @@ export class Player {
62
62
  this.birthAge = birthAge;
63
63
  this.birthIteration = birthIteration;
64
64
  this.declineProfile = declineProfile;
65
+ this.injuryProneProfile = injuryProneProfile;
66
+ this.injury = injury;
65
67
  this.boxScores = boxScores;
66
68
  this.boxScoreTotals = boxScoreTotals;
67
69
  this.generalStats = Object.values(StatsEnum).map((stat) => ({
@@ -79,6 +79,12 @@ export declare const PlayerInputSchema: z.ZodObject<{
79
79
  VETERAN: DeclineProfileEnum.VETERAN;
80
80
  IRONMAN: DeclineProfileEnum.IRONMAN;
81
81
  }>;
82
+ injuryProneProfile: z.ZodDefault<z.ZodNumber>;
83
+ injury: z.ZodOptional<z.ZodObject<{
84
+ severity: z.ZodNumber;
85
+ injuredUntil: z.ZodCoercedDate<unknown>;
86
+ debuff: z.ZodOptional<z.ZodNumber>;
87
+ }, z.core.$strip>>;
82
88
  boxScores: z.ZodOptional<z.ZodArray<z.ZodObject<{
83
89
  playerId: z.ZodUUID;
84
90
  matchSetId: z.ZodUUID;
@@ -35,6 +35,13 @@ export const PlayerInputSchema = z.object({
35
35
  birthAge: z.number().int().min(0),
36
36
  birthIteration: z.number().int().min(1),
37
37
  declineProfile: z.enum(Object.values(DeclineProfileEnum)),
38
+ // Defaulted so every existing Player.create caller (generator, transformers, test helpers) stays valid.
39
+ injuryProneProfile: z.number().min(0).max(1).default(0.5),
40
+ injury: z.object({
41
+ severity: z.number().int().min(1).max(3),
42
+ injuredUntil: z.coerce.date(),
43
+ debuff: z.number().min(0).max(1).optional()
44
+ }).optional(),
38
45
  boxScores: z.array(BoxScoreInputSchema).optional(),
39
46
  boxScoreTotals: BoxScoreTotalsInputSchema.optional()
40
47
  });
@@ -11,3 +11,4 @@ export * from './rotation-system';
11
11
  export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
+ export * from './injury-replacement';
@@ -11,3 +11,4 @@ export * from './rotation-system';
11
11
  export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
+ export * from './injury-replacement';
@@ -0,0 +1,6 @@
1
+ import { Player } from '../player';
2
+ export interface InjuryReplacement {
3
+ readonly starter: Player;
4
+ readonly replacement: Player;
5
+ readonly benchBackfill?: Player;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { TacticsInputSchema } from './tactics.z';
3
+ import { makeCountry, makePlayer } from '../../test-helpers';
4
+ describe('TacticsInputSchema — injuryReplacements (owner rules 2026-07-03)', () => {
5
+ const country = makeCountry();
6
+ const p1 = makePlayer(country);
7
+ const p2 = makePlayer(country);
8
+ const p3 = makePlayer(country);
9
+ const p4 = makePlayer(country);
10
+ const p5 = makePlayer(country);
11
+ const p6 = makePlayer(country);
12
+ const benchA = makePlayer(country);
13
+ const benchB = makePlayer(country);
14
+ const libero = makePlayer(country);
15
+ const reserveA = makePlayer(country);
16
+ const reserveB = makePlayer(country);
17
+ function baseInput(overrides = {}) {
18
+ return {
19
+ lineup: {
20
+ 4: p4,
21
+ 3: p3,
22
+ 2: p2,
23
+ 5: p5,
24
+ 6: p6,
25
+ 1: p1,
26
+ 0: libero,
27
+ bench: [benchA, benchB]
28
+ },
29
+ liberoReplacements: [],
30
+ ...overrides
31
+ };
32
+ }
33
+ it('defaults to true / empty', () => {
34
+ const res = TacticsInputSchema.safeParse(baseInput());
35
+ expect(res.success).toBe(true);
36
+ if (res.success) {
37
+ expect(res.data.replaceInjuredImmediately).toBe(true);
38
+ expect(res.data.injuryReplacements).toEqual([]);
39
+ }
40
+ });
41
+ it('accepts a bench replacement with a reserve backfill', () => {
42
+ const res = TacticsInputSchema.safeParse(baseInput({
43
+ injuryReplacements: [{ starter: p1, replacement: benchA, benchBackfill: reserveA }]
44
+ }));
45
+ expect(res.success).toBe(true);
46
+ });
47
+ it('accepts a bench replacement WITHOUT a backfill (empty bench seat is allowed by design)', () => {
48
+ const res = TacticsInputSchema.safeParse(baseInput({
49
+ injuryReplacements: [{ starter: p1, replacement: benchA }]
50
+ }));
51
+ expect(res.success).toBe(true);
52
+ });
53
+ it('accepts a reserve replacement (no backfill possible)', () => {
54
+ const res = TacticsInputSchema.safeParse(baseInput({
55
+ injuryReplacements: [{ starter: p1, replacement: reserveA }]
56
+ }));
57
+ expect(res.success).toBe(true);
58
+ });
59
+ it('rejects a starter as replacement', () => {
60
+ const res = TacticsInputSchema.safeParse(baseInput({
61
+ injuryReplacements: [{ starter: p1, replacement: p2 }]
62
+ }));
63
+ expect(res.success).toBe(false);
64
+ if (!res.success) {
65
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_MUST_BE_BENCH_OR_RESERVE')).toBe(true);
66
+ }
67
+ });
68
+ it('rejects the libero as replacement', () => {
69
+ const res = TacticsInputSchema.safeParse(baseInput({
70
+ injuryReplacements: [{ starter: p1, replacement: libero }]
71
+ }));
72
+ expect(res.success).toBe(false);
73
+ if (!res.success) {
74
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_MUST_BE_BENCH_OR_RESERVE')).toBe(true);
75
+ }
76
+ });
77
+ it('rejects a non-starter as the starter side', () => {
78
+ const res = TacticsInputSchema.safeParse(baseInput({
79
+ injuryReplacements: [{ starter: benchA, replacement: reserveA }]
80
+ }));
81
+ expect(res.success).toBe(false);
82
+ if (!res.success) {
83
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_STARTER_NOT_A_STARTER')).toBe(true);
84
+ }
85
+ });
86
+ it('rejects a backfill when the replacement is a reserve', () => {
87
+ const res = TacticsInputSchema.safeParse(baseInput({
88
+ injuryReplacements: [{ starter: p1, replacement: reserveA, benchBackfill: reserveB }]
89
+ }));
90
+ expect(res.success).toBe(false);
91
+ if (!res.success) {
92
+ expect(res.error.issues.some(issue => issue.message === 'BENCH_BACKFILL_REQUIRES_BENCH_REPLACEMENT')).toBe(true);
93
+ }
94
+ });
95
+ it('rejects a bench player as backfill (must be a reserve)', () => {
96
+ const res = TacticsInputSchema.safeParse(baseInput({
97
+ injuryReplacements: [{ starter: p1, replacement: benchA, benchBackfill: benchB }]
98
+ }));
99
+ expect(res.success).toBe(false);
100
+ if (!res.success) {
101
+ expect(res.error.issues.some(issue => issue.message === 'BENCH_BACKFILL_MUST_BE_RESERVE')).toBe(true);
102
+ }
103
+ });
104
+ it('rejects duplicate starter entries', () => {
105
+ const res = TacticsInputSchema.safeParse(baseInput({
106
+ injuryReplacements: [
107
+ { starter: p1, replacement: benchA },
108
+ { starter: p1, replacement: reserveA }
109
+ ]
110
+ }));
111
+ expect(res.success).toBe(false);
112
+ if (!res.success) {
113
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_DUPLICATE_STARTER')).toBe(true);
114
+ }
115
+ });
116
+ it('rejects the same incoming player used twice', () => {
117
+ const res = TacticsInputSchema.safeParse(baseInput({
118
+ injuryReplacements: [
119
+ { starter: p1, replacement: reserveA },
120
+ { starter: p2, replacement: reserveA }
121
+ ]
122
+ }));
123
+ expect(res.success).toBe(false);
124
+ if (!res.success) {
125
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_PLAYER_USED_TWICE')).toBe(true);
126
+ }
127
+ });
128
+ it('rejects a player used as replacement in one entry and backfill in another', () => {
129
+ const res = TacticsInputSchema.safeParse(baseInput({
130
+ injuryReplacements: [
131
+ { starter: p1, replacement: reserveA },
132
+ { starter: p2, replacement: benchA, benchBackfill: reserveA }
133
+ ]
134
+ }));
135
+ expect(res.success).toBe(false);
136
+ if (!res.success) {
137
+ expect(res.error.issues.some(issue => issue.message === 'INJURY_REPLACEMENT_PLAYER_USED_TWICE')).toBe(true);
138
+ }
139
+ });
140
+ });
@@ -123,5 +123,11 @@ export declare const TacticsInputSchema: z.ZodObject<{
123
123
  rotation: z.ZodNumber;
124
124
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
125
125
  }, z.core.$strip>>>>;
126
+ replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
127
+ injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
128
+ starter: z.ZodCustom<Player, Player>;
129
+ replacement: z.ZodCustom<Player, Player>;
130
+ benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
131
+ }, z.core.$strip>>>;
126
132
  }, z.core.$strip>;
127
133
  export type TacticsInput = z.infer<typeof TacticsInputSchema>;
@@ -77,7 +77,16 @@ export const TacticsInputSchema = z.object({
77
77
  // Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
78
78
  // validated with the same cross-field rules as the flat fields (see superRefine below).
79
79
  systemSets: z.array(systemSetSchema).max(5).optional(),
80
- offensivePreferenceSets: z.array(z.array(offensivePreferenceSchema)).max(5).optional()
80
+ offensivePreferenceSets: z.array(z.array(offensivePreferenceSchema)).max(5).optional(),
81
+ // Injuries. Defaults keep every existing Tactics.create({...}) call valid. injuryReplacements is the
82
+ // per-starter explicit replacement mapping (owner rules: followed verbatim, bench-only auto fallback,
83
+ // reserves never auto-promoted); cross-field rules in the superRefine below.
84
+ replaceInjuredImmediately: z.boolean().default(true),
85
+ injuryReplacements: z.array(z.object({
86
+ starter: playerInstanceSchema,
87
+ replacement: playerInstanceSchema,
88
+ benchBackfill: playerInstanceSchema.optional()
89
+ })).default([])
81
90
  }).superRefine((data, ctx) => {
82
91
  // Cross-field rules for the rotation system. These keep an invalid config from ever reaching the sim (the
83
92
  // pinch-server outage was caused by an unvalidated tactics payload).
@@ -175,4 +184,41 @@ export const TacticsInputSchema = z.object({
175
184
  }
176
185
  }
177
186
  }
187
+ // Injury replacements (owner rules 2026-07-03). starter = a court starter, at most one entry each.
188
+ // replacement = a bench player or a reserve (never another starter, the libero, or the L2). benchBackfill
189
+ // only when the replacement came from the bench, and must be a RESERVE (outside the whole lineup, not the
190
+ // L2). No player appears as a replacement or backfill more than once across all entries.
191
+ const benchIds = new Set(data.lineup.bench.map((p) => p.id));
192
+ const liberoId = data.lineup[LIBERO_ZONE]?.id;
193
+ const seenInjuryStarters = new Set();
194
+ const seenIncoming = new Set();
195
+ for (const ir of data.injuryReplacements) {
196
+ if (!starterIds.has(ir.starter.id)) {
197
+ ctx.addIssue({ code: 'custom', message: 'INJURY_REPLACEMENT_STARTER_NOT_A_STARTER', path: ['injuryReplacements'] });
198
+ }
199
+ if (seenInjuryStarters.has(ir.starter.id)) {
200
+ ctx.addIssue({ code: 'custom', message: 'INJURY_REPLACEMENT_DUPLICATE_STARTER', path: ['injuryReplacements'] });
201
+ }
202
+ seenInjuryStarters.add(ir.starter.id);
203
+ const isReplacementBench = benchIds.has(ir.replacement.id);
204
+ if (starterIds.has(ir.replacement.id) || ir.replacement.id === liberoId || ir.replacement.id === data.secondLibero?.id) {
205
+ ctx.addIssue({ code: 'custom', message: 'INJURY_REPLACEMENT_MUST_BE_BENCH_OR_RESERVE', path: ['injuryReplacements'] });
206
+ }
207
+ if (seenIncoming.has(ir.replacement.id)) {
208
+ ctx.addIssue({ code: 'custom', message: 'INJURY_REPLACEMENT_PLAYER_USED_TWICE', path: ['injuryReplacements'] });
209
+ }
210
+ seenIncoming.add(ir.replacement.id);
211
+ if (ir.benchBackfill != null) {
212
+ if (!isReplacementBench) {
213
+ ctx.addIssue({ code: 'custom', message: 'BENCH_BACKFILL_REQUIRES_BENCH_REPLACEMENT', path: ['injuryReplacements'] });
214
+ }
215
+ if (inLineupIds.has(ir.benchBackfill.id) || ir.benchBackfill.id === data.secondLibero?.id) {
216
+ ctx.addIssue({ code: 'custom', message: 'BENCH_BACKFILL_MUST_BE_RESERVE', path: ['injuryReplacements'] });
217
+ }
218
+ if (seenIncoming.has(ir.benchBackfill.id)) {
219
+ ctx.addIssue({ code: 'custom', message: 'INJURY_REPLACEMENT_PLAYER_USED_TWICE', path: ['injuryReplacements'] });
220
+ }
221
+ seenIncoming.add(ir.benchBackfill.id);
222
+ }
223
+ }
178
224
  });
@@ -131,6 +131,12 @@ export declare const TeamInputSchema: z.ZodObject<{
131
131
  rotation: z.ZodNumber;
132
132
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
133
133
  }, z.core.$strip>>>>;
134
+ replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
135
+ injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
136
+ starter: z.ZodCustom<Player, Player>;
137
+ replacement: z.ZodCustom<Player, Player>;
138
+ benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
139
+ }, z.core.$strip>>>;
134
140
  }, z.core.$strip>>;
135
141
  }, z.core.$strip>;
136
142
  export type TeamInput = z.infer<typeof TeamInputSchema>;
@@ -5,6 +5,7 @@ import { LiberoSubConfig } from './libero-sub';
5
5
  import { SubBand } from './energy-band';
6
6
  import { RotationSystemEnum } from './rotation-system';
7
7
  import { OffensivePreference } from './offensive-preference';
8
+ import { InjuryReplacement } from './injury-replacement';
8
9
  export interface StartingLineup {
9
10
  readonly [CourtPosition.LEFT_FRONT]: Player;
10
11
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -33,6 +34,8 @@ export interface TacticsOpts {
33
34
  readonly offensivePreferences: OffensivePreference[];
34
35
  readonly systemSets?: SystemSet[];
35
36
  readonly offensivePreferenceSets?: OffensivePreference[][];
37
+ readonly replaceInjuredImmediately: boolean;
38
+ readonly injuryReplacements: InjuryReplacement[];
36
39
  }
37
40
  export declare class Tactics {
38
41
  readonly lineup: StartingLineup;
@@ -48,6 +51,8 @@ export declare class Tactics {
48
51
  readonly offensivePreferences: OffensivePreference[];
49
52
  readonly systemSets?: SystemSet[];
50
53
  readonly offensivePreferenceSets?: OffensivePreference[][];
54
+ readonly replaceInjuredImmediately: boolean;
55
+ readonly injuryReplacements: InjuryReplacement[];
51
56
  static create(input: unknown): Tactics;
52
57
  private constructor();
53
58
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -15,7 +15,7 @@ export class Tactics {
15
15
  }
16
16
  return new Tactics(result.data);
17
17
  }
18
- constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets }) {
18
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceInjuredImmediately = true, injuryReplacements = [] }) {
19
19
  this.lineup = lineup;
20
20
  this.liberoReplacements = liberoReplacements;
21
21
  this.receiveRotationOffset = receiveRotationOffset;
@@ -29,6 +29,8 @@ export class Tactics {
29
29
  this.offensivePreferences = offensivePreferences;
30
30
  this.systemSets = systemSets;
31
31
  this.offensivePreferenceSets = offensivePreferenceSets;
32
+ this.replaceInjuredImmediately = replaceInjuredImmediately;
33
+ this.injuryReplacements = injuryReplacements;
32
34
  }
33
35
  findPlayerInLineup(id) {
34
36
  if (this.lineup.bench.some(p => p.id === id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.463",
3
+ "version": "0.0.465",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",