volleyballsimtypes 0.0.463 → 0.0.464

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 +12 -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 +9 -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 +71 -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/reserve-priority.d.ts +6 -0
  57. package/dist/cjs/src/service/team/reserve-priority.js +2 -0
  58. package/dist/cjs/src/service/team/schemas/reserve-priority.z.test.d.ts +1 -0
  59. package/dist/cjs/src/service/team/schemas/reserve-priority.z.test.js +125 -0
  60. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +5 -0
  61. package/dist/cjs/src/service/team/schemas/tactics.z.js +32 -1
  62. package/dist/cjs/src/service/team/schemas/team.z.d.ts +5 -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 +12 -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 +9 -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 +71 -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/reserve-priority.d.ts +6 -0
  121. package/dist/esm/src/service/team/reserve-priority.js +1 -0
  122. package/dist/esm/src/service/team/schemas/reserve-priority.z.test.d.ts +1 -0
  123. package/dist/esm/src/service/team/schemas/reserve-priority.z.test.js +123 -0
  124. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +5 -0
  125. package/dist/esm/src/service/team/schemas/tactics.z.js +32 -1
  126. package/dist/esm/src/service/team/schemas/team.z.d.ts +5 -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
@@ -59,6 +59,23 @@ export class PlayerModel extends Model {
59
59
  type: DataTypes.ENUM(...Object.values(DeclineProfileEnum)),
60
60
  allowNull: false,
61
61
  defaultValue: DeclineProfileEnum.STANDARD
62
+ },
63
+ injury_prone_profile: {
64
+ type: DataTypes.DECIMAL(3, 2),
65
+ allowNull: false,
66
+ defaultValue: 0.5
67
+ },
68
+ injury_severity: {
69
+ type: DataTypes.INTEGER,
70
+ allowNull: true
71
+ },
72
+ injured_until: {
73
+ type: DataTypes.DATE,
74
+ allowNull: true
75
+ },
76
+ injury_debuff: {
77
+ type: DataTypes.DECIMAL(3, 2),
78
+ allowNull: true
62
79
  }
