volleyballsimtypes 0.0.474 → 0.0.476
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.
- package/dist/cjs/src/api/index.d.ts +6 -0
- package/dist/cjs/src/data/models/tactics.d.ts +8 -1
- package/dist/cjs/src/data/models/tactics.js +4 -0
- package/dist/cjs/src/data/transformers/tactics.js +83 -10
- package/dist/cjs/src/data/transformers/tactics.test.js +80 -0
- package/dist/cjs/src/service/team/index.d.ts +1 -0
- package/dist/cjs/src/service/team/index.js +1 -0
- package/dist/cjs/src/service/team/libero-injury.d.ts +6 -0
- package/dist/cjs/src/service/team/libero-injury.js +2 -0
- package/dist/cjs/src/service/team/schemas/injury-replacement.z.test.js +11 -2
- package/dist/cjs/src/service/team/schemas/libero-injury.z.test.d.ts +1 -0
- package/dist/cjs/src/service/team/schemas/libero-injury.z.test.js +71 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +5 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +46 -5
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +5 -0
- package/dist/cjs/src/service/team/tactics.d.ts +3 -0
- package/dist/cjs/src/service/team/tactics.js +2 -1
- package/dist/esm/src/api/index.d.ts +6 -0
- package/dist/esm/src/data/models/tactics.d.ts +8 -1
- package/dist/esm/src/data/models/tactics.js +4 -0
- package/dist/esm/src/data/transformers/tactics.js +83 -10
- package/dist/esm/src/data/transformers/tactics.test.js +80 -0
- package/dist/esm/src/service/team/index.d.ts +1 -0
- package/dist/esm/src/service/team/index.js +1 -0
- package/dist/esm/src/service/team/libero-injury.d.ts +6 -0
- package/dist/esm/src/service/team/libero-injury.js +1 -0
- package/dist/esm/src/service/team/schemas/injury-replacement.z.test.js +11 -2
- package/dist/esm/src/service/team/schemas/libero-injury.z.test.d.ts +1 -0
- package/dist/esm/src/service/team/schemas/libero-injury.z.test.js +69 -0
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +5 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +46 -5
- package/dist/esm/src/service/team/schemas/team.z.d.ts +5 -0
- package/dist/esm/src/service/team/tactics.d.ts +3 -0
- package/dist/esm/src/service/team/tactics.js +2 -1
- 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>;
|
|
@@ -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
|
|
@@ -171,15 +178,20 @@ function transformToObject(model, roster) {
|
|
|
171
178
|
rotation: p.rotation,
|
|
172
179
|
order: p.order.map((id) => findPlayer(id, roster))
|
|
173
180
|
}))),
|
|
174
|
-
// LENIENT: keep only
|
|
175
|
-
//
|
|
181
|
+
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
182
|
+
// libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
|
|
183
|
+
// key, a non-boolean value, a libero key left after the L2 was removed) can never fail schema validation
|
|
184
|
+
// and crash a team load; a dropped key = the ON default.
|
|
176
185
|
replaceKnockedImmediately: (() => {
|
|
177
|
-
const
|
|
186
|
+
const allowedIds = new Set([lineup[service_1.CourtPosition.LEFT_FRONT], lineup[service_1.CourtPosition.MIDDLE_FRONT], lineup[service_1.CourtPosition.RIGHT_FRONT],
|
|
178
187
|
lineup[service_1.CourtPosition.LEFT_BACK], lineup[service_1.CourtPosition.MIDDLE_BACK], lineup[service_1.CourtPosition.RIGHT_BACK]]
|
|
179
188
|
.map((p) => p.id));
|
|
189
|
+
const liberoZoneId = lineup[service_1.CourtPosition.LIBERO_ZONE]?.id;
|
|
190
|
+
if (liberoZoneId != null && secondLibero != null)
|
|
191
|
+
allowedIds.add(liberoZoneId);
|
|
180
192
|
const healed = {};
|
|
181
193
|
for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
|
|
182
|
-
if (
|
|
194
|
+
if (allowedIds.has(id) && typeof value === 'boolean')
|
|
183
195
|
healed[id] = value;
|
|
184
196
|
}
|
|
185
197
|
return healed;
|
|
@@ -221,6 +233,38 @@ function transformToObject(model, roster) {
|
|
|
221
233
|
healed.push({ starter, replacement, benchBackfill });
|
|
222
234
|
}
|
|
223
235
|
return healed;
|
|
236
|
+
})(),
|
|
237
|
+
// LENIENT (roster.find): drop any stale/invalid libero-injury id so a bad row can never crash a team load.
|
|
238
|
+
// Mirrors the schema pool rules: preMatchReplacement is a RESERVE or the L2 (not in the lineup) and needs a
|
|
239
|
+
// starting libero; secondLiberoBackfill is a RESERVE (not the current L2) kept only when the L2 is the
|
|
240
|
+
// replacement; inMatchDesignate is a bench/reserve player (not a court starter, the libero, or the L2).
|
|
241
|
+
liberoInjury: (() => {
|
|
242
|
+
const li = model.libero_injury;
|
|
243
|
+
if (li == null)
|
|
244
|
+
return undefined;
|
|
245
|
+
const liberoZoneId = lineup[service_1.CourtPosition.LIBERO_ZONE]?.id;
|
|
246
|
+
const injuryStarterIds = new Set([lineup[service_1.CourtPosition.LEFT_FRONT], lineup[service_1.CourtPosition.MIDDLE_FRONT], lineup[service_1.CourtPosition.RIGHT_FRONT],
|
|
247
|
+
lineup[service_1.CourtPosition.LEFT_BACK], lineup[service_1.CourtPosition.MIDDLE_BACK], lineup[service_1.CourtPosition.RIGHT_BACK]]
|
|
248
|
+
.map((p) => p.id));
|
|
249
|
+
const reserveOrL2 = (id) => {
|
|
250
|
+
if (id == null)
|
|
251
|
+
return undefined;
|
|
252
|
+
const p = roster.find((x) => x.id === id);
|
|
253
|
+
return p != null && !inLineupIds.has(p.id) ? p : undefined;
|
|
254
|
+
};
|
|
255
|
+
const preMatchReplacement = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
|
|
256
|
+
const secondLiberoBackfill = (preMatchReplacement != null && preMatchReplacement.id === secondLibero?.id)
|
|
257
|
+
? (() => { const p = reserveOrL2(li.secondLiberoBackfillId); return p != null && p.id !== secondLibero?.id ? p : undefined; })()
|
|
258
|
+
: undefined;
|
|
259
|
+
const inMatchDesignate = (() => {
|
|
260
|
+
const p = li.inMatchDesignateId != null ? roster.find((x) => x.id === li.inMatchDesignateId) : undefined;
|
|
261
|
+
if (p == null || injuryStarterIds.has(p.id) || p.id === liberoZoneId || p.id === secondLibero?.id)
|
|
262
|
+
return undefined;
|
|
263
|
+
return p;
|
|
264
|
+
})();
|
|
265
|
+
if (preMatchReplacement == null && secondLiberoBackfill == null && inMatchDesignate == null)
|
|
266
|
+
return undefined;
|
|
267
|
+
return { preMatchReplacement, secondLiberoBackfill, inMatchDesignate };
|
|
224
268
|
})()
|
|
225
269
|
});
|
|
226
270
|
}
|
|
@@ -282,15 +326,19 @@ function tacticsColumnsToApiDto(model) {
|
|
|
282
326
|
offensivePreferences: model.offensive_preferences,
|
|
283
327
|
systemSets: model.system_sets,
|
|
284
328
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
285
|
-
// Same stale-data policy as the heals below: only
|
|
286
|
-
//
|
|
329
|
+
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
330
|
+
// libero exists, the starting libero (the libero knock toggle is an L2-only feature, owner 2026-07-06)
|
|
331
|
+
// survive; an empty map surfaces as absent (missing key = ON).
|
|
287
332
|
replaceKnockedImmediately: (() => {
|
|
288
|
-
const
|
|
333
|
+
const allowedSlotIds = new Set([model.lineup[service_1.CourtPosition.LEFT_FRONT], model.lineup[service_1.CourtPosition.MIDDLE_FRONT],
|
|
289
334
|
model.lineup[service_1.CourtPosition.RIGHT_FRONT], model.lineup[service_1.CourtPosition.LEFT_BACK],
|
|
290
335
|
model.lineup[service_1.CourtPosition.MIDDLE_BACK], model.lineup[service_1.CourtPosition.RIGHT_BACK]]);
|
|
336
|
+
const liberoZoneId = model.lineup[service_1.CourtPosition.LIBERO_ZONE];
|
|
337
|
+
if (liberoZoneId != null && validSecondLibero != null)
|
|
338
|
+
allowedSlotIds.add(liberoZoneId);
|
|
291
339
|
const healed = {};
|
|
292
340
|
for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
|
|
293
|
-
if (
|
|
341
|
+
if (allowedSlotIds.has(id) && typeof value === 'boolean')
|
|
294
342
|
healed[id] = value;
|
|
295
343
|
}
|
|
296
344
|
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
@@ -325,6 +373,30 @@ function tacticsColumnsToApiDto(model) {
|
|
|
325
373
|
healed.push({ starterId: ir.starterId, replacementId: ir.replacementId, benchBackfillId });
|
|
326
374
|
}
|
|
327
375
|
return healed.length > 0 ? healed : undefined;
|
|
376
|
+
})(),
|
|
377
|
+
// Same stale-data policy for the libero-injury ids (ids only; roster membership is checked in the service
|
|
378
|
+
// heal). preMatchReplacement = reserve or the L2 (not in the lineup) + needs a starting libero;
|
|
379
|
+
// secondLiberoBackfill = a reserve (not the current L2) kept only when the L2 is the replacement;
|
|
380
|
+
// inMatchDesignate = a bench/reserve player (not a court starter, the libero, or the L2).
|
|
381
|
+
liberoInjury: (() => {
|
|
382
|
+
const li = model.libero_injury;
|
|
383
|
+
if (li == null)
|
|
384
|
+
return undefined;
|
|
385
|
+
const liberoZoneId = model.lineup[service_1.CourtPosition.LIBERO_ZONE];
|
|
386
|
+
const starterSlotIds = new Set([model.lineup[service_1.CourtPosition.LEFT_FRONT], model.lineup[service_1.CourtPosition.MIDDLE_FRONT],
|
|
387
|
+
model.lineup[service_1.CourtPosition.RIGHT_FRONT], model.lineup[service_1.CourtPosition.LEFT_BACK],
|
|
388
|
+
model.lineup[service_1.CourtPosition.MIDDLE_BACK], model.lineup[service_1.CourtPosition.RIGHT_BACK]]);
|
|
389
|
+
const reserveOrL2 = (id) => (id != null && !lineupIds.has(id)) ? id : undefined;
|
|
390
|
+
const preMatchReplacementId = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
|
|
391
|
+
const secondLiberoBackfillId = (preMatchReplacementId != null && preMatchReplacementId === validSecondLibero)
|
|
392
|
+
? (() => { const id = reserveOrL2(li.secondLiberoBackfillId); return id != null && id !== validSecondLibero ? id : undefined; })()
|
|
393
|
+
: undefined;
|
|
394
|
+
const designateEligible = li.inMatchDesignateId != null && !starterSlotIds.has(li.inMatchDesignateId) &&
|
|
395
|
+
li.inMatchDesignateId !== liberoZoneId && li.inMatchDesignateId !== validSecondLibero;
|
|
396
|
+
const inMatchDesignateId = designateEligible ? li.inMatchDesignateId : undefined;
|
|
397
|
+
if (preMatchReplacementId == null && secondLiberoBackfillId == null && inMatchDesignateId == null)
|
|
398
|
+
return undefined;
|
|
399
|
+
return { preMatchReplacementId, secondLiberoBackfillId, inMatchDesignateId };
|
|
328
400
|
})()
|
|
329
401
|
};
|
|
330
402
|
}
|
|
@@ -360,6 +432,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
360
432
|
system_sets: config.systemSets,
|
|
361
433
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
362
434
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
363
|
-
injury_replacements: config.injuryReplacements
|
|
435
|
+
injury_replacements: config.injuryReplacements,
|
|
436
|
+
libero_injury: config.liberoInjury
|
|
364
437
|
};
|
|
365
438
|
}
|
|
@@ -78,6 +78,21 @@ function makeTactics(designatedSubs) {
|
|
|
78
78
|
(0, globals_1.expect)(dto.secondLibero).toBe('reserve1');
|
|
79
79
|
(0, globals_1.expect)(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
|
|
80
80
|
});
|
|
81
|
+
(0, globals_1.it)('keeps a libero-keyed replaceKnockedImmediately entry when a second libero exists (L2-only feature)', () => {
|
|
82
|
+
const model = makeTactics([]);
|
|
83
|
+
model.lineup[service_1.CourtPosition.LIBERO_ZONE] = 'libero1';
|
|
84
|
+
model.second_libero = 'reserve1';
|
|
85
|
+
model.replace_knocked_immediately = { libero1: false, lf: false };
|
|
86
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
87
|
+
(0, globals_1.expect)(dto.replaceKnockedImmediately).toEqual({ libero1: false, lf: false });
|
|
88
|
+
});
|
|
89
|
+
(0, globals_1.it)('drops a libero-keyed replaceKnockedImmediately entry when there is no second libero', () => {
|
|
90
|
+
const model = makeTactics([]);
|
|
91
|
+
model.lineup[service_1.CourtPosition.LIBERO_ZONE] = 'libero1';
|
|
92
|
+
model.replace_knocked_immediately = { libero1: false, lf: false };
|
|
93
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
94
|
+
(0, globals_1.expect)(dto.replaceKnockedImmediately).toEqual({ lf: false });
|
|
95
|
+
});
|
|
81
96
|
(0, globals_1.it)('passes per-set tactics columns through to the API DTO shape', () => {
|
|
82
97
|
const model = makeTactics([]);
|
|
83
98
|
model.system_sets = [{ rotationSystem: service_1.RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
|
|
@@ -139,3 +154,68 @@ function makeTactics(designatedSubs) {
|
|
|
139
154
|
(0, globals_1.expect)(back.offensivePreferenceSets).toBeUndefined();
|
|
140
155
|
});
|
|
141
156
|
});
|
|
157
|
+
(0, globals_1.describe)('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
158
|
+
const country = (0, test_helpers_1.makeCountry)();
|
|
159
|
+
const p1 = (0, test_helpers_1.makePlayer)(country);
|
|
160
|
+
const p2 = (0, test_helpers_1.makePlayer)(country);
|
|
161
|
+
const p3 = (0, test_helpers_1.makePlayer)(country);
|
|
162
|
+
const p4 = (0, test_helpers_1.makePlayer)(country);
|
|
163
|
+
const p5 = (0, test_helpers_1.makePlayer)(country);
|
|
164
|
+
const p6 = (0, test_helpers_1.makePlayer)(country);
|
|
165
|
+
const libero = (0, test_helpers_1.makePlayer)(country);
|
|
166
|
+
const l2 = (0, test_helpers_1.makePlayer)(country);
|
|
167
|
+
const reserveA = (0, test_helpers_1.makePlayer)(country);
|
|
168
|
+
const reserveB = (0, test_helpers_1.makePlayer)(country);
|
|
169
|
+
const roster = [p1, p2, p3, p4, p5, p6, libero, l2, reserveA, reserveB];
|
|
170
|
+
const tactics = service_1.Tactics.create({
|
|
171
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
172
|
+
liberoReplacements: [],
|
|
173
|
+
secondLibero: l2,
|
|
174
|
+
liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA, inMatchDesignate: reserveB }
|
|
175
|
+
});
|
|
176
|
+
(0, globals_1.it)('writes libero_injury as id-based jsonb', () => {
|
|
177
|
+
const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
|
|
178
|
+
(0, globals_1.expect)(attrs.libero_injury).toEqual({
|
|
179
|
+
preMatchReplacementId: l2.id,
|
|
180
|
+
secondLiberoBackfillId: reserveA.id,
|
|
181
|
+
inMatchDesignateId: reserveB.id
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
(0, globals_1.it)('resolves the stored libero_injury ids back to roster players', () => {
|
|
185
|
+
const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
|
|
186
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
187
|
+
(0, globals_1.expect)(back.liberoInjury?.preMatchReplacement?.id).toBe(l2.id);
|
|
188
|
+
(0, globals_1.expect)(back.liberoInjury?.secondLiberoBackfill?.id).toBe(reserveA.id);
|
|
189
|
+
(0, globals_1.expect)(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
|
|
190
|
+
});
|
|
191
|
+
(0, globals_1.it)('drops a stale pre-match replacement that is now a court starter (lenient heal, no crash)', () => {
|
|
192
|
+
const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
|
|
193
|
+
attrs.libero_injury.preMatchReplacementId = p2.id;
|
|
194
|
+
attrs.libero_injury.secondLiberoBackfillId = undefined;
|
|
195
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
196
|
+
(0, globals_1.expect)(back.liberoInjury?.preMatchReplacement).toBeUndefined();
|
|
197
|
+
(0, globals_1.expect)(back.liberoInjury?.secondLiberoBackfill).toBeUndefined();
|
|
198
|
+
(0, globals_1.expect)(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
|
|
199
|
+
});
|
|
200
|
+
(0, globals_1.it)('keeps a libero-keyed replaceKnockedImmediately entry on load when an L2 exists', () => {
|
|
201
|
+
const withKnock = service_1.Tactics.create({
|
|
202
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
203
|
+
liberoReplacements: [],
|
|
204
|
+
secondLibero: l2,
|
|
205
|
+
replaceKnockedImmediately: { [libero.id]: false, [p1.id]: false }
|
|
206
|
+
});
|
|
207
|
+
const attrs = (0, tactics_1.transformFromTactics)(withKnock, 'team-1');
|
|
208
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
209
|
+
(0, globals_1.expect)(back.replaceKnockedImmediately).toEqual({ [libero.id]: false, [p1.id]: false });
|
|
210
|
+
});
|
|
211
|
+
(0, globals_1.it)('drops a libero-keyed replaceKnockedImmediately entry on load when there is no L2', () => {
|
|
212
|
+
const withKnock = service_1.Tactics.create({
|
|
213
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
214
|
+
liberoReplacements: [],
|
|
215
|
+
replaceKnockedImmediately: { [libero.id]: false, [p1.id]: false }
|
|
216
|
+
});
|
|
217
|
+
const attrs = (0, tactics_1.transformFromTactics)(withKnock, 'team-1');
|
|
218
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
219
|
+
(0, globals_1.expect)(back.replaceKnockedImmediately).toEqual({ [p1.id]: false });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -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);
|
|
@@ -49,8 +49,17 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
49
49
|
(0, globals_1.expect)(res.data.replaceKnockedImmediately).toEqual({ [p1.id]: false, [p3.id]: true });
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
|
-
(0, globals_1.it)('
|
|
53
|
-
|
|
52
|
+
(0, globals_1.it)('accepts a libero-keyed replaceKnockedImmediately map', () => {
|
|
53
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
54
|
+
replaceKnockedImmediately: { [libero.id]: false }
|
|
55
|
+
}));
|
|
56
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
57
|
+
if (res.success) {
|
|
58
|
+
(0, globals_1.expect)(res.data.replaceKnockedImmediately).toEqual({ [libero.id]: false });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
(0, globals_1.it)('rejects a replaceKnockedImmediately key that is not a court starter or the libero', () => {
|
|
62
|
+
for (const outsider of [benchA, reserveA]) {
|
|
54
63
|
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
55
64
|
replaceKnockedImmediately: { [outsider.id]: false }
|
|
56
65
|
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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).
|
|
@@ -188,10 +198,11 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
200
|
}
|
|
191
|
-
// Per-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
// Per-player knock toggle: every key must be a court starter OR the starting libero. The libero may carry its
|
|
202
|
+
// own key so the user can choose to keep a lightly knocked libero on court (owner 2026-07-06). Bench/reserve
|
|
203
|
+
// players still follow the slot they occupy, so they never carry their own key.
|
|
204
|
+
for (const knockedId of Object.keys(data.replaceKnockedImmediately)) {
|
|
205
|
+
if (!starterIds.has(knockedId) && knockedId !== libero?.id) {
|
|
195
206
|
ctx.addIssue({ code: 'custom', message: 'REPLACE_KNOCKED_KEY_NOT_A_STARTER', path: ['replaceKnockedImmediately'] });
|
|
196
207
|
}
|
|
197
208
|
}
|
|
@@ -232,4 +243,34 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
232
243
|
seenIncoming.add(ir.benchBackfill.id);
|
|
233
244
|
}
|
|
234
245
|
}
|
|
246
|
+
// Libero injuries (owner 2026-07-06). preMatchReplacement is a RESERVE or the L2 (never a court starter, the
|
|
247
|
+
// libero, or a bench player) and needs a starting libero to replace. secondLiberoBackfill (the new L2) is a
|
|
248
|
+
// RESERVE, valid ONLY when the L2 itself is the replacement. inMatchDesignate is a bench or reserve player NOT
|
|
249
|
+
// on court (never a court starter, the libero, or the L2).
|
|
250
|
+
const liberoInjury = data.liberoInjury;
|
|
251
|
+
if (liberoInjury != null) {
|
|
252
|
+
const preMatch = liberoInjury.preMatchReplacement;
|
|
253
|
+
const backfill = liberoInjury.secondLiberoBackfill;
|
|
254
|
+
const designate = liberoInjury.inMatchDesignate;
|
|
255
|
+
const secondLiberoId = data.secondLibero?.id;
|
|
256
|
+
if (preMatch != null) {
|
|
257
|
+
if (libero == null) {
|
|
258
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REQUIRES_LIBERO', path: ['liberoInjury', 'preMatchReplacement'] });
|
|
259
|
+
}
|
|
260
|
+
if (inLineupIds.has(preMatch.id)) {
|
|
261
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2', path: ['liberoInjury', 'preMatchReplacement'] });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (backfill != null) {
|
|
265
|
+
if (preMatch == null || preMatch.id !== secondLiberoId) {
|
|
266
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT', path: ['liberoInjury', 'secondLiberoBackfill'] });
|
|
267
|
+
}
|
|
268
|
+
if (inLineupIds.has(backfill.id) || backfill.id === secondLiberoId) {
|
|
269
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_MUST_BE_RESERVE', path: ['liberoInjury', 'secondLiberoBackfill'] });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (designate != null && (starterIds.has(designate.id) || designate.id === liberoId || designate.id === secondLiberoId)) {
|
|
273
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE', path: ['liberoInjury', 'inMatchDesignate'] });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
235
276
|
});
|
|
@@ -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>;
|
|
@@ -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
|
|
@@ -165,15 +172,20 @@ function transformToObject(model, roster) {
|
|
|
165
172
|
rotation: p.rotation,
|
|
166
173
|
order: p.order.map((id) => findPlayer(id, roster))
|
|
167
174
|
}))),
|
|
168
|
-
// LENIENT: keep only
|
|
169
|
-
//
|
|
175
|
+
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
176
|
+
// libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
|
|
177
|
+
// key, a non-boolean value, a libero key left after the L2 was removed) can never fail schema validation
|
|
178
|
+
// and crash a team load; a dropped key = the ON default.
|
|
170
179
|
replaceKnockedImmediately: (() => {
|
|
171
|
-
const
|
|
180
|
+
const allowedIds = new Set([lineup[CourtPosition.LEFT_FRONT], lineup[CourtPosition.MIDDLE_FRONT], lineup[CourtPosition.RIGHT_FRONT],
|
|
172
181
|
lineup[CourtPosition.LEFT_BACK], lineup[CourtPosition.MIDDLE_BACK], lineup[CourtPosition.RIGHT_BACK]]
|
|
173
182
|
.map((p) => p.id));
|
|
183
|
+
const liberoZoneId = lineup[CourtPosition.LIBERO_ZONE]?.id;
|
|
184
|
+
if (liberoZoneId != null && secondLibero != null)
|
|
185
|
+
allowedIds.add(liberoZoneId);
|
|
174
186
|
const healed = {};
|
|
175
187
|
for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
|
|
176
|
-
if (
|
|
188
|
+
if (allowedIds.has(id) && typeof value === 'boolean')
|
|
177
189
|
healed[id] = value;
|
|
178
190
|
}
|
|
179
191
|
return healed;
|
|
@@ -215,6 +227,38 @@ function transformToObject(model, roster) {
|
|
|
215
227
|
healed.push({ starter, replacement, benchBackfill });
|
|
216
228
|
}
|
|
217
229
|
return healed;
|
|
230
|
+
})(),
|
|
231
|
+
// LENIENT (roster.find): drop any stale/invalid libero-injury id so a bad row can never crash a team load.
|
|
232
|
+
// Mirrors the schema pool rules: preMatchReplacement is a RESERVE or the L2 (not in the lineup) and needs a
|
|
233
|
+
// starting libero; secondLiberoBackfill is a RESERVE (not the current L2) kept only when the L2 is the
|
|
234
|
+
// replacement; inMatchDesignate is a bench/reserve player (not a court starter, the libero, or the L2).
|
|
235
|
+
liberoInjury: (() => {
|
|
236
|
+
const li = model.libero_injury;
|
|
237
|
+
if (li == null)
|
|
238
|
+
return undefined;
|
|
239
|
+
const liberoZoneId = lineup[CourtPosition.LIBERO_ZONE]?.id;
|
|
240
|
+
const injuryStarterIds = new Set([lineup[CourtPosition.LEFT_FRONT], lineup[CourtPosition.MIDDLE_FRONT], lineup[CourtPosition.RIGHT_FRONT],
|
|
241
|
+
lineup[CourtPosition.LEFT_BACK], lineup[CourtPosition.MIDDLE_BACK], lineup[CourtPosition.RIGHT_BACK]]
|
|
242
|
+
.map((p) => p.id));
|
|
243
|
+
const reserveOrL2 = (id) => {
|
|
244
|
+
if (id == null)
|
|
245
|
+
return undefined;
|
|
246
|
+
const p = roster.find((x) => x.id === id);
|
|
247
|
+
return p != null && !inLineupIds.has(p.id) ? p : undefined;
|
|
248
|
+
};
|
|
249
|
+
const preMatchReplacement = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
|
|
250
|
+
const secondLiberoBackfill = (preMatchReplacement != null && preMatchReplacement.id === secondLibero?.id)
|
|
251
|
+
? (() => { const p = reserveOrL2(li.secondLiberoBackfillId); return p != null && p.id !== secondLibero?.id ? p : undefined; })()
|
|
252
|
+
: undefined;
|
|
253
|
+
const inMatchDesignate = (() => {
|
|
254
|
+
const p = li.inMatchDesignateId != null ? roster.find((x) => x.id === li.inMatchDesignateId) : undefined;
|
|
255
|
+
if (p == null || injuryStarterIds.has(p.id) || p.id === liberoZoneId || p.id === secondLibero?.id)
|
|
256
|
+
return undefined;
|
|
257
|
+
return p;
|
|
258
|
+
})();
|
|
259
|
+
if (preMatchReplacement == null && secondLiberoBackfill == null && inMatchDesignate == null)
|
|
260
|
+
return undefined;
|
|
261
|
+
return { preMatchReplacement, secondLiberoBackfill, inMatchDesignate };
|
|
218
262
|
})()
|
|
219
263
|
});
|
|
220
264
|
}
|
|
@@ -276,15 +320,19 @@ function tacticsColumnsToApiDto(model) {
|
|
|
276
320
|
offensivePreferences: model.offensive_preferences,
|
|
277
321
|
systemSets: model.system_sets,
|
|
278
322
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
279
|
-
// Same stale-data policy as the heals below: only
|
|
280
|
-
//
|
|
323
|
+
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
324
|
+
// libero exists, the starting libero (the libero knock toggle is an L2-only feature, owner 2026-07-06)
|
|
325
|
+
// survive; an empty map surfaces as absent (missing key = ON).
|
|
281
326
|
replaceKnockedImmediately: (() => {
|
|
282
|
-
const
|
|
327
|
+
const allowedSlotIds = new Set([model.lineup[CourtPosition.LEFT_FRONT], model.lineup[CourtPosition.MIDDLE_FRONT],
|
|
283
328
|
model.lineup[CourtPosition.RIGHT_FRONT], model.lineup[CourtPosition.LEFT_BACK],
|
|
284
329
|
model.lineup[CourtPosition.MIDDLE_BACK], model.lineup[CourtPosition.RIGHT_BACK]]);
|
|
330
|
+
const liberoZoneId = model.lineup[CourtPosition.LIBERO_ZONE];
|
|
331
|
+
if (liberoZoneId != null && validSecondLibero != null)
|
|
332
|
+
allowedSlotIds.add(liberoZoneId);
|
|
285
333
|
const healed = {};
|
|
286
334
|
for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
|
|
287
|
-
if (
|
|
335
|
+
if (allowedSlotIds.has(id) && typeof value === 'boolean')
|
|
288
336
|
healed[id] = value;
|
|
289
337
|
}
|
|
290
338
|
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
@@ -319,6 +367,30 @@ function tacticsColumnsToApiDto(model) {
|
|
|
319
367
|
healed.push({ starterId: ir.starterId, replacementId: ir.replacementId, benchBackfillId });
|
|
320
368
|
}
|
|
321
369
|
return healed.length > 0 ? healed : undefined;
|
|
370
|
+
})(),
|
|
371
|
+
// Same stale-data policy for the libero-injury ids (ids only; roster membership is checked in the service
|
|
372
|
+
// heal). preMatchReplacement = reserve or the L2 (not in the lineup) + needs a starting libero;
|
|
373
|
+
// secondLiberoBackfill = a reserve (not the current L2) kept only when the L2 is the replacement;
|
|
374
|
+
// inMatchDesignate = a bench/reserve player (not a court starter, the libero, or the L2).
|
|
375
|
+
liberoInjury: (() => {
|
|
376
|
+
const li = model.libero_injury;
|
|
377
|
+
if (li == null)
|
|
378
|
+
return undefined;
|
|
379
|
+
const liberoZoneId = model.lineup[CourtPosition.LIBERO_ZONE];
|
|
380
|
+
const starterSlotIds = new Set([model.lineup[CourtPosition.LEFT_FRONT], model.lineup[CourtPosition.MIDDLE_FRONT],
|
|
381
|
+
model.lineup[CourtPosition.RIGHT_FRONT], model.lineup[CourtPosition.LEFT_BACK],
|
|
382
|
+
model.lineup[CourtPosition.MIDDLE_BACK], model.lineup[CourtPosition.RIGHT_BACK]]);
|
|
383
|
+
const reserveOrL2 = (id) => (id != null && !lineupIds.has(id)) ? id : undefined;
|
|
384
|
+
const preMatchReplacementId = liberoZoneId != null ? reserveOrL2(li.preMatchReplacementId) : undefined;
|
|
385
|
+
const secondLiberoBackfillId = (preMatchReplacementId != null && preMatchReplacementId === validSecondLibero)
|
|
386
|
+
? (() => { const id = reserveOrL2(li.secondLiberoBackfillId); return id != null && id !== validSecondLibero ? id : undefined; })()
|
|
387
|
+
: undefined;
|
|
388
|
+
const designateEligible = li.inMatchDesignateId != null && !starterSlotIds.has(li.inMatchDesignateId) &&
|
|
389
|
+
li.inMatchDesignateId !== liberoZoneId && li.inMatchDesignateId !== validSecondLibero;
|
|
390
|
+
const inMatchDesignateId = designateEligible ? li.inMatchDesignateId : undefined;
|
|
391
|
+
if (preMatchReplacementId == null && secondLiberoBackfillId == null && inMatchDesignateId == null)
|
|
392
|
+
return undefined;
|
|
393
|
+
return { preMatchReplacementId, secondLiberoBackfillId, inMatchDesignateId };
|
|
322
394
|
})()
|
|
323
395
|
};
|
|
324
396
|
}
|
|
@@ -354,7 +426,8 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
354
426
|
system_sets: config.systemSets,
|
|
355
427
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
356
428
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
357
|
-
injury_replacements: config.injuryReplacements
|
|
429
|
+
injury_replacements: config.injuryReplacements,
|
|
430
|
+
libero_injury: config.liberoInjury
|
|
358
431
|
};
|
|
359
432
|
}
|
|
360
433
|
export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto, apiTacticsConfigToAttributes as apiTacticsConfigToTacticsAttributes };
|
|
@@ -76,6 +76,21 @@ describe('tacticsColumnsToApiDto()', () => {
|
|
|
76
76
|
expect(dto.secondLibero).toBe('reserve1');
|
|
77
77
|
expect(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
|
|
78
78
|
});
|
|
79
|
+
it('keeps a libero-keyed replaceKnockedImmediately entry when a second libero exists (L2-only feature)', () => {
|
|
80
|
+
const model = makeTactics([]);
|
|
81
|
+
model.lineup[CourtPosition.LIBERO_ZONE] = 'libero1';
|
|
82
|
+
model.second_libero = 'reserve1';
|
|
83
|
+
model.replace_knocked_immediately = { libero1: false, lf: false };
|
|
84
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
85
|
+
expect(dto.replaceKnockedImmediately).toEqual({ libero1: false, lf: false });
|
|
86
|
+
});
|
|
87
|
+
it('drops a libero-keyed replaceKnockedImmediately entry when there is no second libero', () => {
|
|
88
|
+
const model = makeTactics([]);
|
|
89
|
+
model.lineup[CourtPosition.LIBERO_ZONE] = 'libero1';
|
|
90
|
+
model.replace_knocked_immediately = { libero1: false, lf: false };
|
|
91
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
92
|
+
expect(dto.replaceKnockedImmediately).toEqual({ lf: false });
|
|
93
|
+
});
|
|
79
94
|
it('passes per-set tactics columns through to the API DTO shape', () => {
|
|
80
95
|
const model = makeTactics([]);
|
|
81
96
|
model.system_sets = [{ rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
|
|
@@ -137,3 +152,68 @@ describe('per-set tactics — service <-> row round-trip', () => {
|
|
|
137
152
|
expect(back.offensivePreferenceSets).toBeUndefined();
|
|
138
153
|
});
|
|
139
154
|
});
|
|
155
|
+
describe('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
156
|
+
const country = makeCountry();
|
|
157
|
+
const p1 = makePlayer(country);
|
|
158
|
+
const p2 = makePlayer(country);
|
|
159
|
+
const p3 = makePlayer(country);
|
|
160
|
+
const p4 = makePlayer(country);
|
|
161
|
+
const p5 = makePlayer(country);
|
|
162
|
+
const p6 = makePlayer(country);
|
|
163
|
+
const libero = makePlayer(country);
|
|
164
|
+
const l2 = makePlayer(country);
|
|
165
|
+
const reserveA = makePlayer(country);
|
|
166
|
+
const reserveB = makePlayer(country);
|
|
167
|
+
const roster = [p1, p2, p3, p4, p5, p6, libero, l2, reserveA, reserveB];
|
|
168
|
+
const tactics = Tactics.create({
|
|
169
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
170
|
+
liberoReplacements: [],
|
|
171
|
+
secondLibero: l2,
|
|
172
|
+
liberoInjury: { preMatchReplacement: l2, secondLiberoBackfill: reserveA, inMatchDesignate: reserveB }
|
|
173
|
+
});
|
|
174
|
+
it('writes libero_injury as id-based jsonb', () => {
|
|
175
|
+
const attrs = transformFromTactics(tactics, 'team-1');
|
|
176
|
+
expect(attrs.libero_injury).toEqual({
|
|
177
|
+
preMatchReplacementId: l2.id,
|
|
178
|
+
secondLiberoBackfillId: reserveA.id,
|
|
179
|
+
inMatchDesignateId: reserveB.id
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it('resolves the stored libero_injury ids back to roster players', () => {
|
|
183
|
+
const attrs = transformFromTactics(tactics, 'team-1');
|
|
184
|
+
const back = transformToTactics(attrs, roster);
|
|
185
|
+
expect(back.liberoInjury?.preMatchReplacement?.id).toBe(l2.id);
|
|
186
|
+
expect(back.liberoInjury?.secondLiberoBackfill?.id).toBe(reserveA.id);
|
|
187
|
+
expect(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
|
|
188
|
+
});
|
|
189
|
+
it('drops a stale pre-match replacement that is now a court starter (lenient heal, no crash)', () => {
|
|
190
|
+
const attrs = transformFromTactics(tactics, 'team-1');
|
|
191
|
+
attrs.libero_injury.preMatchReplacementId = p2.id;
|
|
192
|
+
attrs.libero_injury.secondLiberoBackfillId = undefined;
|
|
193
|
+
const back = transformToTactics(attrs, roster);
|
|
194
|
+
expect(back.liberoInjury?.preMatchReplacement).toBeUndefined();
|
|
195
|
+
expect(back.liberoInjury?.secondLiberoBackfill).toBeUndefined();
|
|
196
|
+
expect(back.liberoInjury?.inMatchDesignate?.id).toBe(reserveB.id);
|
|
197
|
+
});
|
|
198
|
+
it('keeps a libero-keyed replaceKnockedImmediately entry on load when an L2 exists', () => {
|
|
199
|
+
const withKnock = Tactics.create({
|
|
200
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
201
|
+
liberoReplacements: [],
|
|
202
|
+
secondLibero: l2,
|
|
203
|
+
replaceKnockedImmediately: { [libero.id]: false, [p1.id]: false }
|
|
204
|
+
});
|
|
205
|
+
const attrs = transformFromTactics(withKnock, 'team-1');
|
|
206
|
+
const back = transformToTactics(attrs, roster);
|
|
207
|
+
expect(back.replaceKnockedImmediately).toEqual({ [libero.id]: false, [p1.id]: false });
|
|
208
|
+
});
|
|
209
|
+
it('drops a libero-keyed replaceKnockedImmediately entry on load when there is no L2', () => {
|
|
210
|
+
const withKnock = Tactics.create({
|
|
211
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [] },
|
|
212
|
+
liberoReplacements: [],
|
|
213
|
+
replaceKnockedImmediately: { [libero.id]: false, [p1.id]: false }
|
|
214
|
+
});
|
|
215
|
+
const attrs = transformFromTactics(withKnock, 'team-1');
|
|
216
|
+
const back = transformToTactics(attrs, roster);
|
|
217
|
+
expect(back.replaceKnockedImmediately).toEqual({ [p1.id]: false });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -47,8 +47,17 @@ describe('TacticsInputSchema — injuryReplacements (owner rules 2026-07-03)', (
|
|
|
47
47
|
expect(res.data.replaceKnockedImmediately).toEqual({ [p1.id]: false, [p3.id]: true });
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
|
-
it('
|
|
51
|
-
|
|
50
|
+
it('accepts a libero-keyed replaceKnockedImmediately map', () => {
|
|
51
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
52
|
+
replaceKnockedImmediately: { [libero.id]: false }
|
|
53
|
+
}));
|
|
54
|
+
expect(res.success).toBe(true);
|
|
55
|
+
if (res.success) {
|
|
56
|
+
expect(res.data.replaceKnockedImmediately).toEqual({ [libero.id]: false });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
it('rejects a replaceKnockedImmediately key that is not a court starter or the libero', () => {
|
|
60
|
+
for (const outsider of [benchA, reserveA]) {
|
|
52
61
|
const res = TacticsInputSchema.safeParse(baseInput({
|
|
53
62
|
replaceKnockedImmediately: { [outsider.id]: false }
|
|
54
63
|
}));
|
|
@@ -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).
|
|
@@ -185,10 +195,11 @@ export const TacticsInputSchema = z.object({
|
|
|
185
195
|
}
|
|
186
196
|
}
|
|
187
197
|
}
|
|
188
|
-
// Per-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
// Per-player knock toggle: every key must be a court starter OR the starting libero. The libero may carry its
|
|
199
|
+
// own key so the user can choose to keep a lightly knocked libero on court (owner 2026-07-06). Bench/reserve
|
|
200
|
+
// players still follow the slot they occupy, so they never carry their own key.
|
|
201
|
+
for (const knockedId of Object.keys(data.replaceKnockedImmediately)) {
|
|
202
|
+
if (!starterIds.has(knockedId) && knockedId !== libero?.id) {
|
|
192
203
|
ctx.addIssue({ code: 'custom', message: 'REPLACE_KNOCKED_KEY_NOT_A_STARTER', path: ['replaceKnockedImmediately'] });
|
|
193
204
|
}
|
|
194
205
|
}
|
|
@@ -229,4 +240,34 @@ export const TacticsInputSchema = z.object({
|
|
|
229
240
|
seenIncoming.add(ir.benchBackfill.id);
|
|
230
241
|
}
|
|
231
242
|
}
|
|
243
|
+
// Libero injuries (owner 2026-07-06). preMatchReplacement is a RESERVE or the L2 (never a court starter, the
|
|
244
|
+
// libero, or a bench player) and needs a starting libero to replace. secondLiberoBackfill (the new L2) is a
|
|
245
|
+
// RESERVE, valid ONLY when the L2 itself is the replacement. inMatchDesignate is a bench or reserve player NOT
|
|
246
|
+
// on court (never a court starter, the libero, or the L2).
|
|
247
|
+
const liberoInjury = data.liberoInjury;
|
|
248
|
+
if (liberoInjury != null) {
|
|
249
|
+
const preMatch = liberoInjury.preMatchReplacement;
|
|
250
|
+
const backfill = liberoInjury.secondLiberoBackfill;
|
|
251
|
+
const designate = liberoInjury.inMatchDesignate;
|
|
252
|
+
const secondLiberoId = data.secondLibero?.id;
|
|
253
|
+
if (preMatch != null) {
|
|
254
|
+
if (libero == null) {
|
|
255
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REQUIRES_LIBERO', path: ['liberoInjury', 'preMatchReplacement'] });
|
|
256
|
+
}
|
|
257
|
+
if (inLineupIds.has(preMatch.id)) {
|
|
258
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_INJURY_REPLACEMENT_MUST_BE_RESERVE_OR_L2', path: ['liberoInjury', 'preMatchReplacement'] });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (backfill != null) {
|
|
262
|
+
if (preMatch == null || preMatch.id !== secondLiberoId) {
|
|
263
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_REQUIRES_L2_REPLACEMENT', path: ['liberoInjury', 'secondLiberoBackfill'] });
|
|
264
|
+
}
|
|
265
|
+
if (inLineupIds.has(backfill.id) || backfill.id === secondLiberoId) {
|
|
266
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_BACKFILL_MUST_BE_RESERVE', path: ['liberoInjury', 'secondLiberoBackfill'] });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (designate != null && (starterIds.has(designate.id) || designate.id === liberoId || designate.id === secondLiberoId)) {
|
|
270
|
+
ctx.addIssue({ code: 'custom', message: 'LIBERO_DESIGNATE_MUST_BE_BENCH_OR_RESERVE', path: ['liberoInjury', 'inMatchDesignate'] });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
232
273
|
});
|
|
@@ -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))
|