volleyballsimtypes 0.0.474 → 0.0.475

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 (33) hide show
  1. package/dist/cjs/src/api/index.d.ts +6 -0
  2. package/dist/cjs/src/data/models/tactics.d.ts +8 -1
  3. package/dist/cjs/src/data/models/tactics.js +4 -0
  4. package/dist/cjs/src/data/transformers/tactics.js +66 -2
  5. package/dist/cjs/src/data/transformers/tactics.test.js +44 -0
  6. package/dist/cjs/src/service/team/index.d.ts +1 -0
  7. package/dist/cjs/src/service/team/index.js +1 -0
  8. package/dist/cjs/src/service/team/libero-injury.d.ts +6 -0
  9. package/dist/cjs/src/service/team/libero-injury.js +2 -0
  10. package/dist/cjs/src/service/team/schemas/libero-injury.z.test.d.ts +1 -0
  11. package/dist/cjs/src/service/team/schemas/libero-injury.z.test.js +71 -0
  12. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +5 -0
  13. package/dist/cjs/src/service/team/schemas/tactics.z.js +41 -1
  14. package/dist/cjs/src/service/team/schemas/team.z.d.ts +5 -0
  15. package/dist/cjs/src/service/team/tactics.d.ts +3 -0
  16. package/dist/cjs/src/service/team/tactics.js +2 -1
  17. package/dist/esm/src/api/index.d.ts +6 -0
  18. package/dist/esm/src/data/models/tactics.d.ts +8 -1
  19. package/dist/esm/src/data/models/tactics.js +4 -0
  20. package/dist/esm/src/data/transformers/tactics.js +66 -2
  21. package/dist/esm/src/data/transformers/tactics.test.js +44 -0
  22. package/dist/esm/src/service/team/index.d.ts +1 -0
  23. package/dist/esm/src/service/team/index.js +1 -0
  24. package/dist/esm/src/service/team/libero-injury.d.ts +6 -0
  25. package/dist/esm/src/service/team/libero-injury.js +1 -0
  26. package/dist/esm/src/service/team/schemas/libero-injury.z.test.d.ts +1 -0
  27. package/dist/esm/src/service/team/schemas/libero-injury.z.test.js +69 -0
  28. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +5 -0
  29. package/dist/esm/src/service/team/schemas/tactics.z.js +41 -1
  30. package/dist/esm/src/service/team/schemas/team.z.d.ts +5 -0
  31. package/dist/esm/src/service/team/tactics.d.ts +3 -0
  32. package/dist/esm/src/service/team/tactics.js +2 -1
  33. package/package.json +1 -1
@@ -111,6 +111,11 @@ export interface ApiInjuryReplacement {
111
111
  replacementId: string;
112
112
  benchBackfillId?: string;
113
113
  }
114
+ export interface ApiLiberoInjury {
115
+ preMatchReplacementId?: string;
116
+ secondLiberoBackfillId?: string;
117
+ inMatchDesignateId?: string;
118
+ }
114
119
  export interface ApiLiberoSetSubConfig {
115
120
  mode: LiberoSubMode;
116
121
  fatigueBand?: EnergyBand;
@@ -136,6 +141,7 @@ export interface Tactics {
136
141
  offensivePreferenceSets?: ApiOffensivePreference[][];
137
142
  replaceKnockedImmediately?: Record<string, boolean>;
138
143
  injuryReplacements?: ApiInjuryReplacement[];
144
+ liberoInjury?: ApiLiberoInjury;
139
145
  }
140
146
  export interface ApiLineupPreset {
141
147
  presetId: string;
@@ -44,6 +44,11 @@ export interface InjuryReplacementAttributes {
44
44
  replacementId: PlayerId;
45
45
  benchBackfillId?: PlayerId;
46
46
  }
47
+ export interface LiberoInjuryAttributes {
48
+ preMatchReplacementId?: PlayerId;
49
+ secondLiberoBackfillId?: PlayerId;
50
+ inMatchDesignateId?: PlayerId;
51
+ }
47
52
  export interface LiberoSetSubConfigAttributes {
48
53
  mode: LiberoSubMode;
49
54
  fatigueBand?: EnergyBand;
@@ -70,10 +75,11 @@ export interface TacticsAttributes {
70
75
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
71
76
  replace_knocked_immediately?: Record<PlayerId, boolean>;
72
77
  injury_replacements?: InjuryReplacementAttributes[];
78
+ libero_injury?: LiberoInjuryAttributes;
73
79
  }
74
80
  export type TacticsPk = 'team_id';
75
81
  export type TacticsId = TacticsModel[TacticsPk];
76
- 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_knocked_immediately' | 'injury_replacements';
82
+ 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_knocked_immediately' | 'injury_replacements' | 'libero_injury';
77
83
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
78
84
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
79
85
  team_id: string;
@@ -92,6 +98,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
92
98
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
93
99
  replace_knocked_immediately?: Record<PlayerId, boolean>;
94
100
  injury_replacements?: InjuryReplacementAttributes[];
101
+ libero_injury?: LiberoInjuryAttributes;
95
102
  team: TeamModel;
96
103
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
97
104
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -82,6 +82,10 @@ class TacticsModel extends sequelize_1.Model {
82
82
  injury_replacements: {
83
83
  type: sequelize_1.DataTypes.JSONB,
84
84
  allowNull: true
85
+ },
86
+ libero_injury: {
87
+ type: sequelize_1.DataTypes.JSONB,
88
+ allowNull: true
85
89
  }
86
90
  }, {
87
91
  sequelize,
@@ -93,7 +93,14 @@ function transformToAttributes(tactics, teamId) {
93
93
  starterId: ir.starter.id,
94
94
  replacementId: ir.replacement.id,
95
95
  benchBackfillId: ir.benchBackfill?.id
96
- }))
96
+ })),
97
+ libero_injury: tactics.liberoInjury != null
98
+ ? {
99
+ preMatchReplacementId: tactics.liberoInjury.preMatchReplacement?.id,
100
+ secondLiberoBackfillId: tactics.liberoInjury.secondLiberoBackfill?.id,
101
+ inMatchDesignateId: tactics.liberoInjury.inMatchDesignate?.id
102
+ }
103
+ : undefined
97
104
  };