63
80
  }, {
64
81
  sequelize,
@@ -39,6 +39,10 @@ export interface SystemSetAttributes {
39
39
  rotationSystem: RotationSystemEnum;
40
40
  designatedSetters: PlayerId[];
41
41
  }
42
+ export interface ReservePriorityAttributes {
43
+ position: CourtPosition;
44
+ reserves: PlayerId[];
45
+ }
42
46
  export interface LiberoSetSubConfigAttributes {
43
47
  mode: LiberoSubMode;
44
48
  fatigueBand?: EnergyBand;
@@ -61,10 +65,12 @@ export interface TacticsAttributes {
61
65
  offensive_preferences: OffensivePreferenceAttributes[];
62
66
  system_sets?: SystemSetAttributes[];
63
67
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
68
+ replace_injured_immediately?: boolean;
69
+ reserve_priorities?: ReservePriorityAttributes[];
64
70
  }
65
71
  export type TacticsPk = 'team_id';
66
72
  export type TacticsId = TacticsModel[TacticsPk];
67
- export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets';
73
+ export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_injured_immediately' | 'reserve_priorities';
68
74
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
69
75
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
70
76
  team_id: string;
@@ -81,6 +87,8 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
81
87
  offensive_preferences: OffensivePreferenceAttributes[];
82
88
  system_sets?: SystemSetAttributes[];
83
89
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
90
+ replace_injured_immediately?: boolean;
91
+ reserve_priorities?: ReservePriorityAttributes[];
84
92
  team: TeamModel;
85
93
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
86
94
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -71,6 +71,15 @@ export class TacticsModel extends Model {
71
71
  offensive_preference_sets: {
72
72
  type: DataTypes.JSONB,
73
73
  allowNull: true
74
+ },
75
+ replace_injured_immediately: {
76
+ type: DataTypes.BOOLEAN,
77
+ allowNull: false,
78
+ defaultValue: true
79
+ },
80
+ reserve_priorities: {
81
+ type: DataTypes.JSONB,
82
+ allowNull: true
74
83
  }
75
84
  }, {
76
85
  sequelize,
@@ -1,11 +1,17 @@
1
1
  // `config` is already stored as the API Tactics DTO, so a preset row maps to its API shape with no hydration.
2
+ // Presets saved before the injuries fields exist lack replaceInjuredImmediately: heal it to the effective
3
+ // default (true, matching transformToTactics' hydration) so the DTO's required field is honest and a UI
4
+ // toggle seeded from an old preset cannot silently flip the user's effective setting on re-save.
2
5
  function transformToObject(model) {
3
6
  return {
4
7
  presetId: model.preset_id,
5
8
  name: model.name,
6
9
  isActive: model.is_active,
7
10
  orderIndex: model.order_index,
8
- config: model.config
11
+ config: {
12
+ ...model.config,
13
+ replaceInjuredImmediately: model.config.replaceInjuredImmediately ?? true
14
+ }
9
15
  };
10
16
  }
11
17
  export { transformToObject as transformToLineupPreset };
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
2
  import { transformToLineupPreset } from './lineup-preset';
3
3
  describe('transformToLineupPreset()', () => {
4
- it('maps preset columns to the API shape and passes config through by reference', () => {
4
+ it('maps preset columns to the API shape and heals a pre-injuries config to the effective default', () => {
5
5
  const config = { lineup: { bench: [] }, substitutionBand: 'TIRED' };
6
6
  const preset = transformToLineupPreset({
7
7
  preset_id: 'p1',
@@ -16,8 +16,21 @@ describe('transformToLineupPreset()', () => {
16
16
  name: 'Starters',
17
17
  isActive: true,
18
18
  orderIndex: 2,
19
+ // A config stored before the injuries fields lacks replaceInjuredImmediately; the transform heals it
20
+ // to true (the effective hydration default) so the DTO's required field is honest.
21
+ config: { ...config, replaceInjuredImmediately: true }
22
+ });
23
+ });
24
+ it('keeps an explicit replaceInjuredImmediately: false', () => {
25
+ const config = { lineup: { bench: [] }, substitutionBand: 'TIRED', replaceInjuredImmediately: false };
26
+ const preset = transformToLineupPreset({
27
+ preset_id: 'p2',
28
+ team_id: 't1',
29
+ name: 'Risky',
30
+ is_active: false,
31
+ order_index: 3,
19
32
  config
20
33
  });
21
- expect(preset.config).toBe(config);
34
+ expect(preset.config.replaceInjuredImmediately).toBe(false);
22
35
  });
23
36
  });
@@ -23,6 +23,7 @@ function transformToAttributes(set, matchId) {
23
23
  is_tie_break: set.isTieBreak,
24
24
  home_score: set.homeScore,
25
25
  away_score: set.awayScore,
26
+ forfeited: set.forfeited,
26
27
  player_positions: [...homeSetPositions, ...awaySetPositions],
27
28
  BoxScores: (set.boxScores ?? []).map(transformFromBoxScore),
28
29
  Rallies: (set.rallies ?? []).map((rally) => transformFromRally(rally, set.id))
@@ -39,6 +40,7 @@ function transformToObject(model) {
39
40
  homeScore: model.home_score,
40
41
  awayScore: model.away_score,
41
42
  isTieBreak: model.is_tie_break,
43
+ forfeited: model.forfeited ?? false,
42
44
  boxScores: (model.BoxScores ?? []).map(transformToBoxScore),
43
45
  homePlayerPosition,
44
46
  awayPlayerPosition,
@@ -8,7 +8,10 @@ function transformToAttributes(match) {
8
8
  away_team: match.awayTeam.id,
9
9
  home_team: match.homeTeam.id,
10
10
  scheduled_date: match.scheduledDate,
11
- status: match.status
11
+ status: match.status,
12
+ // Both directions on purpose (the read is in transformToObject below). NOTE for the Sim: updateMatch's
13
+ // completion write uses an explicit column list and must include forfeit_type or the marker is lost.
14
+ forfeit_type: match.forfeitType ?? null
12
15
  };
13
16
  }
14
17
  function transformToObject(model, currentIteration, onPresetFallback) {
@@ -63,7 +66,8 @@ function transformToObject(model, currentIteration, onPresetFallback) {
63
66
  VPERs,
64
67
  homeScore,
65
68
  awayScore,
66
- winner: model.MatchResult?.Winner != null ? transformToTeam(model.MatchResult.Winner, currentIteration) : undefined
69
+ winner: model.MatchResult?.Winner != null ? transformToTeam(model.MatchResult.Winner, currentIteration) : undefined,
70
+ forfeitType: model.forfeit_type ?? undefined
67
71
  });
68
72
  }
69
73
  export { transformToObject as transformToMatch, transformToAttributes as transformFromMatch };
@@ -21,7 +21,11 @@ function transformToAttributes(player) {
21
21
  birth_age: player.birthAge,
22
22
  birth_iteration: player.birthIteration,
23
23
  last_declined_iteration: null,
24
- decline_profile: player.declineProfile
24
+ decline_profile: player.declineProfile,
25
+ injury_prone_profile: player.injuryProneProfile,
26
+ injury_severity: player.injury?.severity ?? null,
27
+ injured_until: player.injury?.injuredUntil ?? null,
28
+ injury_debuff: player.injury?.debuff ?? null
25
29
  };
26
30
  }
27
31
  function transformToBoxScoreTotals(model) {
@@ -87,6 +91,16 @@ function transformToObject(model, currentIteration) {
87
91
  birthAge: model.birth_age,
88
92
  birthIteration: model.birth_iteration,
89
93
  declineProfile: model.decline_profile,
94
+ // DECIMAL comes back as a string from pg; schema-defaulted when the column is absent (pre-migration rows).
95
+ injuryProneProfile: model.injury_prone_profile != null ? Number(model.injury_prone_profile) : undefined,
96
+ // Severity defends against a partial write: an injured_until without severity reads as MINOR.
97
+ injury: model.injured_until != null
98
+ ? {
99
+ severity: model.injury_severity != null ? Number(model.injury_severity) : 1,
100
+ injuredUntil: model.injured_until,
101
+ debuff: model.injury_debuff != null ? Number(model.injury_debuff) : undefined
102
+ }
103
+ : undefined,
90
104
  boxScores: (model.BoxScores ?? []).map(transformToBoxScore),
91
105
  boxScoreTotals
92
106
  });
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { EventTypeEnum, Substitution } from '../../service';
4
+ import { transformToCompact, transformToSubstitution } from './rally-event';
5
+ describe('rally-event compact round trip — injury substitution marker', () => {
6
+ const playerIn = uuidv4();
7
+ const playerOut = uuidv4();
8
+ const roster = [playerIn, playerOut];
9
+ const playerIndex = new Map(roster.map((id, i) => [id, i]));
10
+ it('packs the injury flag as j=1 and round-trips it', () => {
11
+ const sub = Substitution.create({ playerId: playerIn, playerOut, injury: true });
12
+ const compact = transformToCompact(sub, playerIndex);
13
+ expect(compact.j).toBe(1);
14
+ const decoded = transformToSubstitution(compact, roster);
15
+ expect(decoded.injury).toBe(true);
16
+ expect(decoded.playerId).toBe(playerIn);
17
+ expect(decoded.playerOut).toBe(playerOut);
18
+ });
19
+ it('omits j entirely for a normal substitution', () => {
20
+ const sub = Substitution.create({ playerId: playerIn, playerOut });
21
+ const compact = transformToCompact(sub, playerIndex);
22
+ expect('j' in compact).toBe(false);
23
+ const decoded = transformToSubstitution(compact, roster);
24
+ expect(decoded.injury).toBeUndefined();
25
+ });
26
+ it('decodes a legacy compact event (no j key) as a non-injury substitution', () => {
27
+ const legacy = { p: 0, e: EventTypeEnum.SUBSTITUTION, o: 1 };
28
+ const decoded = transformToSubstitution(legacy, roster);
29
+ expect(decoded.injury).toBeUndefined();
30
+ });
31
+ });
@@ -9,6 +9,7 @@ export interface CompactEvent {
9
9
  b?: number[];
10
10
  l?: number;
11
11
  o?: number;
12
+ j?: number;
12
13
  }
13
14
  export declare function transformToCompact(evt: RallyEvent, playerIndex: Map<string, number>): CompactEvent;
14
15
  export declare function transformToBlock(event: CompactEvent, roster: string[]): Block;
@@ -19,6 +19,8 @@ export function transformToCompact(evt, playerIndex) {
19
19
  }
20
20
  else if (evt instanceof Substitution) {
21
21
  out.o = playerIndex.get(evt.playerOut);
22
+ if (evt.injury === true)
23
+ out.j = 1;
22
24
  }
23
25
  return out;
24
26
  }
@@ -78,6 +80,7 @@ export function transformToSpike(event, roster) {
78
80
  export function transformToSubstitution(event, roster) {
79
81
  return Substitution.create({
80
82
  playerId: roster[event.p],
81
- playerOut: roster[event.o]
83
+ playerOut: roster[event.o],
84
+ injury: event.j === 1 ? true : undefined
82
85
  });
83
86
  }
@@ -81,7 +81,12 @@ function transformToAttributes(tactics, teamId) {
81
81
  offensive_preference_sets: tactics.offensivePreferenceSets?.map(prefs => prefs.map(p => ({
82
82
  rotation: p.rotation,
83
83
  order: p.order.map((a) => a.id)
84
- })))
84
+ }))),
85
+ replace_injured_immediately: tactics.replaceInjuredImmediately,
86
+ reserve_priorities: tactics.reservePriorities.map(rp => ({
87
+ position: rp.position,
88
+ reserves: rp.reserves.map((p) => p.id)
89
+ }))
85
90
  };
86
91
  }
87
92
  // Accepts TacticsAttributes (not just TacticsModel) so a per-match preset's config, shaped as column data by
@@ -158,7 +163,39 @@ function transformToObject(model, roster) {
158
163
  offensivePreferenceSets: model.offensive_preference_sets?.map(prefs => prefs.map(p => ({
159
164
  rotation: p.rotation,
160
165
  order: p.order.map((id) => findPlayer(id, roster))
161
- })))
166
+ }))),
167
+ replaceInjuredImmediately: model.replace_injured_immediately ?? true,
168
+ // LENIENT (roster.find, not findPlayer): a dismissed/retired reserve, one that has since moved INTO the
169
+ // lineup, one that doubles as the resolved second libero, or a duplicate (player in two lists, repeated
170
+ // position) is DROPPED rather than crashing the sim batch (reserves are depth, not structure); now-empty
171
+ // entries are pruned. First occurrence wins on duplicates; the next tactics save rewrites cleanly.
172
+ reservePriorities: (() => {
173
+ const seenIds = new Set();
174
+ const seenPositions = new Set();
175
+ return (model.reserve_priorities ?? [])
176
+ .filter(rp => {
177
+ // Court slots only (1-6): jsonb has no constraint, and an out-of-range position would pass the
178
+ // heal only to be rejected by the schema, crashing the very sim batch this heal protects.
179
+ if (!Number.isInteger(rp.position) || rp.position < 1 || rp.position > 6)
180
+ return false;
181
+ if (seenPositions.has(rp.position))
182
+ return false;
183
+ seenPositions.add(rp.position);
184
+ return true;
185
+ })
186
+ .map(rp => ({
187
+ position: rp.position,
188
+ reserves: rp.reserves
189
+ .map((id) => roster.find((p) => p.id === id))
190
+ .filter((p) => {
191
+ if (p == null || inLineupIds.has(p.id) || p.id === secondLibero?.id || seenIds.has(p.id))
192
+ return false;
193
+ seenIds.add(p.id);
194
+ return true;
195
+ })
196
+ }))
197
+ .filter(rp => rp.reserves.length > 0);
198
+ })()
162
199
  });
