volleyballsimtypes 0.0.498 → 0.0.500
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 +2 -0
- 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 +32 -0
- package/dist/cjs/src/data/transformers/tactics.test.js +37 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +20 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +80 -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 +2 -0
- 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 +32 -0
- package/dist/esm/src/data/transformers/tactics.test.js +37 -0
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +20 -0
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +80 -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
|
@@ -139,6 +139,7 @@ export interface Tactics {
|
|
|
139
139
|
offensivePreferences: ApiOffensivePreference[];
|
|
140
140
|
systemSets?: ApiSystemSet[];
|
|
141
141
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
142
|
+
serveAttackShares?: Record<string, number>;
|
|
142
143
|
blockingPreferences: ApiBlockingPreference[];
|
|
143
144
|
blockingPreferenceSets?: ApiBlockingPreference[][];
|
|
144
145
|
replaceKnockedImmediately?: Record<string, boolean>;
|
|
@@ -172,6 +173,7 @@ export type Team = Omit<DataProps<_Team>, 'roster' | '_rating' | 'rating' | 'tac
|
|
|
172
173
|
};
|
|
173
174
|
};
|
|
174
175
|
nationalCountryName?: string;
|
|
176
|
+
isEvent?: boolean;
|
|
175
177
|
};
|
|
176
178
|
export type Standing = DataProps<_Standing> & {
|
|
177
179
|
position: number;
|
|
@@ -76,6 +76,7 @@ export interface TacticsAttributes {
|
|
|
76
76
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
77
77
|
system_sets?: SystemSetAttributes[];
|
|
78
78
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
79
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
79
80
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
80
81
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
81
82
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -84,7 +85,7 @@ export interface TacticsAttributes {
|
|
|
84
85
|
}
|
|
85
86
|
export type TacticsPk = 'team_id';
|
|
86
87
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
87
|
-
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';
|
|
88
|
+
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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
|
|
88
89
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
89
90
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
90
91
|
team_id: string;
|
|
@@ -101,6 +102,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
101
102
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
102
103
|
system_sets?: SystemSetAttributes[];
|
|
103
104
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
105
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
104
106
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
105
107
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
106
108
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -75,6 +75,11 @@ class TacticsModel extends sequelize_1.Model {
|
|
|
75
75
|
type: sequelize_1.DataTypes.JSONB,
|
|
76
76
|
allowNull: true
|
|
77
77
|
},
|
|
78
|
+
serve_attack_shares: {
|
|
79
|
+
type: sequelize_1.DataTypes.JSONB,
|
|
80
|
+
allowNull: false,
|
|
81
|
+
defaultValue: {}
|
|
82
|
+
},
|
|
78
83
|
replace_knocked_immediately: {
|
|
79
84
|
type: sequelize_1.DataTypes.JSONB,
|
|
80
85
|
allowNull: true
|
|
@@ -85,6 +85,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
85
85
|
weights: p.weights,
|
|
86
86
|
tempos: p.tempos
|
|
87
87
|
}))),
|
|
88
|
+
serve_attack_shares: tactics.serveAttackShares ?? {},
|
|
88
89
|
blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
89
90
|
blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
90
91
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
@@ -191,6 +192,21 @@ function transformToObject(model, roster) {
|
|
|
191
192
|
weights: p.weights,
|
|
192
193
|
tempos: p.tempos
|
|
193
194
|
}))),
|
|
195
|
+
// LENIENT: keep only serve shares keyed by a current libero-replacement (flat or per-set) with a value in the
|
|
196
|
+
// 0-100 band, so a stale key (a released player, or a libero-replacement config change) can never fail schema
|
|
197
|
+
// validation and crash a team load; a dropped key = no serve share.
|
|
198
|
+
serveAttackShares: (() => {
|
|
199
|
+
const liberoReplacementIds = new Set([
|
|
200
|
+
...model.libero_replacements,
|
|
201
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
202
|
+
]);
|
|
203
|
+
const healed = {};
|
|
204
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
205
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
206
|
+
healed[id] = value;
|
|
207
|
+
}
|
|
208
|
+
return healed;
|
|
209
|
+
})(),
|
|
194
210
|
blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
195
211
|
blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
196
212
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
@@ -336,6 +352,21 @@ function tacticsColumnsToApiDto(model) {
|
|
|
336
352
|
offensivePreferences: model.offensive_preferences,
|
|
337
353
|
systemSets: model.system_sets,
|
|
338
354
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
355
|
+
// Same stale-data policy as the heals below: only serve shares keyed by a current libero-replacement (flat or
|
|
356
|
+
// per-set) with a value in the 0-100 band survive; an empty map surfaces as absent so the UI never reloads and
|
|
357
|
+
// re-emits a stale key.
|
|
358
|
+
serveAttackShares: (() => {
|
|
359
|
+
const liberoReplacementIds = new Set([
|
|
360
|
+
...model.libero_replacements,
|
|
361
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
362
|
+
]);
|
|
363
|
+
const healed = {};
|
|
364
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
365
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
366
|
+
healed[id] = value;
|
|
367
|
+
}
|
|
368
|
+
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
369
|
+
})(),
|
|
339
370
|
blockingPreferences: model.blocking_preferences,
|
|
340
371
|
blockingPreferenceSets: model.blocking_preference_sets,
|
|
341
372
|
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
@@ -443,6 +474,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
443
474
|
offensive_preferences: config.offensivePreferences,
|
|
444
475
|
system_sets: config.systemSets,
|
|
445
476
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
477
|
+
serve_attack_shares: config.serveAttackShares ?? {},
|
|
446
478
|
blocking_preferences: config.blockingPreferences ?? [],
|
|
447
479
|
blocking_preference_sets: config.blockingPreferenceSets,
|
|
448
480
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
@@ -143,6 +143,22 @@ function makeTactics(designatedSubs) {
|
|
|
143
143
|
(0, globals_1.expect)(dto.systemSets).toBeUndefined();
|
|
144
144
|
(0, globals_1.expect)(dto.offensivePreferenceSets).toBeUndefined();
|
|
145
145
|
});
|
|
146
|
+
(0, globals_1.it)('surfaces serve attack shares keyed by a libero replacement', () => {
|
|
147
|
+
const model = makeTactics([]);
|
|
148
|
+
model.serve_attack_shares = { lf: 30 }; // lf is the configured libero_replacement
|
|
149
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
150
|
+
(0, globals_1.expect)(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
151
|
+
});
|
|
152
|
+
(0, globals_1.it)('drops a serve attack share keyed by a non-libero-replacement (stale heal)', () => {
|
|
153
|
+
const model = makeTactics([]);
|
|
154
|
+
model.serve_attack_shares = { lf: 30, rf: 40 }; // rf is not in libero_replacements (['lf'])
|
|
155
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
156
|
+
(0, globals_1.expect)(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
157
|
+
});
|
|
158
|
+
(0, globals_1.it)('leaves serveAttackShares undefined when the column is empty', () => {
|
|
159
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(makeTactics([]));
|
|
160
|
+
(0, globals_1.expect)(dto.serveAttackShares).toBeUndefined();
|
|
161
|
+
});
|
|
146
162
|
});
|
|
147
163
|
(0, globals_1.describe)('per-set tactics — service <-> row round-trip', () => {
|
|
148
164
|
const country = (0, test_helpers_1.makeCountry)();
|
|
@@ -190,6 +206,27 @@ function makeTactics(designatedSubs) {
|
|
|
190
206
|
(0, globals_1.expect)(back.systemSets).toBeUndefined();
|
|
191
207
|
(0, globals_1.expect)(back.offensivePreferenceSets).toBeUndefined();
|
|
192
208
|
});
|
|
209
|
+
(0, globals_1.it)('round-trips serve attack shares keyed by a libero replacement', () => {
|
|
210
|
+
const withServe = service_1.Tactics.create({
|
|
211
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
212
|
+
liberoReplacements: [p5],
|
|
213
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
214
|
+
});
|
|
215
|
+
const attrs = (0, tactics_1.transformFromTactics)(withServe, 'team-1');
|
|
216
|
+
(0, globals_1.expect)(attrs.serve_attack_shares).toEqual({ [p5.id]: 35 });
|
|
217
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
218
|
+
(0, globals_1.expect)(back.serveAttackShares).toEqual({ [p5.id]: 35 });
|
|
219
|
+
});
|
|
220
|
+
(0, globals_1.it)('drops a stale serve attack share key on load (lenient heal, no crash)', () => {
|
|
221
|
+
const attrs = (0, tactics_1.transformFromTactics)(service_1.Tactics.create({
|
|
222
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
223
|
+
liberoReplacements: [p5],
|
|
224
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
225
|
+
}), 'team-1');
|
|
226
|
+
attrs.libero_replacements = [];
|
|
227
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
228
|
+
(0, globals_1.expect)(back.serveAttackShares).toEqual({});
|
|
229
|
+
});
|
|
193
230
|
(0, globals_1.it)('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
194
231
|
const withTempos = service_1.Tactics.create({
|
|
195
232
|
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
@@ -267,6 +267,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
267
267
|
HIGH: "HIGH";
|
|
268
268
|
}>>>;
|
|
269
269
|
}, z.core.$strip>>>;
|
|
270
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
270
271
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
272
273
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -95,6 +95,11 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
95
95
|
rotationSystem: zod_1.z.nativeEnum(rotation_system_1.RotationSystemEnum).default(rotation_system_1.RotationSystemEnum.SIX_ZERO),
|
|
96
96
|
designatedSetters: zod_1.z.array(playerInstanceSchema).default([]),
|
|
97
97
|
offensivePreferences: zod_1.z.array(offensivePreferenceSchema).default([]),
|
|
98
|
+
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
99
|
+
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
100
|
+
// This is NOT part of the offensivePreferences sum-to-100; its band (0-100) and libero-replacement-key rules
|
|
101
|
+
// are validated independently in the superRefine below.
|
|
102
|
+
serveAttackShares: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int()).default({}),
|
|
98
103
|
// Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
|
|
99
104
|
// validated with the same cross-field rules as the flat fields (see superRefine below).
|
|
100
105
|
systemSets: zod_1.z.array(systemSetSchema).max(5).optional(),
|
|
@@ -216,6 +221,21 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
216
221
|
for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
|
|
217
222
|
validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
|
|
218
223
|
}
|
|
224
|
+
// Serve attack shares (libero-serve feature): only a current libero-replacement starter (flat or per-set) may
|
|
225
|
+
// carry one, and each value must be in the 0-100 band. Validated independently of the offensive-preference
|
|
226
|
+
// sum-to-100 (a serve share never enters that total).
|
|
227
|
+
const liberoReplacementIds = new Set([
|
|
228
|
+
...data.liberoReplacements.map(p => p.id),
|
|
229
|
+
...(data.liberoReplacementSets ?? []).flatMap(set => set.map(p => p.id))
|
|
230
|
+
]);
|
|
231
|
+
for (const [id, value] of Object.entries(data.serveAttackShares)) {
|
|
232
|
+
if (!liberoReplacementIds.has(id)) {
|
|
233
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT', path: ['serveAttackShares'] });
|
|
234
|
+
}
|
|
235
|
+
if (value < 0 || value > 100) {
|
|
236
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_OUT_OF_BAND', path: ['serveAttackShares'] });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
219
239
|
// Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
|
|
220
240
|
// rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
|
|
221
241
|
// libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
|
|
@@ -313,3 +313,83 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
313
313
|
(0, globals_1.expect)(res.success).toBe(false);
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
(0, globals_1.describe)('TacticsInputSchema — serve attack shares', () => {
|
|
317
|
+
const country = (0, test_helpers_1.makeCountry)();
|
|
318
|
+
const p1 = (0, test_helpers_1.makePlayer)(country);
|
|
319
|
+
const p2 = (0, test_helpers_1.makePlayer)(country);
|
|
320
|
+
const p3 = (0, test_helpers_1.makePlayer)(country);
|
|
321
|
+
const p4 = (0, test_helpers_1.makePlayer)(country);
|
|
322
|
+
const p5 = (0, test_helpers_1.makePlayer)(country);
|
|
323
|
+
const p6 = (0, test_helpers_1.makePlayer)(country);
|
|
324
|
+
const reserve = (0, test_helpers_1.makePlayer)(country);
|
|
325
|
+
// p5 sits at zone 5 (back row) and is the configured libero replacement, so it may carry a serve share.
|
|
326
|
+
function baseInput(overrides = {}) {
|
|
327
|
+
return {
|
|
328
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
329
|
+
liberoReplacements: [p5],
|
|
330
|
+
...overrides
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
(0, globals_1.it)('accepts a serve share keyed by a libero replacement, value in band', () => {
|
|
334
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 30 } }));
|
|
335
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
336
|
+
if (res.success)
|
|
337
|
+
(0, globals_1.expect)(res.data.serveAttackShares[p5.id]).toBe(30);
|
|
338
|
+
});
|
|
339
|
+
(0, globals_1.it)('accepts 0 and 100 at the band edges', () => {
|
|
340
|
+
(0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 0 } })).success).toBe(true);
|
|
341
|
+
(0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 100 } })).success).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
(0, globals_1.it)('rejects a serve share keyed by a starter who is not a libero replacement', () => {
|
|
344
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p2.id]: 30 } }));
|
|
345
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
346
|
+
if (!res.success) {
|
|
347
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
(0, globals_1.it)('rejects a serve share keyed by a reserve', () => {
|
|
351
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [reserve.id]: 30 } }));
|
|
352
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
353
|
+
if (!res.success) {
|
|
354
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
(0, globals_1.it)('rejects an out-of-band serve share (over 100)', () => {
|
|
358
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 101 } }));
|
|
359
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
360
|
+
if (!res.success) {
|
|
361
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
(0, globals_1.it)('rejects a negative serve share', () => {
|
|
365
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: -1 } }));
|
|
366
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
367
|
+
if (!res.success) {
|
|
368
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
(0, globals_1.it)('validates independently of the offensive-preference sum-to-100 (never enters that total)', () => {
|
|
372
|
+
// The other attackers' weights sum to exactly 100; the serve share on the libero replacement is separate
|
|
373
|
+
// and must not push the rotation over 100 or otherwise fail.
|
|
374
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
375
|
+
offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 50 } }],
|
|
376
|
+
serveAttackShares: { [p5.id]: 40 }
|
|
377
|
+
}));
|
|
378
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
(0, globals_1.it)('accepts a serve share on a per-set libero replacement', () => {
|
|
381
|
+
// p6 is a back-row starter made a libero replacement only via liberoReplacementSets.
|
|
382
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
383
|
+
liberoReplacements: [],
|
|
384
|
+
liberoReplacementSets: [[p6]],
|
|
385
|
+
serveAttackShares: { [p6.id]: 25 }
|
|
386
|
+
}));
|
|
387
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
(0, globals_1.it)('defaults serveAttackShares to an empty map when omitted', () => {
|
|
390
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput());
|
|
391
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
392
|
+
if (res.success)
|
|
393
|
+
(0, globals_1.expect)(res.data.serveAttackShares).toEqual({});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -275,6 +275,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
275
275
|
HIGH: "HIGH";
|
|
276
276
|
}>>>;
|
|
277
277
|
}, z.core.$strip>>>;
|
|
278
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
278
279
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
279
280
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
280
281
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly rotationSystem: RotationSystemEnum;
|
|
35
35
|
readonly designatedSetters: Player[];
|
|
36
36
|
readonly offensivePreferences: OffensivePreference[];
|
|
37
|
+
readonly serveAttackShares: Record<string, number>;
|
|
37
38
|
readonly systemSets?: SystemSet[];
|
|
38
39
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
39
40
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -54,6 +55,7 @@ export declare class Tactics {
|
|
|
54
55
|
readonly rotationSystem: RotationSystemEnum;
|
|
55
56
|
readonly designatedSetters: Player[];
|
|
56
57
|
readonly offensivePreferences: OffensivePreference[];
|
|
58
|
+
readonly serveAttackShares: Record<string, number>;
|
|
57
59
|
readonly systemSets?: SystemSet[];
|
|
58
60
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
59
61
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -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 }) {
|
|
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 = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
|
|
22
22
|
this.lineup = lineup;
|
|
23
23
|
this.liberoReplacements = liberoReplacements;
|
|
24
24
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -30,6 +30,7 @@ class Tactics {
|
|
|
30
30
|
this.rotationSystem = rotationSystem;
|
|
31
31
|
this.designatedSetters = designatedSetters;
|
|
32
32
|
this.offensivePreferences = offensivePreferences;
|
|
33
|
+
this.serveAttackShares = serveAttackShares;
|
|
33
34
|
this.systemSets = systemSets;
|
|
34
35
|
this.offensivePreferenceSets = offensivePreferenceSets;
|
|
35
36
|
this.replaceKnockedImmediately = replaceKnockedImmediately;
|
|
@@ -139,6 +139,7 @@ export interface Tactics {
|
|
|
139
139
|
offensivePreferences: ApiOffensivePreference[];
|
|
140
140
|
systemSets?: ApiSystemSet[];
|
|
141
141
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
142
|
+
serveAttackShares?: Record<string, number>;
|
|
142
143
|
blockingPreferences: ApiBlockingPreference[];
|
|
143
144
|
blockingPreferenceSets?: ApiBlockingPreference[][];
|
|
144
145
|
replaceKnockedImmediately?: Record<string, boolean>;
|
|
@@ -172,6 +173,7 @@ export type Team = Omit<DataProps<_Team>, 'roster' | '_rating' | 'rating' | 'tac
|
|
|
172
173
|
};
|
|
173
174
|
};
|
|
174
175
|
nationalCountryName?: string;
|
|
176
|
+
isEvent?: boolean;
|
|
175
177
|
};
|
|
176
178
|
export type Standing = DataProps<_Standing> & {
|
|
177
179
|
position: number;
|
|
@@ -76,6 +76,7 @@ export interface TacticsAttributes {
|
|
|
76
76
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
77
77
|
system_sets?: SystemSetAttributes[];
|
|
78
78
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
79
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
79
80
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
80
81
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
81
82
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -84,7 +85,7 @@ export interface TacticsAttributes {
|
|
|
84
85
|
}
|
|
85
86
|
export type TacticsPk = 'team_id';
|
|
86
87
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
87
|
-
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';
|
|
88
|
+
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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
|
|
88
89
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
89
90
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
90
91
|
team_id: string;
|
|
@@ -101,6 +102,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
101
102
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
102
103
|
system_sets?: SystemSetAttributes[];
|
|
103
104
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
105
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
104
106
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
105
107
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
106
108
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -72,6 +72,11 @@ export class TacticsModel extends Model {
|
|
|
72
72
|
type: DataTypes.JSONB,
|
|
73
73
|
allowNull: true
|
|
74
74
|
},
|
|
75
|
+
serve_attack_shares: {
|
|
76
|
+
type: DataTypes.JSONB,
|
|
77
|
+
allowNull: false,
|
|
78
|
+
defaultValue: {}
|
|
79
|
+
},
|
|
75
80
|
replace_knocked_immediately: {
|
|
76
81
|
type: DataTypes.JSONB,
|
|
77
82
|
allowNull: true
|
|
@@ -79,6 +79,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
79
79
|
weights: p.weights,
|
|
80
80
|
tempos: p.tempos
|
|
81
81
|
}))),
|
|
82
|
+
serve_attack_shares: tactics.serveAttackShares ?? {},
|
|
82
83
|
blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
83
84
|
blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
84
85
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
@@ -185,6 +186,21 @@ function transformToObject(model, roster) {
|
|
|
185
186
|
weights: p.weights,
|
|
186
187
|
tempos: p.tempos
|
|
187
188
|
}))),
|
|
189
|
+
// LENIENT: keep only serve shares keyed by a current libero-replacement (flat or per-set) with a value in the
|
|
190
|
+
// 0-100 band, so a stale key (a released player, or a libero-replacement config change) can never fail schema
|
|
191
|
+
// validation and crash a team load; a dropped key = no serve share.
|
|
192
|
+
serveAttackShares: (() => {
|
|
193
|
+
const liberoReplacementIds = new Set([
|
|
194
|
+
...model.libero_replacements,
|
|
195
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
196
|
+
]);
|
|
197
|
+
const healed = {};
|
|
198
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
199
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
200
|
+
healed[id] = value;
|
|
201
|
+
}
|
|
202
|
+
return healed;
|
|
203
|
+
})(),
|
|
188
204
|
blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
189
205
|
blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
190
206
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
@@ -330,6 +346,21 @@ function tacticsColumnsToApiDto(model) {
|
|
|
330
346
|
offensivePreferences: model.offensive_preferences,
|
|
331
347
|
systemSets: model.system_sets,
|
|
332
348
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
349
|
+
// Same stale-data policy as the heals below: only serve shares keyed by a current libero-replacement (flat or
|
|
350
|
+
// per-set) with a value in the 0-100 band survive; an empty map surfaces as absent so the UI never reloads and
|
|
351
|
+
// re-emits a stale key.
|
|
352
|
+
serveAttackShares: (() => {
|
|
353
|
+
const liberoReplacementIds = new Set([
|
|
354
|
+
...model.libero_replacements,
|
|
355
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
356
|
+
]);
|
|
357
|
+
const healed = {};
|
|
358
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
359
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
360
|
+
healed[id] = value;
|
|
361
|
+
}
|
|
362
|
+
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
363
|
+
})(),
|
|
333
364
|
blockingPreferences: model.blocking_preferences,
|
|
334
365
|
blockingPreferenceSets: model.blocking_preference_sets,
|
|
335
366
|
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
@@ -437,6 +468,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
437
468
|
offensive_preferences: config.offensivePreferences,
|
|
438
469
|
system_sets: config.systemSets,
|
|
439
470
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
471
|
+
serve_attack_shares: config.serveAttackShares ?? {},
|
|
440
472
|
blocking_preferences: config.blockingPreferences ?? [],
|
|
441
473
|
blocking_preference_sets: config.blockingPreferenceSets,
|
|
442
474
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
@@ -141,6 +141,22 @@ describe('tacticsColumnsToApiDto()', () => {
|
|
|
141
141
|
expect(dto.systemSets).toBeUndefined();
|
|
142
142
|
expect(dto.offensivePreferenceSets).toBeUndefined();
|
|
143
143
|
});
|
|
144
|
+
it('surfaces serve attack shares keyed by a libero replacement', () => {
|
|
145
|
+
const model = makeTactics([]);
|
|
146
|
+
model.serve_attack_shares = { lf: 30 }; // lf is the configured libero_replacement
|
|
147
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
148
|
+
expect(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
149
|
+
});
|
|
150
|
+
it('drops a serve attack share keyed by a non-libero-replacement (stale heal)', () => {
|
|
151
|
+
const model = makeTactics([]);
|
|
152
|
+
model.serve_attack_shares = { lf: 30, rf: 40 }; // rf is not in libero_replacements (['lf'])
|
|
153
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
154
|
+
expect(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
155
|
+
});
|
|
156
|
+
it('leaves serveAttackShares undefined when the column is empty', () => {
|
|
157
|
+
const dto = tacticsColumnsToApiDto(makeTactics([]));
|
|
158
|
+
expect(dto.serveAttackShares).toBeUndefined();
|
|
159
|
+
});
|
|
144
160
|
});
|
|
145
161
|
describe('per-set tactics — service <-> row round-trip', () => {
|
|
146
162
|
const country = makeCountry();
|
|
@@ -188,6 +204,27 @@ describe('per-set tactics — service <-> row round-trip', () => {
|
|
|
188
204
|
expect(back.systemSets).toBeUndefined();
|
|
189
205
|
expect(back.offensivePreferenceSets).toBeUndefined();
|
|
190
206
|
});
|
|
207
|
+
it('round-trips serve attack shares keyed by a libero replacement', () => {
|
|
208
|
+
const withServe = Tactics.create({
|
|
209
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
210
|
+
liberoReplacements: [p5],
|
|
211
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
212
|
+
});
|
|
213
|
+
const attrs = transformFromTactics(withServe, 'team-1');
|
|
214
|
+
expect(attrs.serve_attack_shares).toEqual({ [p5.id]: 35 });
|
|
215
|
+
const back = transformToTactics(attrs, roster);
|
|
216
|
+
expect(back.serveAttackShares).toEqual({ [p5.id]: 35 });
|
|
217
|
+
});
|
|
218
|
+
it('drops a stale serve attack share key on load (lenient heal, no crash)', () => {
|
|
219
|
+
const attrs = transformFromTactics(Tactics.create({
|
|
220
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
221
|
+
liberoReplacements: [p5],
|
|
222
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
223
|
+
}), 'team-1');
|
|
224
|
+
attrs.libero_replacements = [];
|
|
225
|
+
const back = transformToTactics(attrs, roster);
|
|
226
|
+
expect(back.serveAttackShares).toEqual({});
|
|
227
|
+
});
|
|
191
228
|
it('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
192
229
|
const withTempos = Tactics.create({
|
|
193
230
|
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
@@ -267,6 +267,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
267
267
|
HIGH: "HIGH";
|
|
268
268
|
}>>>;
|
|
269
269
|
}, z.core.$strip>>>;
|
|
270
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
270
271
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
272
273
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -92,6 +92,11 @@ export const TacticsInputSchema = z.object({
|
|
|
92
92
|
rotationSystem: z.nativeEnum(RotationSystemEnum).default(RotationSystemEnum.SIX_ZERO),
|
|
93
93
|
designatedSetters: z.array(playerInstanceSchema).default([]),
|
|
94
94
|
offensivePreferences: z.array(offensivePreferenceSchema).default([]),
|
|
95
|
+
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
96
|
+
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
97
|
+
// This is NOT part of the offensivePreferences sum-to-100; its band (0-100) and libero-replacement-key rules
|
|
98
|
+
// are validated independently in the superRefine below.
|
|
99
|
+
serveAttackShares: z.record(z.string(), z.number().int()).default({}),
|
|
95
100
|
// Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
|
|
96
101
|
// validated with the same cross-field rules as the flat fields (see superRefine below).
|
|
97
102
|
systemSets: z.array(systemSetSchema).max(5).optional(),
|
|
@@ -213,6 +218,21 @@ export const TacticsInputSchema = z.object({
|
|
|
213
218
|
for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
|
|
214
219
|
validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
|
|
215
220
|
}
|
|
221
|
+
// Serve attack shares (libero-serve feature): only a current libero-replacement starter (flat or per-set) may
|
|
222
|
+
// carry one, and each value must be in the 0-100 band. Validated independently of the offensive-preference
|
|
223
|
+
// sum-to-100 (a serve share never enters that total).
|
|
224
|
+
const liberoReplacementIds = new Set([
|
|
225
|
+
...data.liberoReplacements.map(p => p.id),
|
|
226
|
+
...(data.liberoReplacementSets ?? []).flatMap(set => set.map(p => p.id))
|
|
227
|
+
]);
|
|
228
|
+
for (const [id, value] of Object.entries(data.serveAttackShares)) {
|
|
229
|
+
if (!liberoReplacementIds.has(id)) {
|
|
230
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT', path: ['serveAttackShares'] });
|
|
231
|
+
}
|
|
232
|
+
if (value < 0 || value > 100) {
|
|
233
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_OUT_OF_BAND', path: ['serveAttackShares'] });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
216
236
|
// Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
|
|
217
237
|
// rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
|
|
218
238
|
// libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
|
|
@@ -311,3 +311,83 @@ describe('TacticsInputSchema — blocking preferences', () => {
|
|
|
311
311
|
expect(res.success).toBe(false);
|
|
312
312
|
});
|
|
313
313
|
});
|
|
314
|
+
describe('TacticsInputSchema — serve attack shares', () => {
|
|
315
|
+
const country = makeCountry();
|
|
316
|
+
const p1 = makePlayer(country);
|
|
317
|
+
const p2 = makePlayer(country);
|
|
318
|
+
const p3 = makePlayer(country);
|
|
319
|
+
const p4 = makePlayer(country);
|
|
320
|
+
const p5 = makePlayer(country);
|
|
321
|
+
const p6 = makePlayer(country);
|
|
322
|
+
const reserve = makePlayer(country);
|
|
323
|
+
// p5 sits at zone 5 (back row) and is the configured libero replacement, so it may carry a serve share.
|
|
324
|
+
function baseInput(overrides = {}) {
|
|
325
|
+
return {
|
|
326
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
327
|
+
liberoReplacements: [p5],
|
|
328
|
+
...overrides
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
it('accepts a serve share keyed by a libero replacement, value in band', () => {
|
|
332
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 30 } }));
|
|
333
|
+
expect(res.success).toBe(true);
|
|
334
|
+
if (res.success)
|
|
335
|
+
expect(res.data.serveAttackShares[p5.id]).toBe(30);
|
|
336
|
+
});
|
|
337
|
+
it('accepts 0 and 100 at the band edges', () => {
|
|
338
|
+
expect(TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 0 } })).success).toBe(true);
|
|
339
|
+
expect(TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 100 } })).success).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
it('rejects a serve share keyed by a starter who is not a libero replacement', () => {
|
|
342
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p2.id]: 30 } }));
|
|
343
|
+
expect(res.success).toBe(false);
|
|
344
|
+
if (!res.success) {
|
|
345
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
it('rejects a serve share keyed by a reserve', () => {
|
|
349
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [reserve.id]: 30 } }));
|
|
350
|
+
expect(res.success).toBe(false);
|
|
351
|
+
if (!res.success) {
|
|
352
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
it('rejects an out-of-band serve share (over 100)', () => {
|
|
356
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 101 } }));
|
|
357
|
+
expect(res.success).toBe(false);
|
|
358
|
+
if (!res.success) {
|
|
359
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
it('rejects a negative serve share', () => {
|
|
363
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: -1 } }));
|
|
364
|
+
expect(res.success).toBe(false);
|
|
365
|
+
if (!res.success) {
|
|
366
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
it('validates independently of the offensive-preference sum-to-100 (never enters that total)', () => {
|
|
370
|
+
// The other attackers' weights sum to exactly 100; the serve share on the libero replacement is separate
|
|
371
|
+
// and must not push the rotation over 100 or otherwise fail.
|
|
372
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
373
|
+
offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 50 } }],
|
|
374
|
+
serveAttackShares: { [p5.id]: 40 }
|
|
375
|
+
}));
|
|
376
|
+
expect(res.success).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
it('accepts a serve share on a per-set libero replacement', () => {
|
|
379
|
+
// p6 is a back-row starter made a libero replacement only via liberoReplacementSets.
|
|
380
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
381
|
+
liberoReplacements: [],
|
|
382
|
+
liberoReplacementSets: [[p6]],
|
|
383
|
+
serveAttackShares: { [p6.id]: 25 }
|
|
384
|
+
}));
|
|
385
|
+
expect(res.success).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
it('defaults serveAttackShares to an empty map when omitted', () => {
|
|
388
|
+
const res = TacticsInputSchema.safeParse(baseInput());
|
|
389
|
+
expect(res.success).toBe(true);
|
|
390
|
+
if (res.success)
|
|
391
|
+
expect(res.data.serveAttackShares).toEqual({});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -275,6 +275,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
275
275
|
HIGH: "HIGH";
|
|
276
276
|
}>>>;
|
|
277
277
|
}, z.core.$strip>>>;
|
|
278
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
278
279
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
279
280
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
280
281
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly rotationSystem: RotationSystemEnum;
|
|
35
35
|
readonly designatedSetters: Player[];
|
|
36
36
|
readonly offensivePreferences: OffensivePreference[];
|
|
37
|
+
readonly serveAttackShares: Record<string, number>;
|
|
37
38
|
readonly systemSets?: SystemSet[];
|
|
38
39
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
39
40
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -54,6 +55,7 @@ export declare class Tactics {
|
|
|
54
55
|
readonly rotationSystem: RotationSystemEnum;
|
|
55
56
|
readonly designatedSetters: Player[];
|
|
56
57
|
readonly offensivePreferences: OffensivePreference[];
|
|
58
|
+
readonly serveAttackShares: Record<string, number>;
|
|
57
59
|
readonly systemSets?: SystemSet[];
|
|
58
60
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
59
61
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -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 }) {
|
|
18
|
+
constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
|
|
19
19
|
this.lineup = lineup;
|
|
20
20
|
this.liberoReplacements = liberoReplacements;
|
|
21
21
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -27,6 +27,7 @@ export class Tactics {
|
|
|
27
27
|
this.rotationSystem = rotationSystem;
|
|
28
28
|
this.designatedSetters = designatedSetters;
|
|
29
29
|
this.offensivePreferences = offensivePreferences;
|
|
30
|
+
this.serveAttackShares = serveAttackShares;
|
|
30
31
|
this.systemSets = systemSets;
|
|
31
32
|
this.offensivePreferenceSets = offensivePreferenceSets;
|
|
32
33
|
this.replaceKnockedImmediately = replaceKnockedImmediately;
|