98
105
  }
99
106
  // Accepts TacticsAttributes (not just TacticsModel) so a per-match preset's config, shaped as column data by
@@ -221,6 +228,38 @@ function transformToObject(model, roster) {
221
228
  healed.push({ starter, replacement, benchBackfill });
222
229
  }
223
230
  return healed;
231
+ })(),
232
+ // LENIENT (roster.find): drop any stale/invalid libero-injury id so a bad row can never crash a team load.
233
+ // Mirrors the schema pool rules: preMatchReplacement is a RESERVE or the L2 (not in the lineup) and needs a
234
+ // starting libero; secondLiberoBackfill is a RESERVE (not the current L2) kept only when the L2 is the
235
+ // replacement; inMatchDesignate is a bench/reserve player (not a court starter, the libero, or the L2).
236
+ liberoInjury: (() => {
237
+ const li = model.libero_injury;
238
+ if (li == null)
239
+ return undefined;
240
+ const liberoZoneId = lineup[service_1.CourtPosition.LIBERO_ZONE]?.id;
241
+ const injuryStarterIds = new Set([lineup[service_1.CourtPosition.LEFT_FRONT], lineup[service_1.CourtPosition.MIDDLE_FRONT], lineup[service_1.CourtPosition.RIGHT_FRONT],
242
+ lineup[service_1.CourtPosition.LEFT_BACK], lineup[service_1.CourtPosition.MIDDLE_BACK], lineup[service_1.CourtPosition.RIGHT_BACK]]
243
+ .map((p) => p.id));
244
+ const reserveOrL2 = (id) => {
245
+ if (id == null)
246
+ return undefined;
247
+ const p = roster.find((x) => x.id === id);
248
+ return p != null && !inLineupIds.has(p.id) ? p : undefined;
249
+ };
250
+ const preMatchReplacement = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
251
+ const secondLiberoBackfill = (preMatchReplacement != null && preMatchReplacement.id === secondLibero?.id)
252
+ ? (() => { const p = reserveOrL2(li.secondLiberoBackfillId); return p != null && p.id !== secondLibero?.id ? p : undefined; })()
253
+ : undefined;
254
+ const inMatchDesignate = (() => {
255
+ const p = li.inMatchDesignateId != null ? roster.find((x) => x.id === li.inMatchDesignateId) : undefined;
256
+ if (p == null || injuryStarterIds.has(p.id) || p.id === liberoZoneId || p.id === secondLibero?.id)
257
+ return undefined;
258
+ return p;
259
+ })();
260
+ if (preMatchReplacement == null && secondLiberoBackfill == null && inMatchDesignate == null)
261
+ return undefined;
262
+ return { preMatchReplacement, secondLiberoBackfill, inMatchDesignate };
224
263
  })()
225
264
  });
226
265
  }
@@ -325,6 +364,30 @@ function tacticsColumnsToApiDto(model) {
325
364
  healed.push({ starterId: ir.starterId, replacementId: ir.replacementId, benchBackfillId });
326
365
  }
327
366
  return healed.length > 0 ? healed : undefined;
367
+ })(),
368
+ // Same stale-data policy for the libero-injury ids (ids only; roster membership is checked in the service
369
+ // heal). preMatchReplacement = reserve or the L2 (not in the lineup) + needs a starting libero;
370
+ // secondLiberoBackfill = a reserve (not the current L2) kept only when the L2 is the replacement;
371
+ // inMatchDesignate = a bench/reserve player (not a court starter, the libero, or the L2).
372
+ liberoInjury: (() => {
373
+ const li = model.libero_injury;
374
+ if (li == null)
375
+ return undefined;
376
+ const liberoZoneId = model.lineup[service_1.CourtPosition.LIBERO_ZONE];
377
+ const starterSlotIds = new Set([model.lineup[service_1.CourtPosition.LEFT_FRONT], model.lineup[service_1.CourtPosition.MIDDLE_FRONT],
378
+ model.lineup[service_1.CourtPosition.RIGHT_FRONT], model.lineup[service_1.CourtPosition.LEFT_BACK],
379
+ model.lineup[service_1.CourtPosition.MIDDLE_BACK], model.lineup[service_1.CourtPosition.RIGHT_BACK]]);
380
+ const reserveOrL2 = (id) => (id != null && !lineupIds.has(id)) ? id : undefined;
381
+ const preMatchReplacementId = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
382
+ const secondLiberoBackfillId = (preMatchReplacementId != null && preMatchReplacementId === validSecondLibero)
383
+ ? (() => { const id = reserveOrL2(li.secondLiberoBackfillId); return id != null && id !== validSecondLibero ? id : undefined; })()
384
+ : undefined;
385
+ const designateEligible = li.inMatchDesignateId != null && !starterSlotIds.has(li.inMatchDesignateId) &&
386
+ li.inMatchDesignateId !== liberoZoneId && li.inMatchDesignateId !== validSecondLibero;
387
+ const inMatchDesignateId = designateEligible ? li.inMatchDesignateId : undefined;
388
+ if (preMatchReplacementId == null && secondLiberoBackfillId == null && inMatchDesignateId == null)
389
+ return undefined;
390
+ return { preMatchReplacementId, secondLiberoBackfillId, inMatchDesignateId };
328
391
  })()
329
392
  };
330
393
  }
@@ -360,6 +423,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
360
423
  system_sets: config.systemSets,
361
424
  offensive_preference_sets: config.offensivePreferenceSets,
362
425
  replace_knocked_immediately: config.replaceKnockedImmediately,
363
- injury_replacements: config.injuryReplacements
426
+ injury_replacements: config.injuryReplacements,
427
+ libero_injury: config.liberoInjury
364
428
  };
365
429
  }
@@ -139,3 +139,47 @@ function makeTactics(designatedSubs) {
139
139
  (0, globals_1.expect)(back.offensivePreferenceSets).toBeUndefined();
140
140
  });
141
141
  });