163
200
  }
164
201
  // Serialize a Tactics ROW (db columns) straight to the API Tactics DTO (player references stay as ids; no
@@ -218,7 +255,35 @@ function tacticsColumnsToApiDto(model) {
218
255
  designatedSetters: model.designated_setters,
219
256
  offensivePreferences: model.offensive_preferences,
220
257
  systemSets: model.system_sets,
221
- offensivePreferenceSets: model.offensive_preference_sets
258
+ offensivePreferenceSets: model.offensive_preference_sets,
259
+ replaceInjuredImmediately: model.replace_injured_immediately ?? true,
260
+ // Same stale-data policy as validSecondLibero above: don't surface reserve entries the service load
261
+ // would drop (bad position, id now in the lineup, the L2, duplicates), or the UI reloads and re-emits
262
+ // them forever. Roster membership can't be checked here (ids only); the service heal covers that.
263
+ reservePriorities: (() => {
264
+ const seenIds = new Set();
265
+ const seenPositions = new Set();
266
+ const healed = (model.reserve_priorities ?? [])
267
+ .filter(rp => {
268
+ if (!Number.isInteger(rp.position) || rp.position < 1 || rp.position > 6)
269
+ return false;
270
+ if (seenPositions.has(rp.position))
271
+ return false;
272
+ seenPositions.add(rp.position);
273
+ return true;
274
+ })
275
+ .map(rp => ({
276
+ position: rp.position,
277
+ reserves: rp.reserves.filter(id => {
278
+ if (lineupIds.has(id) || id === validSecondLibero || seenIds.has(id))
279
+ return false;
280
+ seenIds.add(id);
281
+ return true;
282
+ })
283
+ }))
284
+ .filter(rp => rp.reserves.length > 0);
285
+ return healed.length > 0 ? healed : undefined;
286
+ })()
222
287
  };
