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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.498",
3
+ "version": "0.0.500",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",