142
+ (0, globals_1.describe)('libero injuries — service <-> row round-trip + lenient heal', () => {
143
+ const country = (0, test_helpers_1.makeCountry)();
144
+ const p1 = (0, test_helpers_1.makePlayer)(country);
145
+ const p2 = (0, test_helpers_1.makePlayer)(country);
146
+ const p3 = (0, test_helpers_1.makePlayer)(country);
147
+ const p4 = (0, test_helpers_1.makePlayer)(country);
148
+ const p5 = (0, test_helpers_1.makePlayer)(country);
149
+ const p6 = (0, test_helpers_1.makePlayer)(country);
150
+ const libero = (0, test_helpers_1.makePlayer)(country);
151
+ const l2 = (0, test_helpers_1.makePlayer)(country);
152
+ const reserveA = (0, test_helpers_1.makePlayer)(country);
153
+ const reserveB = (0, test_helpers_1.makePlayer)(country);
154
+ const roster = [p1, p2, p3, p4, p5, p6, libero, l2, reserveA, reserveB];
155
+ const tactics = service_1.Tactics.create({
156
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
157
+ liberoReplacements: [],
158
+ secondLibero: l2,
159
+ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA, inMatchDesignate: reserveB }
160
+ });
161
+ (0, globals_1.it)('writes libero_injury as id-based jsonb', () => {
162
+ const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
163
+ (0, globals_1.expect)(attrs.libero_injury).toEqual({
164
+ preMatchReplacementId: l2.id,
165
+ secondLiberoBackfillId: reserveA.id,
166
+ inMatchDesignateId: reserveB.id
167
+ });
168
+ });
169
+ (0, globals_1.it)('resolves the stored libero_injury ids back to roster players', () => {
170
+ const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
171
+ const back = (0, tactics_1.transformToTactics)(attrs, roster);
172
+ (0, globals_1.expect)(back.liberoInjury?.preMatchReplacement?.id).toBe(l2.id);
173
+ (0, globals_1.expect)(back.liberoInjury?.secondLiberoBackfill?.id).toBe(reserveA.id);
174
+ (0, globals_1.expect)(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
175
+ });
176
+ (0, globals_1.it)('drops a stale pre-match replacement that is now a court starter (lenient heal, no crash)', () => {
177
+ const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
178
+ attrs.libero_injury.preMatchReplacementId = p2.id;
179
+ attrs.libero_injury.secondLiberoBackfillId = undefined;
180
+ const back = (0, tactics_1.transformToTactics)(attrs, roster);
181
+ (0, globals_1.expect)(back.liberoInjury?.preMatchReplacement).toBeUndefined();
182
+ (0, globals_1.expect)(back.liberoInjury?.secondLiberoBackfill).toBeUndefined();
183
+ (0, globals_1.expect)(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
184
+ });
185
+ });
@@ -12,3 +12,4 @@ export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
14
  export * from './injury-replacement';
15
+ export * from './libero-injury';
@@ -28,3 +28,4 @@ __exportStar(require("./lineup-function"), exports);
28
28
  __exportStar(require("./base-position"), exports);
29
29
  __exportStar(require("./offensive-preference"), exports);
30
30
  __exportStar(require("./injury-replacement"), exports);
31
+ __exportStar(require("./libero-injury"), exports);
@@ -0,0 +1,6 @@
1
+ import { Player } from '../player';
2
+ export interface LiberoInjuryConfig {
3
+ readonly preMatchReplacement?: Player;
4
+ readonly secondLiberoBackfill?: Player;
5
+ readonly inMatchDesignate?: Player;
6
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const tactics_z_1 = require("./tactics.z");
5
+ const test_helpers_1 = require("../../test-helpers");
6
+ (0, globals_1.describe)('TacticsInputSchema — liberoInjury (owner 2026-07-06)', () => {
7
+ const country = (0, test_helpers_1.makeCountry)();
8
+ const p1 = (0, test_helpers_1.makePlayer)(country);
9
+ const p2 = (0, test_helpers_1.makePlayer)(country);
10
+ const p3 = (0, test_helpers_1.makePlayer)(country);
11
+ const p4 = (0, test_helpers_1.makePlayer)(country);
12
+ const p5 = (0, test_helpers_1.makePlayer)(country);
13
+ const p6 = (0, test_helpers_1.makePlayer)(country);
14
+ const benchA = (0, test_helpers_1.makePlayer)(country);
15
+ const benchB = (0, test_helpers_1.makePlayer)(country);
16
+ const libero = (0, test_helpers_1.makePlayer)(country);
17
+ const l2 = (0, test_helpers_1.makePlayer)(country); // second libero (a reserve)
18
+ const reserveA = (0, test_helpers_1.makePlayer)(country);
19
+ const reserveB = (0, test_helpers_1.makePlayer)(country);
20
+ function baseInput(overrides = {}) {
21
+ return {
22
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [benchA, benchB] },
23
+ liberoReplacements: [],
24
+ secondLibero: l2,
25
+ ...overrides
26
+ };
27
+ }
28
+ const hasIssue = (res, message) => !res.success && res.error.issues.some(i => i.message === message);
29
+ (0, globals_1.it)('accepts no liberoInjury', () => {
30
+ (0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput()).success).toBe(true);
31
+ });
32
+ (0, globals_1.it)('accepts a reserve as the pre-match libero replacement', () => {
33
+ (0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: reserveA } })).success).toBe(true);
34
+ });
35
+ (0, globals_1.it)('accepts the L2 as the replacement with a reserve backfill (new L2)', () => {
36
+ (0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA } })).success).toBe(true);
37
+ });
38
+ (0, globals_1.it)('rejects a starter / the libero / a bench player as the pre-match replacement', () => {
39
+ for (const bad of [p2, libero, benchA]) {
40
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: bad } }));
41
+ (0, globals_1.expect)(hasIssue(res, 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2')).toBe(true);
42
+ }
43
+ });
44
+ (0, globals_1.it)('rejects a pre-match replacement when there is no starting libero', () => {
45
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
46
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [benchA, benchB] },
47
+ secondLibero: undefined,
48
+ liberoInjury: { preMatchReplacement: reserveA }
49
+ }));
50
+ (0, globals_1.expect)(hasIssue(res, 'LIBERO_INJURY_REQUIRES_LIBERO')).toBe(true);
51
+ });
52
+ (0, globals_1.it)('rejects a backfill when the replacement is not the L2', () => {
53
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: reserveA, secondLiberoBackfill: reserveB } }));
54
+ (0, globals_1.expect)(hasIssue(res, 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT')).toBe(true);
55
+ });
56
+ (0, globals_1.it)('rejects a non-reserve backfill (bench player) even when the L2 is the replacement', () => {
57
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: benchA } }));
58
+ (0, globals_1.expect)(hasIssue(res, 'LIBERO_BACKFILL_MUST_BE_RESERVE')).toBe(true);
59
+ });
60
+ (0, globals_1.it)('accepts a bench or reserve player as the in-match designate', () => {
61
+ for (const ok of [benchA, reserveA]) {
62
+ (0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { inMatchDesignate: ok } })).success).toBe(true);
63
+ }
64
+ });
65
+ (0, globals_1.it)('rejects a starter / the libero / the L2 as the in-match designate', () => {
66
+ for (const bad of [p2, libero, l2]) {
67
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ liberoInjury: { inMatchDesignate: bad } }));
68
+ (0, globals_1.expect)(hasIssue(res, 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE')).toBe(true);
69
+ }
70
+ });
71
+ });
@@ -197,5 +197,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
197
197
  replacement: z.ZodCustom<Player, Player>;