223
288
  }
224
289
  // Inverse of tacticsColumnsToApiDto: shape a preset's stored API config (camelCase, player ids) as
@@ -251,7 +316,9 @@ function apiTacticsConfigToAttributes(config, teamId) {
251
316
  designated_setters: config.designatedSetters,
252
317
  offensive_preferences: config.offensivePreferences,
253
318
  system_sets: config.systemSets,
254
- offensive_preference_sets: config.offensivePreferenceSets
319
+ offensive_preference_sets: config.offensivePreferenceSets,
320
+ replace_injured_immediately: config.replaceInjuredImmediately,
321
+ reserve_priorities: config.reservePriorities
255
322
  };
256
323
  }
257
324
  export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto, apiTacticsConfigToAttributes as apiTacticsConfigToTacticsAttributes };
@@ -2,5 +2,6 @@ import { z } from 'zod';
2
2
  export declare const SubstitutionInputSchema: z.ZodObject<{
3
3
  playerId: z.ZodUUID;
4
4
  playerOut: z.ZodUUID;
5
+ injury: z.ZodOptional<z.ZodBoolean>;
5
6
  }, z.core.$strip>;
6
7
  export type SubstitutionInput = z.infer<typeof SubstitutionInputSchema>;
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  export const SubstitutionInputSchema = z.object({
3
3
  playerId: z.uuid(),
4
- playerOut: z.uuid()
4
+ playerOut: z.uuid(),
5
+ injury: z.boolean().optional()
5
6
  }).superRefine((data, ctx) => {
6
7
  if (data.playerId === data.playerOut) {
7
8
  ctx.addIssue({
@@ -1,6 +1,7 @@
1
1
  import { RallyEvent } from './rally-event';
2
2
  export declare class Substitution extends RallyEvent {
3
3
  readonly playerOut: string;
4
+ readonly injury?: boolean;
4
5
  static create(input: unknown): Substitution;
5
6
  private constructor();
6
7
  toString(): string;
@@ -14,14 +14,16 @@ export class Substitution extends RallyEvent {
14
14
  }
15
15
  return new Substitution(result.data);
16
16
  }
17
- constructor({ playerOut, playerId }) {
17
+ constructor({ playerOut, playerId, injury }) {
18
18
  super({ eventType, playerId });
19
19
  this.playerOut = playerOut;
20
+ this.injury = injury;
20
21
  }
21
22
  toString() {
22
23
  const playerId = `"playerId":"${this.playerId}"`;
23
24
  const playerOut = `"playerOut":"${this.playerOut}"`;
24
25
  const eventType = `"eventType":${this.eventType}`;
25
- return `{${playerId},${playerOut},${eventType}}`;
26
+ const injury = this.injury === true ? ',"injury":true' : '';
27
+ return `{${playerId},${playerOut},${eventType}${injury}}`;
26
28
  }
27
29
  }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { MatchSet } from './match-set';
4
+ import { MatchTeam } from './match-team';
5
+ function makeSet(homeScore, awayScore, isTieBreak = false, forfeited) {
6
+ return MatchSet.create({
7
+ id: uuidv4(),
8
+ order: isTieBreak ? 4 : 0,
9
+ isTieBreak,
10
+ homePlayerPosition: [],
11
+ awayPlayerPosition: [],
12
+ homeScore,
13
+ awayScore,
14
+ boxScores: [],
15
+ rallies: [],
16
+ ...(forfeited != null ? { forfeited } : {})
17
+ });
18
+ }
19
+ // Mid-set states are not valid CREATION states (the schema wants 0-0 or a finished score), so build them the
20
+ // way the sim does: create at 0-0 and play to the score.
21
+ function playedTo(homeScore, awayScore, isTieBreak = false) {
22
+ const set = makeSet(0, 0, isTieBreak);
23
+ for (let i = 0; i < homeScore; i++)
24
+ set.increaseScore(MatchTeam.HOME);
25
+ for (let i = 0; i < awayScore; i++)
26
+ set.increaseScore(MatchTeam.AWAY);
27
+ return set;
28
+ }
29
+ describe('MatchSet — forfeit (FIVB 6.4.3)', () => {
30
+ it('defaults forfeited to false', () => {
31
+ expect(makeSet(25, 20).forfeited).toBe(false);
32
+ });
33
+ it('accepts a persisted forfeited set with an awarded score', () => {
34
+ const set = makeSet(25, 13, false, true);
35
+ expect(set.forfeited).toBe(true);
36
+ expect(set.isOver()).toBe(true);
37
+ expect(set.getWinner()).toBe(MatchTeam.HOME);
38
+ });
39
+ it('awards the threshold when the loser is below deuce (18-23, home granted)', () => {
40
+ const set = playedTo(18, 23);
41
+ // Score order in makeSet is home, away; away leads 23-18 but HOME is granted the forfeit win.
42
+ set.forfeit(MatchTeam.HOME);
43
+ expect(set.homeScore).toBe(25);
44
+ expect(set.awayScore).toBe(23);
45
+ expect(set.forfeited).toBe(true);
46
+ expect(set.isOver()).toBe(true);
47
+ expect(set.getWinner()).toBe(MatchTeam.HOME);
48
+ });
49
+ it('awards loser + 2 beyond deuce (24-24, away granted -> 24-26)', () => {
50
+ const set = playedTo(24, 24);
51
+ set.forfeit(MatchTeam.AWAY);
52
+ expect(set.homeScore).toBe(24);
53
+ expect(set.awayScore).toBe(26);
54
+ expect(set.isOver()).toBe(true);
55
+ expect(set.getWinner()).toBe(MatchTeam.AWAY);
56
+ });
57
+ it('keeps the forfeiting side points (0-0 fully-awarded set -> 25-0)', () => {
58
+ const set = makeSet(0, 0);
59
+ set.forfeit(MatchTeam.HOME);
60
+ expect(set.homeScore).toBe(25);
61
+ expect(set.awayScore).toBe(0);
62
+ expect(set.isOver()).toBe(true);
63
+ });
64
+ it('awards 15 on a tie break', () => {
65
+ const set = playedTo(10, 13, true);
66
+ set.forfeit(MatchTeam.HOME);
67
+ expect(set.homeScore).toBe(15);
68
+ expect(set.awayScore).toBe(13);
69
+ expect(set.isOver()).toBe(true);
70
+ });
71
+ it('rejects a forfeited set whose score never reached the awarding rule (below threshold)', () => {
72
+ expect(() => makeSet(18, 12, false, true)).toThrow(/INVALID_MATCH_SET/);
73
+ });
74
+ });
@@ -13,10 +13,18 @@ export declare class MatchSet {
13
13
  readonly scoreThreshold: ScoreThreshold;
14
14
  private _homeScore;
15
15
  private _awayScore;
16
+ private _forfeited;
16
17
  static create(input: unknown): MatchSet;
17
18
  private constructor();
18
19
  get homeScore(): number;
19
20
  get awayScore(): number;
21
+ get forfeited(): boolean;
22
+ /**
23
+ * End the set by forfeit (FIVB 6.4.3, incomplete team). The forfeiting side keeps its points; the winner
24
+ * is granted exactly the points needed to win the set (the threshold, or loser + 2 beyond deuce), so the
25
+ * final score always satisfies the normal reached-threshold and win-by-2 rules. Marks the set forfeited.
26
+ */
27
+ forfeit(winner: MatchTeam): void;
20
28
  increaseScore(team: MatchTeam): void;
21
29
  addRally(rally: Rally): void;
22
30
  /**
@@ -13,11 +13,12 @@ export class MatchSet {
13
13
  }
14
14
  return new MatchSet(result.data);
15
15
  }
16
- constructor({ id, order, isTieBreak, homePlayerPosition, awayPlayerPosition, boxScores, rallies, homeScore, awayScore }) {
16
+ constructor({ id, order, isTieBreak, homePlayerPosition, awayPlayerPosition, boxScores, rallies, homeScore, awayScore, forfeited = false }) {
17
17
  this.id = id;
18
18
  this.order = order;
19
19
  this._homeScore = homeScore;
20
20
  this._awayScore = awayScore;
21
+ this._forfeited = forfeited;
21
22
  this.homePlayerPosition = homePlayerPosition;
22
23
  this.awayPlayerPosition = awayPlayerPosition;
23
24
  this.boxScores = boxScores;
@@ -31,6 +32,27 @@ export class MatchSet {
31
32
  get awayScore() {
32
33
  return this._awayScore;
33
34
  }
35
+ get forfeited() {
36
+ return this._forfeited;
37
+ }
38
+ /**
39
+ * End the set by forfeit (FIVB 6.4.3, incomplete team). The forfeiting side keeps its points; the winner
40
+ * is granted exactly the points needed to win the set (the threshold, or loser + 2 beyond deuce), so the
41
+ * final score always satisfies the normal reached-threshold and win-by-2 rules. Marks the set forfeited.
42
+ */
43
+ forfeit(winner) {
44
+ // Forfeiting an already-decided (or already-forfeited) set would silently rewrite the winner with a
45
+ // schema-legal score: surface the caller bug instead (e.g. both teams forfeited in the same rally).
46
+ if (this._forfeited || this.isOver())
47
+ throw new Error('SET_ALREADY_DECIDED');
48
+ if (winner === MatchTeam.HOME)
49
+ this._homeScore = Math.max(this.scoreThreshold, this._awayScore + 2);
50
+ else if (winner === MatchTeam.AWAY)
51
+ this._awayScore = Math.max(this.scoreThreshold, this._homeScore + 2);
52
+ else
53
+ throw new Error('UNKNOWN_TEAM');
54
+ this._forfeited = true;
55
+ }
34
56
  increaseScore(team) {
35
57
  if (team === MatchTeam.HOME)
36
58
  this._homeScore++;
@@ -3,6 +3,7 @@ import { Team } from '../team';
3
3
  import { MatchTeam } from './match-team';
4
4
  import { StatusEnum } from '../../data';
5
5
  import { VPER } from './vper';
6
+ export type MatchForfeitType = 'INCOMPLETE' | 'DEFAULT';
6
7
  export type MatchScore = '3-0' | '3-1' | '3-2' | '2-3' | '1-3' | '0-3';
7
8
  export type SetScore = 0 | 1 | 2 | 3;
8
9
  export declare class Match {
@@ -16,6 +17,7 @@ export declare class Match {
16
17
  homeScore?: number;
17
18
  awayScore?: number;
18
19
  winner?: Team;
20
+ forfeitType?: MatchForfeitType;
19
21
  static create(input: unknown): Match;
20
22
  private constructor();
21
23
  addSet(set: MatchSet): void;
@@ -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
  });