volleyballsimtypes 0.0.516 → 0.0.517
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 +1 -0
- package/dist/cjs/src/api/tactics-player-refs.d.ts +2 -2
- package/dist/cjs/src/api/tactics-player-refs.js +3 -2
- package/dist/cjs/src/data/models/tactics.d.ts +3 -1
- package/dist/cjs/src/data/models/tactics.js +5 -0
- package/dist/cjs/src/data/transformers/tactics.js +39 -0
- package/dist/cjs/src/data/transformers/tactics.test.js +25 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +27 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +63 -0
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +1 -0
- package/dist/cjs/src/service/team/tactics.d.ts +2 -0
- package/dist/cjs/src/service/team/tactics.js +2 -1
- package/dist/esm/src/api/index.d.ts +1 -0
- package/dist/esm/src/api/tactics-player-refs.d.ts +2 -2
- package/dist/esm/src/api/tactics-player-refs.js +3 -2
- package/dist/esm/src/data/models/tactics.d.ts +3 -1
- package/dist/esm/src/data/models/tactics.js +5 -0
- package/dist/esm/src/data/transformers/tactics.js +39 -0
- package/dist/esm/src/data/transformers/tactics.test.js +25 -0
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +27 -0
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +63 -0
- package/dist/esm/src/service/team/schemas/team.z.d.ts +1 -0
- package/dist/esm/src/service/team/tactics.d.ts +2 -0
- package/dist/esm/src/service/team/tactics.js +2 -1
- package/package.json +1 -1
|
@@ -137,6 +137,7 @@ export interface Tactics {
|
|
|
137
137
|
liberoReplacementSets?: string[][];
|
|
138
138
|
rotationSystem: RotationSystemEnum;
|
|
139
139
|
designatedSetters: string[];
|
|
140
|
+
emergencySetterOrder: string[];
|
|
140
141
|
offensivePreferences: ApiOffensivePreference[];
|
|
141
142
|
systemSets?: ApiSystemSet[];
|
|
142
143
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
@@ -2,8 +2,8 @@ import type { Tactics } from '.';
|
|
|
2
2
|
/**
|
|
3
3
|
* Every player id a tactics config references, anywhere: the six court slots and the libero, the bench, libero
|
|
4
4
|
* replacements (flat and per-set), the second libero, designated subs and their per-set overrides, designated
|
|
5
|
-
* setters (flat and per-set), the keys of the offensive-weight map, the blocking
|
|
6
|
-
* replacements and the libero-injury reserves.
|
|
5
|
+
* setters (flat and per-set), the emergency setter order, the keys of the offensive-weight map, the blocking
|
|
6
|
+
* commit + no-block ids, injury replacements and the libero-injury reserves.
|
|
7
7
|
*
|
|
8
8
|
* There is exactly ONE implementation of this on purpose. It backs three rules that must agree or a player
|
|
9
9
|
* slips through: the dismiss/retire guard (a referenced player cannot be removed), the event rarity check
|
|
@@ -4,8 +4,8 @@ exports.collectTacticsPlayerIds = collectTacticsPlayerIds;
|
|
|
4
4
|
/**
|
|
5
5
|
* Every player id a tactics config references, anywhere: the six court slots and the libero, the bench, libero
|
|
6
6
|
* replacements (flat and per-set), the second libero, designated subs and their per-set overrides, designated
|
|
7
|
-
* setters (flat and per-set), the keys of the offensive-weight map, the blocking
|
|
8
|
-
* replacements and the libero-injury reserves.
|
|
7
|
+
* setters (flat and per-set), the emergency setter order, the keys of the offensive-weight map, the blocking
|
|
8
|
+
* commit + no-block ids, injury replacements and the libero-injury reserves.
|
|
9
9
|
*
|
|
10
10
|
* There is exactly ONE implementation of this on purpose. It backs three rules that must agree or a player
|
|
11
11
|
* slips through: the dismiss/retire guard (a referenced player cannot be removed), the event rarity check
|
|
@@ -26,6 +26,7 @@ function collectTacticsPlayerIds(config) {
|
|
|
26
26
|
config.secondLibero,
|
|
27
27
|
...((config.liberoReplacementSets ?? []).flat()),
|
|
28
28
|
...(config.designatedSetters ?? []),
|
|
29
|
+
...(config.emergencySetterOrder ?? []),
|
|
29
30
|
...((config.offensivePreferences ?? []).flatMap(p => Object.keys(p.priorities))),
|
|
30
31
|
...((config.systemSets ?? []).flatMap(s => s.designatedSetters)),
|
|
31
32
|
...((config.offensivePreferenceSets ?? []).flat().flatMap(p => Object.keys(p.priorities))),
|
|
@@ -74,6 +74,7 @@ export interface TacticsAttributes {
|
|
|
74
74
|
libero_replacement_sets?: PlayerId[][];
|
|
75
75
|
rotation_system: RotationSystemEnum;
|
|
76
76
|
designated_setters: PlayerId[];
|
|
77
|
+
emergency_setter_order: PlayerId[];
|
|
77
78
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
78
79
|
system_sets?: SystemSetAttributes[];
|
|
79
80
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
@@ -86,7 +87,7 @@ export interface TacticsAttributes {
|
|
|
86
87
|
}
|
|
87
88
|
export type TacticsPk = 'team_id';
|
|
88
89
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
89
|
-
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' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
|
|
90
|
+
export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'emergency_setter_order' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
|
|
90
91
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
91
92
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
92
93
|
team_id: string;
|
|
@@ -100,6 +101,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
100
101
|
libero_replacement_sets?: PlayerId[][];
|
|
101
102
|
rotation_system: RotationSystemEnum;
|
|
102
103
|
designated_setters: PlayerId[];
|
|
104
|
+
emergency_setter_order: PlayerId[];
|
|
103
105
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
104
106
|
system_sets?: SystemSetAttributes[];
|
|
105
107
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
@@ -62,6 +62,11 @@ class TacticsModel extends sequelize_1.Model {
|
|
|
62
62
|
allowNull: false,
|
|
63
63
|
defaultValue: []
|
|
64
64
|
},
|
|
65
|
+
emergency_setter_order: {
|
|
66
|
+
type: sequelize_1.DataTypes.ARRAY(sequelize_1.DataTypes.UUID),
|
|
67
|
+
allowNull: false,
|
|
68
|
+
defaultValue: []
|
|
69
|
+
},
|
|
65
70
|
offensive_preferences: {
|
|
66
71
|
type: sequelize_1.DataTypes.JSONB,
|
|
67
72
|
allowNull: false,
|
|
@@ -71,6 +71,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
71
71
|
})),
|
|
72
72
|
rotation_system: tactics.rotationSystem,
|
|
73
73
|
designated_setters: tactics.designatedSetters.map((s) => s.id),
|
|
74
|
+
emergency_setter_order: tactics.emergencySetterOrder.map((p) => p.id),
|
|
74
75
|
offensive_preferences: tactics.offensivePreferences.map(p => ({
|
|
75
76
|
rotation: p.rotation,
|
|
76
77
|
priorities: p.priorities,
|
|
@@ -178,6 +179,27 @@ function transformToObject(model, roster) {
|
|
|
178
179
|
})),
|
|
179
180
|
rotationSystem: model.rotation_system,
|
|
180
181
|
designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
|
|
182
|
+
// LENIENT (roster.find): the coach-ranked emergency-setter chain. Keep only ids still on the roster, inside the
|
|
183
|
+
// squad pool (starters + starting libero + L2 + bench), and NOT a current designated setter (setters are pinned
|
|
184
|
+
// ahead of this list, never stored inside it), first-occurrence-wins on duplicates. So stale jsonb (a departed
|
|
185
|
+
// player, a promoted setter, a non-pool id) can never fail schema validation and crash a team load; the next
|
|
186
|
+
// tactics save rewrites it cleanly.
|
|
187
|
+
emergencySetterOrder: (() => {
|
|
188
|
+
const poolIds = new Set([...inLineupIds, ...(secondLibero != null ? [secondLibero.id] : [])]);
|
|
189
|
+
const setterIds = new Set(model.designated_setters ?? []);
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const healed = [];
|
|
192
|
+
for (const id of model.emergency_setter_order ?? []) {
|
|
193
|
+
if (setterIds.has(id) || seen.has(id) || !poolIds.has(id))
|
|
194
|
+
continue;
|
|
195
|
+
const p = roster.find((x) => x.id === id);
|
|
196
|
+
if (p == null)
|
|
197
|
+
continue;
|
|
198
|
+
seen.add(id);
|
|
199
|
+
healed.push(p);
|
|
200
|
+
}
|
|
201
|
+
return healed;
|
|
202
|
+
})(),
|
|
181
203
|
offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
|
|
182
204
|
rotation: p.rotation,
|
|
183
205
|
priorities: p.priorities,
|
|
@@ -335,6 +357,22 @@ function tacticsColumnsToApiDto(model) {
|
|
|
335
357
|
}),
|
|
336
358
|
rotationSystem: model.rotation_system,
|
|
337
359
|
designatedSetters: model.designated_setters,
|
|
360
|
+
// Same stale-data policy: drop any emergency-setter id the service load would drop (a current designated
|
|
361
|
+
// setter, or one outside the squad pool) so the UI never reloads and re-emits a rejected entry. Roster
|
|
362
|
+
// membership can't be checked here (ids only); the service heal covers that.
|
|
363
|
+
emergencySetterOrder: (() => {
|
|
364
|
+
const poolIds = new Set([...lineupIds, ...(validSecondLibero != null ? [validSecondLibero] : [])]);
|
|
365
|
+
const setterIds = new Set(model.designated_setters ?? []);
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
const healed = [];
|
|
368
|
+
for (const id of model.emergency_setter_order ?? []) {
|
|
369
|
+
if (setterIds.has(id) || seen.has(id) || !poolIds.has(id))
|
|
370
|
+
continue;
|
|
371
|
+
seen.add(id);
|
|
372
|
+
healed.push(id);
|
|
373
|
+
}
|
|
374
|
+
return healed;
|
|
375
|
+
})(),
|
|
338
376
|
offensivePreferences: model.offensive_preferences,
|
|
339
377
|
systemSets: model.system_sets,
|
|
340
378
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
@@ -443,6 +481,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
443
481
|
libero_replacement_sets: config.liberoReplacementSets,
|
|
444
482
|
rotation_system: config.rotationSystem,
|
|
445
483
|
designated_setters: config.designatedSetters,
|
|
484
|
+
emergency_setter_order: config.emergencySetterOrder ?? [],
|
|
446
485
|
offensive_preferences: config.offensivePreferences,
|
|
447
486
|
system_sets: config.systemSets,
|
|
448
487
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
@@ -261,6 +261,31 @@ function makeTactics(designatedSubs) {
|
|
|
261
261
|
(0, globals_1.expect)(back.blockingPreferences[0].noBlock).toEqual({ DEF_L: [], DEF_M: [p4.id], DEF_R: [] });
|
|
262
262
|
(0, globals_1.expect)(back.blockingPreferenceSets?.[0][0].noBlock).toEqual({ DEF_L: [p2.id], DEF_M: [], DEF_R: [] });
|
|
263
263
|
});
|
|
264
|
+
(0, globals_1.it)('round-trips the emergency setter order (non-setter pool members)', () => {
|
|
265
|
+
const withOrder = service_1.Tactics.create({
|
|
266
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
267
|
+
liberoReplacements: [],
|
|
268
|
+
emergencySetterOrder: [p2, p3]
|
|
269
|
+
});
|
|
270
|
+
const attrs = (0, tactics_1.transformFromTactics)(withOrder, 'team-1');
|
|
271
|
+
(0, globals_1.expect)(attrs.emergency_setter_order).toEqual([p2.id, p3.id]);
|
|
272
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
273
|
+
(0, globals_1.expect)(back.emergencySetterOrder.map(p => p.id)).toEqual([p2.id, p3.id]);
|
|
274
|
+
});
|
|
275
|
+
// The read path must never crash a team load on a stale order. Ids that are gone from the roster, outside the
|
|
276
|
+
// squad pool, or now a designated setter (setters are pinned, never stored) are silently dropped, order preserved.
|
|
277
|
+
(0, globals_1.it)('drops emergency-order ids that are gone or a designated setter on read (lenient)', () => {
|
|
278
|
+
const attrs = (0, tactics_1.transformFromTactics)(service_1.Tactics.create({
|
|
279
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
280
|
+
liberoReplacements: [],
|
|
281
|
+
rotationSystem: service_1.RotationSystemEnum.FIVE_ONE,
|
|
282
|
+
designatedSetters: [p1],
|
|
283
|
+
emergencySetterOrder: [p2]
|
|
284
|
+
}), 'team-1');
|
|
285
|
+
attrs.emergency_setter_order = [p1.id, 'gone', p2.id];
|
|
286
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
287
|
+
(0, globals_1.expect)(back.emergencySetterOrder.map(p => p.id)).toEqual([p2.id]);
|
|
288
|
+
});
|
|
264
289
|
});
|
|
265
290
|
(0, globals_1.describe)('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
266
291
|
const country = (0, test_helpers_1.makeCountry)();
|
|
@@ -259,6 +259,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
259
259
|
liberoReplacementSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodCustom<Player, Player>>>>;
|
|
260
260
|
rotationSystem: z.ZodDefault<z.ZodEnum<typeof RotationSystemEnum>>;
|
|
261
261
|
designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
262
|
+
emergencySetterOrder: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
262
263
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
263
264
|
rotation: z.ZodNumber;
|
|
264
265
|
priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
@@ -118,6 +118,10 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
118
118
|
// Tactics.create({...}) call valid and make an unconfigured team play exactly as before.
|
|
119
119
|
rotationSystem: zod_1.z.nativeEnum(rotation_system_1.RotationSystemEnum).default(rotation_system_1.RotationSystemEnum.SIX_ZERO),
|
|
120
120
|
designatedSetters: zod_1.z.array(playerInstanceSchema).default([]),
|
|
121
|
+
// Emergency setter order (owner 2026-08-14): the coach-ranked NON-setter chain the engine follows when the
|
|
122
|
+
// designated setter is out of system. GLOBAL. Holds only non-setter pool members (setters are pinned ahead of
|
|
123
|
+
// it, derived). Default [] keeps every existing Tactics.create({...}) call valid; cross-field pool rules below.
|
|
124
|
+
emergencySetterOrder: zod_1.z.array(playerInstanceSchema).default([]),
|
|
121
125
|
offensivePreferences: zod_1.z.array(offensivePreferenceSchema).default([]),
|
|
122
126
|
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
123
127
|
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
@@ -293,6 +297,29 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
293
297
|
}
|
|
294
298
|
}
|
|
295
299
|
}
|
|
300
|
+
// Emergency setter order (owner 2026-08-14): the coach-ranked NON-setter chain. Every entry must be a squad-pool
|
|
301
|
+
// member (a court starter, the starting libero, the second libero, or a bench player), appear at most once, and
|
|
302
|
+
// NOT be one of the flat designated setters (they are pinned ahead of this list and never stored inside it).
|
|
303
|
+
const emergencyPoolIds = new Set([
|
|
304
|
+
...starterIds,
|
|
305
|
+
...(libero != null ? [libero.id] : []),
|
|
306
|
+
...(data.secondLibero != null ? [data.secondLibero.id] : []),
|
|
307
|
+
...data.lineup.bench.map(p => p.id)
|
|
308
|
+
]);
|
|
309
|
+
const flatSetterIds = new Set(data.designatedSetters.map(s => s.id));
|
|
310
|
+
const seenEmergency = new Set();
|
|
311
|
+
for (const player of data.emergencySetterOrder) {
|
|
312
|
+
if (!emergencyPoolIds.has(player.id)) {
|
|
313
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_NOT_IN_POOL', path: ['emergencySetterOrder'] });
|
|
314
|
+
}
|
|
315
|
+
if (flatSetterIds.has(player.id)) {
|
|
316
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_IS_DESIGNATED_SETTER', path: ['emergencySetterOrder'] });
|
|
317
|
+
}
|
|
318
|
+
if (seenEmergency.has(player.id)) {
|
|
319
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_DUPLICATE', path: ['emergencySetterOrder'] });
|
|
320
|
+
}
|
|
321
|
+
seenEmergency.add(player.id);
|
|
322
|
+
}
|
|
296
323
|
// Per-player knock toggle: every key must be a court starter OR the starting libero. The libero may carry its
|
|
297
324
|
// own key so the user can choose to keep a lightly knocked libero on court (owner 2026-07-06). Bench/reserve
|
|
298
325
|
// players still follow the slot they occupy, so they never carry their own key.
|
|
@@ -338,3 +338,66 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
338
338
|
(0, globals_1.expect)(res.success).toBe(false);
|
|
339
339
|
});
|
|
340
340
|
});
|
|
341
|
+
(0, globals_1.describe)('TacticsInputSchema — emergency setter order', () => {
|
|
342
|
+
const country = (0, test_helpers_1.makeCountry)();
|
|
343
|
+
const p1 = (0, test_helpers_1.makePlayer)(country);
|
|
344
|
+
const p2 = (0, test_helpers_1.makePlayer)(country);
|
|
345
|
+
const p3 = (0, test_helpers_1.makePlayer)(country);
|
|
346
|
+
const p4 = (0, test_helpers_1.makePlayer)(country);
|
|
347
|
+
const p5 = (0, test_helpers_1.makePlayer)(country);
|
|
348
|
+
const p6 = (0, test_helpers_1.makePlayer)(country);
|
|
349
|
+
const libero = (0, test_helpers_1.makePlayer)(country);
|
|
350
|
+
const benchA = (0, test_helpers_1.makePlayer)(country);
|
|
351
|
+
const secondLibero = (0, test_helpers_1.makePlayer)(country);
|
|
352
|
+
const reserve = (0, test_helpers_1.makePlayer)(country);
|
|
353
|
+
// Pool = the 6 starters (p1..p6), the starting libero, the second libero, and the bench (benchA). `reserve` is
|
|
354
|
+
// outside the pool. 6-0 default => no designated setters, so the whole pool is orderable.
|
|
355
|
+
function baseInput(overrides = {}) {
|
|
356
|
+
return {
|
|
357
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [benchA] },
|
|
358
|
+
liberoReplacements: [],
|
|
359
|
+
secondLibero,
|
|
360
|
+
...overrides
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
(0, globals_1.it)('accepts an order of pool members (starters, libero, L2, bench), no duplicates', () => {
|
|
364
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
365
|
+
emergencySetterOrder: [p2, benchA, libero, secondLibero]
|
|
366
|
+
}));
|
|
367
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
368
|
+
if (res.success) {
|
|
369
|
+
(0, globals_1.expect)(res.data.emergencySetterOrder.map(p => p.id)).toEqual([p2.id, benchA.id, libero.id, secondLibero.id]);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
(0, globals_1.it)('defaults to [] when omitted', () => {
|
|
373
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput());
|
|
374
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
375
|
+
if (res.success)
|
|
376
|
+
(0, globals_1.expect)(res.data.emergencySetterOrder).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
(0, globals_1.it)('rejects an entry outside the squad pool', () => {
|
|
379
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ emergencySetterOrder: [reserve] }));
|
|
380
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
381
|
+
if (!res.success) {
|
|
382
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_NOT_IN_POOL')).toBe(true);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
(0, globals_1.it)('rejects a duplicate entry', () => {
|
|
386
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ emergencySetterOrder: [p2, p2] }));
|
|
387
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
388
|
+
if (!res.success) {
|
|
389
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_DUPLICATE')).toBe(true);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
(0, globals_1.it)('rejects an entry that is a flat designated setter (setters are pinned, never stored in the list)', () => {
|
|
393
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
394
|
+
rotationSystem: '5-1',
|
|
395
|
+
designatedSetters: [p1],
|
|
396
|
+
emergencySetterOrder: [p1]
|
|
397
|
+
}));
|
|
398
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
399
|
+
if (!res.success) {
|
|
400
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_IS_DESIGNATED_SETTER')).toBe(true);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -267,6 +267,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
267
267
|
liberoReplacementSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodCustom<Player, Player>>>>;
|
|
268
268
|
rotationSystem: z.ZodDefault<z.ZodEnum<typeof import("..").RotationSystemEnum>>;
|
|
269
269
|
designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
270
|
+
emergencySetterOrder: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
270
271
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotation: z.ZodNumber;
|
|
272
273
|
priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly liberoReplacementSets?: Player[][];
|
|
35
35
|
readonly rotationSystem: RotationSystemEnum;
|
|
36
36
|
readonly designatedSetters: Player[];
|
|
37
|
+
readonly emergencySetterOrder: Player[];
|
|
37
38
|
readonly offensivePreferences: OffensivePreference[];
|
|
38
39
|
readonly systemSets?: SystemSet[];
|
|
39
40
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
@@ -55,6 +56,7 @@ export declare class Tactics {
|
|
|
55
56
|
readonly liberoReplacementSets?: Player[][];
|
|
56
57
|
readonly rotationSystem: RotationSystemEnum;
|
|
57
58
|
readonly designatedSetters: Player[];
|
|
59
|
+
readonly emergencySetterOrder: Player[];
|
|
58
60
|
readonly offensivePreferences: OffensivePreference[];
|
|
59
61
|
readonly systemSets?: SystemSet[];
|
|
60
62
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
@@ -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 = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
|
|
21
|
+
constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], emergencySetterOrder = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
|
|
22
22
|
this.lineup = lineup;
|
|
23
23
|
this.liberoReplacements = liberoReplacements;
|
|
24
24
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -29,6 +29,7 @@ class Tactics {
|
|
|
29
29
|
this.liberoReplacementSets = liberoReplacementSets;
|
|
30
30
|
this.rotationSystem = rotationSystem;
|
|
31
31
|
this.designatedSetters = designatedSetters;
|
|
32
|
+
this.emergencySetterOrder = emergencySetterOrder;
|
|
32
33
|
this.offensivePreferences = offensivePreferences;
|
|
33
34
|
this.systemSets = systemSets;
|
|
34
35
|
this.offensivePreferenceSets = offensivePreferenceSets;
|
|
@@ -137,6 +137,7 @@ export interface Tactics {
|
|
|
137
137
|
liberoReplacementSets?: string[][];
|
|
138
138
|
rotationSystem: RotationSystemEnum;
|
|
139
139
|
designatedSetters: string[];
|
|
140
|
+
emergencySetterOrder: string[];
|
|
140
141
|
offensivePreferences: ApiOffensivePreference[];
|
|
141
142
|
systemSets?: ApiSystemSet[];
|
|
142
143
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
@@ -2,8 +2,8 @@ import type { Tactics } from '.';
|
|
|
2
2
|
/**
|
|
3
3
|
* Every player id a tactics config references, anywhere: the six court slots and the libero, the bench, libero
|
|
4
4
|
* replacements (flat and per-set), the second libero, designated subs and their per-set overrides, designated
|
|
5
|
-
* setters (flat and per-set), the keys of the offensive-weight map, the blocking
|
|
6
|
-
* replacements and the libero-injury reserves.
|
|
5
|
+
* setters (flat and per-set), the emergency setter order, the keys of the offensive-weight map, the blocking
|
|
6
|
+
* commit + no-block ids, injury replacements and the libero-injury reserves.
|
|
7
7
|
*
|
|
8
8
|
* There is exactly ONE implementation of this on purpose. It backs three rules that must agree or a player
|
|
9
9
|
* slips through: the dismiss/retire guard (a referenced player cannot be removed), the event rarity check
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every player id a tactics config references, anywhere: the six court slots and the libero, the bench, libero
|
|
3
3
|
* replacements (flat and per-set), the second libero, designated subs and their per-set overrides, designated
|
|
4
|
-
* setters (flat and per-set), the keys of the offensive-weight map, the blocking
|
|
5
|
-
* replacements and the libero-injury reserves.
|
|
4
|
+
* setters (flat and per-set), the emergency setter order, the keys of the offensive-weight map, the blocking
|
|
5
|
+
* commit + no-block ids, injury replacements and the libero-injury reserves.
|
|
6
6
|
*
|
|
7
7
|
* There is exactly ONE implementation of this on purpose. It backs three rules that must agree or a player
|
|
8
8
|
* slips through: the dismiss/retire guard (a referenced player cannot be removed), the event rarity check
|
|
@@ -23,6 +23,7 @@ export function collectTacticsPlayerIds(config) {
|
|
|
23
23
|
config.secondLibero,
|
|
24
24
|
...((config.liberoReplacementSets ?? []).flat()),
|
|
25
25
|
...(config.designatedSetters ?? []),
|
|
26
|
+
...(config.emergencySetterOrder ?? []),
|
|
26
27
|
...((config.offensivePreferences ?? []).flatMap(p => Object.keys(p.priorities))),
|
|
27
28
|
...((config.systemSets ?? []).flatMap(s => s.designatedSetters)),
|
|
28
29
|
...((config.offensivePreferenceSets ?? []).flat().flatMap(p => Object.keys(p.priorities))),
|
|
@@ -74,6 +74,7 @@ export interface TacticsAttributes {
|
|
|
74
74
|
libero_replacement_sets?: PlayerId[][];
|
|
75
75
|
rotation_system: RotationSystemEnum;
|
|
76
76
|
designated_setters: PlayerId[];
|
|
77
|
+
emergency_setter_order: PlayerId[];
|
|
77
78
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
78
79
|
system_sets?: SystemSetAttributes[];
|
|
79
80
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
@@ -86,7 +87,7 @@ export interface TacticsAttributes {
|
|
|
86
87
|
}
|
|
87
88
|
export type TacticsPk = 'team_id';
|
|
88
89
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
89
|
-
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' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
|
|
90
|
+
export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'emergency_setter_order' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
|
|
90
91
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
91
92
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
92
93
|
team_id: string;
|
|
@@ -100,6 +101,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
100
101
|
libero_replacement_sets?: PlayerId[][];
|
|
101
102
|
rotation_system: RotationSystemEnum;
|
|
102
103
|
designated_setters: PlayerId[];
|
|
104
|
+
emergency_setter_order: PlayerId[];
|
|
103
105
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
104
106
|
system_sets?: SystemSetAttributes[];
|
|
105
107
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
@@ -59,6 +59,11 @@ export class TacticsModel extends Model {
|
|
|
59
59
|
allowNull: false,
|
|
60
60
|
defaultValue: []
|
|
61
61
|
},
|
|
62
|
+
emergency_setter_order: {
|
|
63
|
+
type: DataTypes.ARRAY(DataTypes.UUID),
|
|
64
|
+
allowNull: false,
|
|
65
|
+
defaultValue: []
|
|
66
|
+
},
|
|
62
67
|
offensive_preferences: {
|
|
63
68
|
type: DataTypes.JSONB,
|
|
64
69
|
allowNull: false,
|
|
@@ -65,6 +65,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
65
65
|
})),
|
|
66
66
|
rotation_system: tactics.rotationSystem,
|
|
67
67
|
designated_setters: tactics.designatedSetters.map((s) => s.id),
|
|
68
|
+
emergency_setter_order: tactics.emergencySetterOrder.map((p) => p.id),
|
|
68
69
|
offensive_preferences: tactics.offensivePreferences.map(p => ({
|
|
69
70
|
rotation: p.rotation,
|
|
70
71
|
priorities: p.priorities,
|
|
@@ -172,6 +173,27 @@ function transformToObject(model, roster) {
|
|
|
172
173
|
})),
|
|
173
174
|
rotationSystem: model.rotation_system,
|
|
174
175
|
designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
|
|
176
|
+
// LENIENT (roster.find): the coach-ranked emergency-setter chain. Keep only ids still on the roster, inside the
|
|
177
|
+
// squad pool (starters + starting libero + L2 + bench), and NOT a current designated setter (setters are pinned
|
|
178
|
+
// ahead of this list, never stored inside it), first-occurrence-wins on duplicates. So stale jsonb (a departed
|
|
179
|
+
// player, a promoted setter, a non-pool id) can never fail schema validation and crash a team load; the next
|
|
180
|
+
// tactics save rewrites it cleanly.
|
|
181
|
+
emergencySetterOrder: (() => {
|
|
182
|
+
const poolIds = new Set([...inLineupIds, ...(secondLibero != null ? [secondLibero.id] : [])]);
|
|
183
|
+
const setterIds = new Set(model.designated_setters ?? []);
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const healed = [];
|
|
186
|
+
for (const id of model.emergency_setter_order ?? []) {
|
|
187
|
+
if (setterIds.has(id) || seen.has(id) || !poolIds.has(id))
|
|
188
|
+
continue;
|
|
189
|
+
const p = roster.find((x) => x.id === id);
|
|
190
|
+
if (p == null)
|
|
191
|
+
continue;
|
|
192
|
+
seen.add(id);
|
|
193
|
+
healed.push(p);
|
|
194
|
+
}
|
|
195
|
+
return healed;
|
|
196
|
+
})(),
|
|
175
197
|
offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
|
|
176
198
|
rotation: p.rotation,
|
|
177
199
|
priorities: p.priorities,
|
|
@@ -329,6 +351,22 @@ function tacticsColumnsToApiDto(model) {
|
|
|
329
351
|
}),
|
|
330
352
|
rotationSystem: model.rotation_system,
|
|
331
353
|
designatedSetters: model.designated_setters,
|
|
354
|
+
// Same stale-data policy: drop any emergency-setter id the service load would drop (a current designated
|
|
355
|
+
// setter, or one outside the squad pool) so the UI never reloads and re-emits a rejected entry. Roster
|
|
356
|
+
// membership can't be checked here (ids only); the service heal covers that.
|
|
357
|
+
emergencySetterOrder: (() => {
|
|
358
|
+
const poolIds = new Set([...lineupIds, ...(validSecondLibero != null ? [validSecondLibero] : [])]);
|
|
359
|
+
const setterIds = new Set(model.designated_setters ?? []);
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
const healed = [];
|
|
362
|
+
for (const id of model.emergency_setter_order ?? []) {
|
|
363
|
+
if (setterIds.has(id) || seen.has(id) || !poolIds.has(id))
|
|
364
|
+
continue;
|
|
365
|
+
seen.add(id);
|
|
366
|
+
healed.push(id);
|
|
367
|
+
}
|
|
368
|
+
return healed;
|
|
369
|
+
})(),
|
|
332
370
|
offensivePreferences: model.offensive_preferences,
|
|
333
371
|
systemSets: model.system_sets,
|
|
334
372
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
@@ -437,6 +475,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
437
475
|
libero_replacement_sets: config.liberoReplacementSets,
|
|
438
476
|
rotation_system: config.rotationSystem,
|
|
439
477
|
designated_setters: config.designatedSetters,
|
|
478
|
+
emergency_setter_order: config.emergencySetterOrder ?? [],
|
|
440
479
|
offensive_preferences: config.offensivePreferences,
|
|
441
480
|
system_sets: config.systemSets,
|
|
442
481
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
@@ -259,6 +259,31 @@ describe('per-set tactics — service <-> row round-trip', () => {
|
|
|
259
259
|
expect(back.blockingPreferences[0].noBlock).toEqual({ DEF_L: [], DEF_M: [p4.id], DEF_R: [] });
|
|
260
260
|
expect(back.blockingPreferenceSets?.[0][0].noBlock).toEqual({ DEF_L: [p2.id], DEF_M: [], DEF_R: [] });
|
|
261
261
|
});
|
|
262
|
+
it('round-trips the emergency setter order (non-setter pool members)', () => {
|
|
263
|
+
const withOrder = Tactics.create({
|
|
264
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
265
|
+
liberoReplacements: [],
|
|
266
|
+
emergencySetterOrder: [p2, p3]
|
|
267
|
+
});
|
|
268
|
+
const attrs = transformFromTactics(withOrder, 'team-1');
|
|
269
|
+
expect(attrs.emergency_setter_order).toEqual([p2.id, p3.id]);
|
|
270
|
+
const back = transformToTactics(attrs, roster);
|
|
271
|
+
expect(back.emergencySetterOrder.map(p => p.id)).toEqual([p2.id, p3.id]);
|
|
272
|
+
});
|
|
273
|
+
// The read path must never crash a team load on a stale order. Ids that are gone from the roster, outside the
|
|
274
|
+
// squad pool, or now a designated setter (setters are pinned, never stored) are silently dropped, order preserved.
|
|
275
|
+
it('drops emergency-order ids that are gone or a designated setter on read (lenient)', () => {
|
|
276
|
+
const attrs = transformFromTactics(Tactics.create({
|
|
277
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
278
|
+
liberoReplacements: [],
|
|
279
|
+
rotationSystem: RotationSystemEnum.FIVE_ONE,
|
|
280
|
+
designatedSetters: [p1],
|
|
281
|
+
emergencySetterOrder: [p2]
|
|
282
|
+
}), 'team-1');
|
|
283
|
+
attrs.emergency_setter_order = [p1.id, 'gone', p2.id];
|
|
284
|
+
const back = transformToTactics(attrs, roster);
|
|
285
|
+
expect(back.emergencySetterOrder.map(p => p.id)).toEqual([p2.id]);
|
|
286
|
+
});
|
|
262
287
|
});
|
|
263
288
|
describe('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
264
289
|
const country = makeCountry();
|
|
@@ -259,6 +259,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
259
259
|
liberoReplacementSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodCustom<Player, Player>>>>;
|
|
260
260
|
rotationSystem: z.ZodDefault<z.ZodEnum<typeof RotationSystemEnum>>;
|
|
261
261
|
designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
262
|
+
emergencySetterOrder: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
262
263
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
263
264
|
rotation: z.ZodNumber;
|
|
264
265
|
priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
@@ -115,6 +115,10 @@ export const TacticsInputSchema = z.object({
|
|
|
115
115
|
// Tactics.create({...}) call valid and make an unconfigured team play exactly as before.
|
|
116
116
|
rotationSystem: z.nativeEnum(RotationSystemEnum).default(RotationSystemEnum.SIX_ZERO),
|
|
117
117
|
designatedSetters: z.array(playerInstanceSchema).default([]),
|
|
118
|
+
// Emergency setter order (owner 2026-08-14): the coach-ranked NON-setter chain the engine follows when the
|
|
119
|
+
// designated setter is out of system. GLOBAL. Holds only non-setter pool members (setters are pinned ahead of
|
|
120
|
+
// it, derived). Default [] keeps every existing Tactics.create({...}) call valid; cross-field pool rules below.
|
|
121
|
+
emergencySetterOrder: z.array(playerInstanceSchema).default([]),
|
|
118
122
|
offensivePreferences: z.array(offensivePreferenceSchema).default([]),
|
|
119
123
|
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
120
124
|
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
@@ -290,6 +294,29 @@ export const TacticsInputSchema = z.object({
|
|
|
290
294
|
}
|
|
291
295
|
}
|
|
292
296
|
}
|
|
297
|
+
// Emergency setter order (owner 2026-08-14): the coach-ranked NON-setter chain. Every entry must be a squad-pool
|
|
298
|
+
// member (a court starter, the starting libero, the second libero, or a bench player), appear at most once, and
|
|
299
|
+
// NOT be one of the flat designated setters (they are pinned ahead of this list and never stored inside it).
|
|
300
|
+
const emergencyPoolIds = new Set([
|
|
301
|
+
...starterIds,
|
|
302
|
+
...(libero != null ? [libero.id] : []),
|
|
303
|
+
...(data.secondLibero != null ? [data.secondLibero.id] : []),
|
|
304
|
+
...data.lineup.bench.map(p => p.id)
|
|
305
|
+
]);
|
|
306
|
+
const flatSetterIds = new Set(data.designatedSetters.map(s => s.id));
|
|
307
|
+
const seenEmergency = new Set();
|
|
308
|
+
for (const player of data.emergencySetterOrder) {
|
|
309
|
+
if (!emergencyPoolIds.has(player.id)) {
|
|
310
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_NOT_IN_POOL', path: ['emergencySetterOrder'] });
|
|
311
|
+
}
|
|
312
|
+
if (flatSetterIds.has(player.id)) {
|
|
313
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_IS_DESIGNATED_SETTER', path: ['emergencySetterOrder'] });
|
|
314
|
+
}
|
|
315
|
+
if (seenEmergency.has(player.id)) {
|
|
316
|
+
ctx.addIssue({ code: 'custom', message: 'EMERGENCY_SETTER_DUPLICATE', path: ['emergencySetterOrder'] });
|
|
317
|
+
}
|
|
318
|
+
seenEmergency.add(player.id);
|
|
319
|
+
}
|
|
293
320
|
// Per-player knock toggle: every key must be a court starter OR the starting libero. The libero may carry its
|
|
294
321
|
// own key so the user can choose to keep a lightly knocked libero on court (owner 2026-07-06). Bench/reserve
|
|
295
322
|
// players still follow the slot they occupy, so they never carry their own key.
|
|
@@ -336,3 +336,66 @@ describe('TacticsInputSchema — blocking preferences', () => {
|
|
|
336
336
|
expect(res.success).toBe(false);
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
describe('TacticsInputSchema — emergency setter order', () => {
|
|
340
|
+
const country = makeCountry();
|
|
341
|
+
const p1 = makePlayer(country);
|
|
342
|
+
const p2 = makePlayer(country);
|
|
343
|
+
const p3 = makePlayer(country);
|
|
344
|
+
const p4 = makePlayer(country);
|
|
345
|
+
const p5 = makePlayer(country);
|
|
346
|
+
const p6 = makePlayer(country);
|
|
347
|
+
const libero = makePlayer(country);
|
|
348
|
+
const benchA = makePlayer(country);
|
|
349
|
+
const secondLibero = makePlayer(country);
|
|
350
|
+
const reserve = makePlayer(country);
|
|
351
|
+
// Pool = the 6 starters (p1..p6), the starting libero, the second libero, and the bench (benchA). `reserve` is
|
|
352
|
+
// outside the pool. 6-0 default => no designated setters, so the whole pool is orderable.
|
|
353
|
+
function baseInput(overrides = {}) {
|
|
354
|
+
return {
|
|
355
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, 0: libero, bench: [benchA] },
|
|
356
|
+
liberoReplacements: [],
|
|
357
|
+
secondLibero,
|
|
358
|
+
...overrides
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
it('accepts an order of pool members (starters, libero, L2, bench), no duplicates', () => {
|
|
362
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
363
|
+
emergencySetterOrder: [p2, benchA, libero, secondLibero]
|
|
364
|
+
}));
|
|
365
|
+
expect(res.success).toBe(true);
|
|
366
|
+
if (res.success) {
|
|
367
|
+
expect(res.data.emergencySetterOrder.map(p => p.id)).toEqual([p2.id, benchA.id, libero.id, secondLibero.id]);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
it('defaults to [] when omitted', () => {
|
|
371
|
+
const res = TacticsInputSchema.safeParse(baseInput());
|
|
372
|
+
expect(res.success).toBe(true);
|
|
373
|
+
if (res.success)
|
|
374
|
+
expect(res.data.emergencySetterOrder).toEqual([]);
|
|
375
|
+
});
|
|
376
|
+
it('rejects an entry outside the squad pool', () => {
|
|
377
|
+
const res = TacticsInputSchema.safeParse(baseInput({ emergencySetterOrder: [reserve] }));
|
|
378
|
+
expect(res.success).toBe(false);
|
|
379
|
+
if (!res.success) {
|
|
380
|
+
expect(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_NOT_IN_POOL')).toBe(true);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
it('rejects a duplicate entry', () => {
|
|
384
|
+
const res = TacticsInputSchema.safeParse(baseInput({ emergencySetterOrder: [p2, p2] }));
|
|
385
|
+
expect(res.success).toBe(false);
|
|
386
|
+
if (!res.success) {
|
|
387
|
+
expect(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_DUPLICATE')).toBe(true);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
it('rejects an entry that is a flat designated setter (setters are pinned, never stored in the list)', () => {
|
|
391
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
392
|
+
rotationSystem: '5-1',
|
|
393
|
+
designatedSetters: [p1],
|
|
394
|
+
emergencySetterOrder: [p1]
|
|
395
|
+
}));
|
|
396
|
+
expect(res.success).toBe(false);
|
|
397
|
+
if (!res.success) {
|
|
398
|
+
expect(res.error.issues.some(i => i.message === 'EMERGENCY_SETTER_IS_DESIGNATED_SETTER')).toBe(true);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -267,6 +267,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
267
267
|
liberoReplacementSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodCustom<Player, Player>>>>;
|
|
268
268
|
rotationSystem: z.ZodDefault<z.ZodEnum<typeof import("..").RotationSystemEnum>>;
|
|
269
269
|
designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
270
|
+
emergencySetterOrder: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
|
|
270
271
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotation: z.ZodNumber;
|
|
272
273
|
priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly liberoReplacementSets?: Player[][];
|
|
35
35
|
readonly rotationSystem: RotationSystemEnum;
|
|
36
36
|
readonly designatedSetters: Player[];
|
|
37
|
+
readonly emergencySetterOrder: Player[];
|
|
37
38
|
readonly offensivePreferences: OffensivePreference[];
|
|
38
39
|
readonly systemSets?: SystemSet[];
|
|
39
40
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
@@ -55,6 +56,7 @@ export declare class Tactics {
|
|
|
55
56
|
readonly liberoReplacementSets?: Player[][];
|
|
56
57
|
readonly rotationSystem: RotationSystemEnum;
|
|
57
58
|
readonly designatedSetters: Player[];
|
|
59
|
+
readonly emergencySetterOrder: Player[];
|
|
58
60
|
readonly offensivePreferences: OffensivePreference[];
|
|
59
61
|
readonly systemSets?: SystemSet[];
|
|
60
62
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
@@ -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 = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
|
|
18
|
+
constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], emergencySetterOrder = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
|
|
19
19
|
this.lineup = lineup;
|
|
20
20
|
this.liberoReplacements = liberoReplacements;
|
|
21
21
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -26,6 +26,7 @@ export class Tactics {
|
|
|
26
26
|
this.liberoReplacementSets = liberoReplacementSets;
|
|
27
27
|
this.rotationSystem = rotationSystem;
|
|
28
28
|
this.designatedSetters = designatedSetters;
|
|
29
|
+
this.emergencySetterOrder = emergencySetterOrder;
|
|
29
30
|
this.offensivePreferences = offensivePreferences;
|
|
30
31
|
this.systemSets = systemSets;
|
|
31
32
|
this.offensivePreferenceSets = offensivePreferenceSets;
|