198
198
  benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
199
199
  }, z.core.$strip>>>;
200
+ liberoInjury: z.ZodOptional<z.ZodObject<{
201
+ preMatchReplacement: z.ZodOptional<z.ZodCustom<Player, Player>>;
202
+ secondLiberoBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
203
+ inMatchDesignate: z.ZodOptional<z.ZodCustom<Player, Player>>;
204
+ }, z.core.$strip>>;
200
205
  }, z.core.$strip>;
201
206
  export type TacticsInput = z.infer<typeof TacticsInputSchema>;
@@ -90,7 +90,17 @@ exports.TacticsInputSchema = zod_1.z.object({
90
90
  starter: playerInstanceSchema,
91
91
  replacement: playerInstanceSchema,
92
92
  benchBackfill: playerInstanceSchema.optional()
93
- })).default([])
93
+ })).default([]),
94
+ // Libero injuries (owner 2026-07-06). All optional => an unconfigured team behaves exactly as before.
95
+ // preMatchReplacement: the reserve or the L2 that takes an already-injured starting libero's zone at match
96
+ // load; secondLiberoBackfill: the reserve that becomes the new L2 (only when the L2 is the replacement);
97
+ // inMatchDesignate: a bench/reserve player re-designated as a full libero if the sole libero goes down
98
+ // in-match with no L2. Cross-field pool rules are in the superRefine below.
99
+ liberoInjury: zod_1.z.object({
100
+ preMatchReplacement: playerInstanceSchema.optional(),
101
+ secondLiberoBackfill: playerInstanceSchema.optional(),
102
+ inMatchDesignate: playerInstanceSchema.optional()
103
+ }).optional()
94
104
  }).superRefine((data, ctx) => {
95
105
  // Cross-field rules for the rotation system. These keep an invalid config from ever reaching the sim (the
96
106
  // pinch-server outage was caused by an unvalidated tactics payload).
@@ -232,4 +242,34 @@ exports.TacticsInputSchema = zod_1.z.object({
232
242
  seenIncoming.add(ir.benchBackfill.id);
233
243
  }
234
244
  }
245
+ // Libero injuries (owner 2026-07-06). preMatchReplacement is a RESERVE or the L2 (never a court starter, the
246
+ // libero, or a bench player) and needs a starting libero to replace. secondLiberoBackfill (the new L2) is a
247
+ // RESERVE, valid ONLY when the L2 itself is the replacement. inMatchDesignate is a bench or reserve player NOT
248
+ // on court (never a court starter, the libero, or the L2).
249
+ const liberoInjury = data.liberoInjury;
250
+ if (liberoInjury != null) {
251
+ const preMatch = liberoInjury.preMatchReplacement;
252
+ const backfill = liberoInjury.secondLiberoBackfill;
253
+ const designate = liberoInjury.inMatchDesignate;
254
+ const secondLiberoId = data.secondLibero?.id;
255
+ if (preMatch != null) {
256
+ if (libero == null) {
257
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REQUIRES_LIBERO', path: ['liberoInjury', 'preMatchReplacement'] });
258
+ }
259
+ if (inLineupIds.has(preMatch.id)) {
260
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2', path: ['liberoInjury', 'preMatchReplacement'] });
261
+ }
262
+ }
263
+ if (backfill != null) {
264
+ if (preMatch == null || preMatch.id !== secondLiberoId) {
265
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT', path: ['liberoInjury', 'secondLiberoBackfill'] });
266
+ }
267
+ if (inLineupIds.has(backfill.id) || backfill.id === secondLiberoId) {
268
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_MUST_BE_RESERVE', path: ['liberoInjury', 'secondLiberoBackfill'] });
269
+ }
270
+ }
271
+ if (designate != null && (starterIds.has(designate.id) || designate.id === liberoId || designate.id === secondLiberoId)) {
272
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE', path: ['liberoInjury', 'inMatchDesignate'] });
273
+ }
274
+ }
235
275
  });
@@ -205,6 +205,11 @@ export declare const TeamInputSchema: z.ZodObject<{
205
205
  replacement: z.ZodCustom<Player, Player>;
206
206
  benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
207
207
  }, z.core.$strip>>>;
208
+ liberoInjury: z.ZodOptional<z.ZodObject<{
209
+ preMatchReplacement: z.ZodOptional<z.ZodCustom<Player, Player>>;
210
+ secondLiberoBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
211
+ inMatchDesignate: z.ZodOptional<z.ZodCustom<Player, Player>>;
212
+ }, z.core.$strip>>;
208
213
  }, z.core.$strip>>;
209
214
  }, z.core.$strip>;
210
215
  export type TeamInput = z.infer<typeof TeamInputSchema>;
@@ -6,6 +6,7 @@ import { SubBand } from './energy-band';
6
6
  import { RotationSystemEnum } from './rotation-system';
7
7
  import { OffensivePreference } from './offensive-preference';
8
8
  import { InjuryReplacement } from './injury-replacement';
9
+ import { LiberoInjuryConfig } from './libero-injury';
9
10
  export interface StartingLineup {
10
11
  readonly [CourtPosition.LEFT_FRONT]: Player;
11
12
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -36,6 +37,7 @@ export interface TacticsOpts {
36
37
  readonly offensivePreferenceSets?: OffensivePreference[][];
37
38
  readonly replaceKnockedImmediately: Record<string, boolean>;
38
39
  readonly injuryReplacements: InjuryReplacement[];
40
+ readonly liberoInjury?: LiberoInjuryConfig;
39
41
  }
40
42
  export declare class Tactics {
41
43
  readonly lineup: StartingLineup;
@@ -53,6 +55,7 @@ export declare class Tactics {
53
55
  readonly offensivePreferenceSets?: OffensivePreference[][];
54
56
  readonly replaceKnockedImmediately: Record<string, boolean>;
55
57
  readonly injuryReplacements: InjuryReplacement[];
58
+ readonly liberoInjury?: LiberoInjuryConfig;
56
59
  static create(input: unknown): Tactics;
57
60
  private constructor();
58
61
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -18,7 +18,7 @@ class Tactics {
18
18
  }
19
19
  return new Tactics(result.data);
20
20
  }
21
- constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [] }) {
21
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury }) {
22
22
  this.lineup = lineup;
23
23
  this.liberoReplacements = liberoReplacements;
24
24
  this.receiveRotationOffset = receiveRotationOffset;
@@ -34,6 +34,7 @@ class Tactics {
34
34
  this.offensivePreferenceSets = offensivePreferenceSets;
35
35
  this.replaceKnockedImmediately = replaceKnockedImmediately;
36
36
  this.injuryReplacements = injuryReplacements;
37
+ this.liberoInjury = liberoInjury;
37
38
  }
38
39
  findPlayerInLineup(id) {
39
40
  if (this.lineup.bench.some(p => p.id === id))
@@ -111,6 +111,11 @@ export interface ApiInjuryReplacement {
111
111
  replacementId: string;
112
112
  benchBackfillId?: string;
113
113
  }
114
+ export interface ApiLiberoInjury {
115
+ preMatchReplacementId?: string;
116
+ secondLiberoBackfillId?: string;
117
+ inMatchDesignateId?: string;
118
+ }
114
119
  export interface ApiLiberoSetSubConfig {
115
120
  mode: LiberoSubMode;
116
121
  fatigueBand?: EnergyBand;
@@ -136,6 +141,7 @@ export interface Tactics {
136
141
  offensivePreferenceSets?: ApiOffensivePreference[][];
137
142
  replaceKnockedImmediately?: Record<string, boolean>;
138
143
  injuryReplacements?: ApiInjuryReplacement[];
144
+ liberoInjury?: ApiLiberoInjury;
139
145
  }
140
146
  export interface ApiLineupPreset {
141
147
  presetId: string;
@@ -44,6 +44,11 @@ export interface InjuryReplacementAttributes {
44
44
  replacementId: PlayerId;
45
45
  benchBackfillId?: PlayerId;
46
46
  }
47
+ export interface LiberoInjuryAttributes {
48
+ preMatchReplacementId?: PlayerId;
49
+ secondLiberoBackfillId?: PlayerId;
50
+ inMatchDesignateId?: PlayerId;
51
+ }
47
52
  export interface LiberoSetSubConfigAttributes {
48
53
  mode: LiberoSubMode;
49
54
  fatigueBand?: EnergyBand;
@@ -70,10 +75,11 @@ export interface TacticsAttributes {
70
75
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
71
76
  replace_knocked_immediately?: Record<PlayerId, boolean>;
72
77
  injury_replacements?: InjuryReplacementAttributes[];
78
+ libero_injury?: LiberoInjuryAttributes;
73
79
  }
74
80
  export type TacticsPk = 'team_id';
75
81
  export type TacticsId = TacticsModel[TacticsPk];
76
- 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_knocked_immediately' | 'injury_replacements';
82
+ 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_knocked_immediately' | 'injury_replacements' | 'libero_injury';
77
83
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
78
84
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
79
85
  team_id: string;
@@ -92,6 +98,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
92
98
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
93
99
  replace_knocked_immediately?: Record<PlayerId, boolean>;
94
100
  injury_replacements?: InjuryReplacementAttributes[];
101
+ libero_injury?: LiberoInjuryAttributes;
95
102
  team: TeamModel;
96
103
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
97
104
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -79,6 +79,10 @@ export class TacticsModel extends Model {
79
79
  injury_replacements: {
80
80
  type: DataTypes.JSONB,
81
81
  allowNull: true
82
+ },
83
+ libero_injury: {
84
+ type: DataTypes.JSONB,
85
+ allowNull: true
82
86
  }
83
87
  }, {
84
88
  sequelize,
@@ -87,7 +87,14 @@ function transformToAttributes(tactics, teamId) {
87
87
  starterId: ir.starter.id,
88
88
  replacementId: ir.replacement.id,
89
89
  benchBackfillId: ir.benchBackfill?.id
90
- }))
90
+ })),
91
+ libero_injury: tactics.liberoInjury != null
92
+ ? {
93
+ preMatchReplacementId: tactics.liberoInjury.preMatchReplacement?.id,
94
+ secondLiberoBackfillId: tactics.liberoInjury.secondLiberoBackfill?.id,
95
+ inMatchDesignateId: tactics.liberoInjury.inMatchDesignate?.id
96
+ }
97
+ : undefined
91
98
  };
92
99
  }
93
100
  // Accepts TacticsAttributes (not just TacticsModel) so a per-match preset's config, shaped as column data by
@@ -215,6 +222,38 @@ function transformToObject(model, roster) {
215
222
  healed.push({ starter, replacement, benchBackfill });
216
223
  }
217
224
  return healed;
225
+ })(),
226
+ // LENIENT (roster.find): drop any stale/invalid libero-injury id so a bad row can never crash a team load.
227
+ // Mirrors the schema pool rules: preMatchReplacement is a RESERVE or the L2 (not in the lineup) and needs a
228
+ // starting libero; secondLiberoBackfill is a RESERVE (not the current L2) kept only when the L2 is the
229
+ // replacement; inMatchDesignate is a bench/reserve player (not a court starter, the libero, or the L2).
230
+ liberoInjury: (() => {
231
+ const li = model.libero_injury;
232
+ if (li == null)
233
+ return undefined;
234
+ const liberoZoneId = lineup[CourtPosition.LIBERO_ZONE]?.id;
235
+ const injuryStarterIds = new Set([lineup[CourtPosition.LEFT_FRONT], lineup[CourtPosition.MIDDLE_FRONT], lineup[CourtPosition.RIGHT_FRONT],
236
+ lineup[CourtPosition.LEFT_BACK], lineup[CourtPosition.MIDDLE_BACK], lineup[CourtPosition.RIGHT_BACK]]
237
+ .map((p) => p.id));
238
+ const reserveOrL2 = (id) => {
239
+ if (id == null)
240
+ return undefined;
241
+ const p = roster.find((x) => x.id === id);
242
+ return p != null && !inLineupIds.has(p.id) ? p : undefined;
243
+ };
244
+ const preMatchReplacement = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
245
+ const secondLiberoBackfill = (preMatchReplacement != null && preMatchReplacement.id === secondLibero?.id)
246
+ ? (() => { const p = reserveOrL2(li.secondLiberoBackfillId); return p != null && p.id !== secondLibero?.id ? p : undefined; })()
247
+ : undefined;
248
+ const inMatchDesignate = (() => {
249
+ const p = li.inMatchDesignateId != null ? roster.find((x) => x.id === li.inMatchDesignateId) : undefined;
250
+ if (p == null || injuryStarterIds.has(p.id) || p.id === liberoZoneId || p.id === secondLibero?.id)
251
+ return undefined;
252
+ return p;
253
+ })();
254
+ if (preMatchReplacement == null && secondLiberoBackfill == null && inMatchDesignate == null)
255
+ return undefined;
256
+ return { preMatchReplacement, secondLiberoBackfill, inMatchDesignate };
218
257
  })()
219
258
  });
220
259
  }
@@ -319,6 +358,30 @@ function tacticsColumnsToApiDto(model) {
319
358
  healed.push({ starterId: ir.starterId, replacementId: ir.replacementId, benchBackfillId });
320
359
  }
321
360
  return healed.length > 0 ? healed : undefined;
361
+ })(),
362
+ // Same stale-data policy for the libero-injury ids (ids only; roster membership is checked in the service
363
+ // heal). preMatchReplacement = reserve or the L2 (not in the lineup) + needs a starting libero;
364
+ // secondLiberoBackfill = a reserve (not the current L2) kept only when the L2 is the replacement;
365
+ // inMatchDesignate = a bench/reserve player (not a court starter, the libero, or the L2).
366
+ liberoInjury: (() => {
367
+ const li = model.libero_injury;
368
+ if (li == null)
369
+ return undefined;
370
+ const liberoZoneId = model.lineup[CourtPosition.LIBERO_ZONE];
371
+ const starterSlotIds = new Set([model.lineup[CourtPosition.LEFT_FRONT], model.lineup[CourtPosition.MIDDLE_FRONT],
372
+ model.lineup[CourtPosition.RIGHT_FRONT], model.lineup[CourtPosition.LEFT_BACK],
373
+ model.lineup[CourtPosition.MIDDLE_BACK], model.lineup[CourtPosition.RIGHT_BACK]]);
374
+ const reserveOrL2 = (id) => (id != null && !lineupIds.has(id)) ? id : undefined;
375
+ const preMatchReplacementId = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
376
+ const secondLiberoBackfillId = (preMatchReplacementId != null && preMatchReplacementId === validSecondLibero)
377
+ ? (() => { const id = reserveOrL2(li.secondLiberoBackfillId); return id != null && id !== validSecondLibero ? id : undefined; })()
378
+ : undefined;
379
+ const designateEligible = li.inMatchDesignateId != null && !starterSlotIds.has(li.inMatchDesignateId) &&
380
+ li.inMatchDesignateId !== liberoZoneId && li.inMatchDesignateId !== validSecondLibero;
381
+ const inMatchDesignateId = designateEligible ? li.inMatchDesignateId : undefined;
382
+ if (preMatchReplacementId == null && secondLiberoBackfillId == null && inMatchDesignateId == null)
383
+ return undefined;
384
+ return { preMatchReplacementId, secondLiberoBackfillId, inMatchDesignateId };
322
385
  })()
323
386
  };
324
387
  }
@@ -354,7 +417,8 @@ function apiTacticsConfigToAttributes(config, teamId) {
354
417
  system_sets: config.systemSets,
355
418
  offensive_preference_sets: config.offensivePreferenceSets,
356
419
  replace_knocked_immediately: config.replaceKnockedImmediately,
357
- injury_replacements: config.injuryReplacements
420
+ injury_replacements: config.injuryReplacements,
421
+ libero_injury: config.liberoInjury
358
422
  };
359
423
  }
360
424
  export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto, apiTacticsConfigToAttributes as apiTacticsConfigToTacticsAttributes };
@@ -137,3 +137,47 @@ describe('per-set tactics — service <-> row round-trip', () => {
137
137
  expect(back.offensivePreferenceSets).toBeUndefined();
138
138
  });
139
139
  });
140
+ describe('libero injuries — service <-> row round-trip + lenient heal', () => {
141
+ const country = makeCountry();
142
+ const p1 = makePlayer(country);
143
+ const p2 = makePlayer(country);
144
+ const p3 = makePlayer(country);
145
+ const p4 = makePlayer(country);
146
+ const p5 = makePlayer(country);
147
+ const p6 = makePlayer(country);
148
+ const libero = makePlayer(country);
149
+ const l2 = makePlayer(country);
150
+ const reserveA = makePlayer(country);
151
+ const reserveB = makePlayer(country);
152
+ const roster = [p1, p2, p3, p4, p5, p6, libero, l2, reserveA, reserveB];
153
+ const tactics = Tactics.create({
154
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
155
+ liberoReplacements: [],
156
+ secondLibero: l2,
157
+ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA, inMatchDesignate: reserveB }
158
+ });
159
+ it('writes libero_injury as id-based jsonb', () => {
160
+ const attrs = transformFromTactics(tactics, 'team-1');
161
+ expect(attrs.libero_injury).toEqual({
162
+ preMatchReplacementId: l2.id,
163
+ secondLiberoBackfillId: reserveA.id,
164
+ inMatchDesignateId: reserveB.id
165
+ });
166
+ });
167
+ it('resolves the stored libero_injury ids back to roster players', () => {
168
+ const attrs = transformFromTactics(tactics, 'team-1');
169
+ const back = transformToTactics(attrs, roster);
170
+ expect(back.liberoInjury?.preMatchReplacement?.id).toBe(l2.id);
171
+ expect(back.liberoInjury?.secondLiberoBackfill?.id).toBe(reserveA.id);
172
+ expect(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
173
+ });
174
+ it('drops a stale pre-match replacement that is now a court starter (lenient heal, no crash)', () => {
175
+ const attrs = transformFromTactics(tactics, 'team-1');
176
+ attrs.libero_injury.preMatchReplacementId = p2.id;
177
+ attrs.libero_injury.secondLiberoBackfillId = undefined;
178
+ const back = transformToTactics(attrs, roster);
179
+ expect(back.liberoInjury?.preMatchReplacement).toBeUndefined();
180
+ expect(back.liberoInjury?.secondLiberoBackfill).toBeUndefined();
181
+ expect(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
182
+ });
183
+ });
@@ -12,3 +12,4 @@ export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
14
  export * from './injury-replacement';
15
+ export * from './libero-injury';
@@ -12,3 +12,4 @@ export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
14
  export * from './injury-replacement';
15
+ export * from './libero-injury';
@@ -0,0 +1,6 @@
1
+ import { Player } from '../player';
2
+ export interface LiberoInjuryConfig {
3
+ readonly preMatchReplacement?: Player;
4
+ readonly secondLiberoBackfill?: Player;
5
+ readonly inMatchDesignate?: Player;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { TacticsInputSchema } from './tactics.z';
3
+ import { makeCountry, makePlayer } from '../../test-helpers';
4
+ describe('TacticsInputSchema — liberoInjury (owner 2026-07-06)', () => {
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 l2 = makePlayer(country); // second libero (a reserve)
16
+ const reserveA = makePlayer(country);
17
+ const reserveB = makePlayer(country);
18
+ function baseInput(overrides = {}) {
19
+ return {
20
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [benchA, benchB] },
21
+ liberoReplacements: [],
22
+ secondLibero: l2,
23
+ ...overrides
24
+ };
25
+ }
26
+ const hasIssue = (res, message) => !res.success && res.error.issues.some(i => i.message === message);
27
+ it('accepts no liberoInjury', () => {
28
+ expect(TacticsInputSchema.safeParse(baseInput()).success).toBe(true);
29
+ });
30
+ it('accepts a reserve as the pre-match libero replacement', () => {
31
+ expect(TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: reserveA } })).success).toBe(true);
32
+ });
33
+ it('accepts the L2 as the replacement with a reserve backfill (new L2)', () => {
34
+ expect(TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA } })).success).toBe(true);
35
+ });
36
+ it('rejects a starter / the libero / a bench player as the pre-match replacement', () => {
37
+ for (const bad of [p2, libero, benchA]) {
38
+ const res = TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: bad } }));
39
+ expect(hasIssue(res, 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2')).toBe(true);
40
+ }
41
+ });
42
+ it('rejects a pre-match replacement when there is no starting libero', () => {
43
+ const res = TacticsInputSchema.safeParse(baseInput({
44
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [benchA, benchB] },
45
+ secondLibero: undefined,
46
+ liberoInjury: { preMatchReplacement: reserveA }
47
+ }));
48
+ expect(hasIssue(res, 'LIBERO_INJURY_REQUIRES_LIBERO')).toBe(true);
49
+ });
50
+ it('rejects a backfill when the replacement is not the L2', () => {
51
+ const res = TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: reserveA, secondLiberoBackfill: reserveB } }));
52
+ expect(hasIssue(res, 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT')).toBe(true);
53
+ });
54
+ it('rejects a non-reserve backfill (bench player) even when the L2 is the replacement', () => {
55
+ const res = TacticsInputSchema.safeParse(baseInput({ liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: benchA } }));
56
+ expect(hasIssue(res, 'LIBERO_BACKFILL_MUST_BE_RESERVE')).toBe(true);
57
+ });
58
+ it('accepts a bench or reserve player as the in-match designate', () => {
59
+ for (const ok of [benchA, reserveA]) {
60
+ expect(TacticsInputSchema.safeParse(baseInput({ liberoInjury: { inMatchDesignate: ok } })).success).toBe(true);
61
+ }
62
+ });
63
+ it('rejects a starter / the libero / the L2 as the in-match designate', () => {
64
+ for (const bad of [p2, libero, l2]) {
65
+ const res = TacticsInputSchema.safeParse(baseInput({ liberoInjury: { inMatchDesignate: bad } }));
66
+ expect(hasIssue(res, 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE')).toBe(true);
67
+ }
68
+ });
69
+ });
@@ -197,5 +197,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
197
197
  replacement: z.ZodCustom<Player, Player>;
198
198
  benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
199
199
  }, z.core.$strip>>>;
200
+ liberoInjury: z.ZodOptional<z.ZodObject<{
201
+ preMatchReplacement: z.ZodOptional<z.ZodCustom<Player, Player>>;
202
+ secondLiberoBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
203
+ inMatchDesignate: z.ZodOptional<z.ZodCustom<Player, Player>>;
204
+ }, z.core.$strip>>;
200
205
  }, z.core.$strip>;
201
206
  export type TacticsInput = z.infer<typeof TacticsInputSchema>;
@@ -87,7 +87,17 @@ export const TacticsInputSchema = z.object({
87
87
  starter: playerInstanceSchema,
88
88
  replacement: playerInstanceSchema,
89
89
  benchBackfill: playerInstanceSchema.optional()
90
- })).default([])
90
+ })).default([]),
91
+ // Libero injuries (owner 2026-07-06). All optional => an unconfigured team behaves exactly as before.
92
+ // preMatchReplacement: the reserve or the L2 that takes an already-injured starting libero's zone at match
93
+ // load; secondLiberoBackfill: the reserve that becomes the new L2 (only when the L2 is the replacement);
94
+ // inMatchDesignate: a bench/reserve player re-designated as a full libero if the sole libero goes down
95
+ // in-match with no L2. Cross-field pool rules are in the superRefine below.
96
+ liberoInjury: z.object({
97
+ preMatchReplacement: playerInstanceSchema.optional(),
98
+ secondLiberoBackfill: playerInstanceSchema.optional(),
99
+ inMatchDesignate: playerInstanceSchema.optional()
100
+ }).optional()
91
101
  }).superRefine((data, ctx) => {
92
102
  // Cross-field rules for the rotation system. These keep an invalid config from ever reaching the sim (the
93
103
  // pinch-server outage was caused by an unvalidated tactics payload).
@@ -229,4 +239,34 @@ export const TacticsInputSchema = z.object({
229
239
  seenIncoming.add(ir.benchBackfill.id);
230
240
  }
231
241
  }
242
+ // Libero injuries (owner 2026-07-06). preMatchReplacement is a RESERVE or the L2 (never a court starter, the
243
+ // libero, or a bench player) and needs a starting libero to replace. secondLiberoBackfill (the new L2) is a
244
+ // RESERVE, valid ONLY when the L2 itself is the replacement. inMatchDesignate is a bench or reserve player NOT
245
+ // on court (never a court starter, the libero, or the L2).
246
+ const liberoInjury = data.liberoInjury;
247
+ if (liberoInjury != null) {
248
+ const preMatch = liberoInjury.preMatchReplacement;
249
+ const backfill = liberoInjury.secondLiberoBackfill;
250
+ const designate = liberoInjury.inMatchDesignate;
251
+ const secondLiberoId = data.secondLibero?.id;
252
+ if (preMatch != null) {
253
+ if (libero == null) {
254
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REQUIRES_LIBERO', path: ['liberoInjury', 'preMatchReplacement'] });
255
+ }
256
+ if (inLineupIds.has(preMatch.id)) {
257
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2', path: ['liberoInjury', 'preMatchReplacement'] });
258
+ }
259
+ }
260
+ if (backfill != null) {
261
+ if (preMatch == null || preMatch.id !== secondLiberoId) {
262
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT', path: ['liberoInjury', 'secondLiberoBackfill'] });
263
+ }
264
+ if (inLineupIds.has(backfill.id) || backfill.id === secondLiberoId) {
265
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_MUST_BE_RESERVE', path: ['liberoInjury', 'secondLiberoBackfill'] });
266
+ }
267
+ }
268
+ if (designate != null && (starterIds.has(designate.id) || designate.id === liberoId || designate.id === secondLiberoId)) {
269
+ ctx.addIssue({ code: 'custom', message: 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE', path: ['liberoInjury', 'inMatchDesignate'] });
270
+ }
271
+ }
232
272
  });
@@ -205,6 +205,11 @@ export declare const TeamInputSchema: z.ZodObject<{
205
205
  replacement: z.ZodCustom<Player, Player>;
206
206
  benchBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
207
207
  }, z.core.$strip>>>;
208
+ liberoInjury: z.ZodOptional<z.ZodObject<{
209
+ preMatchReplacement: z.ZodOptional<z.ZodCustom<Player, Player>>;
210
+ secondLiberoBackfill: z.ZodOptional<z.ZodCustom<Player, Player>>;
211
+ inMatchDesignate: z.ZodOptional<z.ZodCustom<Player, Player>>;
212
+ }, z.core.$strip>>;
208
213
  }, z.core.$strip>>;
209
214
  }, z.core.$strip>;
210
215
  export type TeamInput = z.infer<typeof TeamInputSchema>;
@@ -6,6 +6,7 @@ import { SubBand } from './energy-band';
6
6
  import { RotationSystemEnum } from './rotation-system';
7
7
  import { OffensivePreference } from './offensive-preference';
8
8
  import { InjuryReplacement } from './injury-replacement';
9
+ import { LiberoInjuryConfig } from './libero-injury';
9
10
  export interface StartingLineup {
10
11
  readonly [CourtPosition.LEFT_FRONT]: Player;
11
12
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -36,6 +37,7 @@ export interface TacticsOpts {
36
37
  readonly offensivePreferenceSets?: OffensivePreference[][];
37
38
  readonly replaceKnockedImmediately: Record<string, boolean>;
38
39
  readonly injuryReplacements: InjuryReplacement[];
40
+ readonly liberoInjury?: LiberoInjuryConfig;
39
41
  }
40
42
  export declare class Tactics {
41
43
  readonly lineup: StartingLineup;
@@ -53,6 +55,7 @@ export declare class Tactics {
53
55
  readonly offensivePreferenceSets?: OffensivePreference[][];
54
56
  readonly replaceKnockedImmediately: Record<string, boolean>;
55
57
  readonly injuryReplacements: InjuryReplacement[];
58
+ readonly liberoInjury?: LiberoInjuryConfig;
56
59
  static create(input: unknown): Tactics;
57
60
  private constructor();
58
61
  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, replaceKnockedImmediately = {}, injuryReplacements = [] }) {
18
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury }) {
19
19
  this.lineup = lineup;
20
20
  this.liberoReplacements = liberoReplacements;
21
21
  this.receiveRotationOffset = receiveRotationOffset;
@@ -31,6 +31,7 @@ export class Tactics {
31
31
  this.offensivePreferenceSets = offensivePreferenceSets;
32
32
  this.replaceKnockedImmediately = replaceKnockedImmediately;
33
33
  this.injuryReplacements = injuryReplacements;
34
+ this.liberoInjury = liberoInjury;
34
35
  }
35
36
  findPlayerInLineup(id) {
36
37
  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.474",
3
+ "version": "0.0.475",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",