volleyballsimtypes 0.0.499 → 0.0.501

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/cjs/src/api/index.d.ts +1 -0
  2. package/dist/cjs/src/data/models/tactics.d.ts +3 -1
  3. package/dist/cjs/src/data/models/tactics.js +5 -0
  4. package/dist/cjs/src/data/transformers/tactics.js +32 -0
  5. package/dist/cjs/src/data/transformers/tactics.test.js +37 -0
  6. package/dist/cjs/src/service/competition/event-rewards.d.ts +3 -2
  7. package/dist/cjs/src/service/competition/event-rewards.js +5 -3
  8. package/dist/cjs/src/service/competition/event-rewards.test.js +9 -9
  9. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -0
  10. package/dist/cjs/src/service/team/schemas/tactics.z.js +20 -0
  11. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +80 -0
  12. package/dist/cjs/src/service/team/schemas/team.z.d.ts +1 -0
  13. package/dist/cjs/src/service/team/tactics.d.ts +2 -0
  14. package/dist/cjs/src/service/team/tactics.js +2 -1
  15. package/dist/esm/src/api/index.d.ts +1 -0
  16. package/dist/esm/src/data/models/tactics.d.ts +3 -1
  17. package/dist/esm/src/data/models/tactics.js +5 -0
  18. package/dist/esm/src/data/transformers/tactics.js +32 -0
  19. package/dist/esm/src/data/transformers/tactics.test.js +37 -0
  20. package/dist/esm/src/service/competition/event-rewards.d.ts +3 -2
  21. package/dist/esm/src/service/competition/event-rewards.js +5 -3
  22. package/dist/esm/src/service/competition/event-rewards.test.js +9 -9
  23. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -0
  24. package/dist/esm/src/service/team/schemas/tactics.z.js +20 -0
  25. package/dist/esm/src/service/team/schemas/tactics.z.test.js +80 -0
  26. package/dist/esm/src/service/team/schemas/team.z.d.ts +1 -0
  27. package/dist/esm/src/service/team/tactics.d.ts +2 -0
  28. package/dist/esm/src/service/team/tactics.js +2 -1
  29. 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>;
@@ -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: [] },
@@ -1,4 +1,4 @@
1
- export type EventRewardBand = 'PARTICIPATION' | 'TOP_20' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
1
+ export type EventRewardBand = 'PARTICIPATION' | 'FINAL_GROUP' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
2
2
  export interface EventRewardRow {
3
3
  readonly band: EventRewardBand;
4
4
  /** Best final position that earns this row; PARTICIPATION has none. */
@@ -15,7 +15,8 @@ export interface EventRewardRow {
15
15
  export declare const EVENT_REWARDS: readonly EventRewardRow[];
16
16
  /**
17
17
  * The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
18
- * the final, and so does any position past the last banded row, which both count as taking part.
18
+ * the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
19
+ * teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
19
20
  */
20
21
  export declare function eventRewardBandFor(position: number | null): EventRewardBand;
21
22
  export interface EventPayout {
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.EVENT_REWARDS = void 0;
9
9
  exports.eventRewardBandFor = eventRewardBandFor;
10
10
  exports.eventPayoutFor = eventPayoutFor;
11
+ const event_schedule_1 = require("./event-schedule");
11
12
  /**
12
13
  * Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
13
14
  * place collects all six.
@@ -17,18 +18,19 @@ exports.EVENT_REWARDS = [
17
18
  { band: 'TOP_3', maxPosition: 3, packs: 0, coins: 500 },
18
19
  { band: 'TOP_5', maxPosition: 5, packs: 0, coins: 1000 },
19
20
  { band: 'TOP_10', maxPosition: 10, packs: 1, coins: 0 },
20
- { band: 'TOP_20', maxPosition: 20, packs: 0, coins: 1000 },
21
+ { band: 'FINAL_GROUP', maxPosition: event_schedule_1.MAX_FINAL_SIZE, packs: 0, coins: 1000 },
21
22
  { band: 'PARTICIPATION', maxPosition: null, packs: 1, coins: 1000 }
22
23
  ];
23
24
  /**
24
25
  * The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
25
- * the final, and so does any position past the last banded row, which both count as taking part.
26
+ * the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
27
+ * teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
26
28
  */
27
29
  function eventRewardBandFor(position) {
28
30
  if (position == null)
29
31
  return 'PARTICIPATION';
30
32
  // Rows are ordered best-first, so the FIRST row the position satisfies is the narrowest band it earns.
31
- // Scanning to the end instead would always land on the widest, handing first place a "top 20" badge.
33
+ // Scanning to the end instead would always land on the widest, handing first place a "final group" badge.
32
34
  for (const row of exports.EVENT_REWARDS) {
33
35
  if (row.maxPosition != null && position <= row.maxPosition)
34
36
  return row.band;
@@ -11,11 +11,11 @@ const event_rewards_1 = require("./event-rewards");
11
11
  [5, 'TOP_5'],
12
12
  [6, 'TOP_10'],
13
13
  [10, 'TOP_10'],
14
- [11, 'TOP_20'],
15
- [20, 'TOP_20'],
16
- // Inside a 24-team final but outside every banded row, so it counts as taking part.
17
- [21, 'PARTICIPATION'],
18
- [24, 'PARTICIPATION']
14
+ [11, 'FINAL_GROUP'],
15
+ [20, 'FINAL_GROUP'],
16
+ // The final holds 24 teams and FINAL_GROUP covers all of them.
17
+ [21, 'FINAL_GROUP'],
18
+ [24, 'FINAL_GROUP']
19
19
  ])('places a finish of %i in %s', (position, band) => {
20
20
  (0, globals_1.expect)((0, event_rewards_1.eventRewardBandFor)(position)).toBe(band);
21
21
  });
@@ -26,9 +26,9 @@ const event_rewards_1 = require("./event-rewards");
26
26
  (0, globals_1.describe)('eventPayoutFor()', () => {
27
27
  globals_1.it.each([
28
28
  // position, packs, coins. Cumulative: every row from Participation down to the finish.
29
- [null, 1, 1000], // group stage: 1 pack + 1000
30
- [24, 1, 1000], // in the final but outside the top 20: still just participation
31
- [20, 1, 2000], // + top 20's 1000
29
+ [null, 1, 1000], // never reached the final (group stage): 1 pack + 1000
30
+ [24, 1, 2000], // last of the 24-team final: final group's 1000 on top of participation
31
+ [20, 1, 2000], // + final group's 1000
32
32
  [11, 1, 2000],
33
33
  [10, 2, 2000], // + top 10's pack
34
34
  [6, 2, 2000],
@@ -50,7 +50,7 @@ const event_rewards_1 = require("./event-rewards");
50
50
  }
51
51
  });
52
52
  (0, globals_1.it)('adds up to the owner-defined table for a winner', () => {
53
- // 1x pack + 1000 (participation), +1000 (top 20), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
53
+ // 1x pack + 1000 (participation), +1000 (final group), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
54
54
  const total = event_rewards_1.EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
55
55
  (0, globals_1.expect)((0, event_rewards_1.eventPayoutFor)(1)).toEqual(total);
56
56
  (0, globals_1.expect)(total).toEqual({ packs: 3, coins: 3500 });
@@ -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>;
@@ -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: [] },
@@ -1,4 +1,4 @@
1
- export type EventRewardBand = 'PARTICIPATION' | 'TOP_20' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
1
+ export type EventRewardBand = 'PARTICIPATION' | 'FINAL_GROUP' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
2
2
  export interface EventRewardRow {
3
3
  readonly band: EventRewardBand;
4
4
  /** Best final position that earns this row; PARTICIPATION has none. */
@@ -15,7 +15,8 @@ export interface EventRewardRow {
15
15
  export declare const EVENT_REWARDS: readonly EventRewardRow[];
16
16
  /**
17
17
  * The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
18
- * the final, and so does any position past the last banded row, which both count as taking part.
18
+ * the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
19
+ * teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
19
20
  */
20
21
  export declare function eventRewardBandFor(position: number | null): EventRewardBand;
21
22
  export interface EventPayout {
@@ -3,6 +3,7 @@
3
3
  // One source of truth for three consumers: the simulator grants from it, the About view lists it, and the
4
4
  // accolades page names the band. Rewards are CUMULATIVE, so a team collects every row from Participation down
5
5
  // to its own placement.
6
+ import { MAX_FINAL_SIZE } from './event-schedule';
6
7
  /**
7
8
  * Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
8
9
  * place collects all six.
@@ -12,18 +13,19 @@ export const EVENT_REWARDS = [
12
13
  { band: 'TOP_3', maxPosition: 3, packs: 0, coins: 500 },
13
14
  { band: 'TOP_5', maxPosition: 5, packs: 0, coins: 1000 },
14
15
  { band: 'TOP_10', maxPosition: 10, packs: 1, coins: 0 },
15
- { band: 'TOP_20', maxPosition: 20, packs: 0, coins: 1000 },
16
+ { band: 'FINAL_GROUP', maxPosition: MAX_FINAL_SIZE, packs: 0, coins: 1000 },
16
17
  { band: 'PARTICIPATION', maxPosition: null, packs: 1, coins: 1000 }
17
18
  ];
18
19
  /**
19
20
  * The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
20
- * the final, and so does any position past the last banded row, which both count as taking part.
21
+ * the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
22
+ * teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
21
23
  */
22
24
  export function eventRewardBandFor(position) {
23
25
  if (position == null)
24
26
  return 'PARTICIPATION';
25
27
  // Rows are ordered best-first, so the FIRST row the position satisfies is the narrowest band it earns.
26
- // Scanning to the end instead would always land on the widest, handing first place a "top 20" badge.
28
+ // Scanning to the end instead would always land on the widest, handing first place a "final group" badge.
27
29
  for (const row of EVENT_REWARDS) {
28
30
  if (row.maxPosition != null && position <= row.maxPosition)
29
31
  return row.band;
@@ -9,11 +9,11 @@ describe('eventRewardBandFor()', () => {
9
9
  [5, 'TOP_5'],
10
10
  [6, 'TOP_10'],
11
11
  [10, 'TOP_10'],
12
- [11, 'TOP_20'],
13
- [20, 'TOP_20'],
14
- // Inside a 24-team final but outside every banded row, so it counts as taking part.
15
- [21, 'PARTICIPATION'],
16
- [24, 'PARTICIPATION']
12
+ [11, 'FINAL_GROUP'],
13
+ [20, 'FINAL_GROUP'],
14
+ // The final holds 24 teams and FINAL_GROUP covers all of them.
15
+ [21, 'FINAL_GROUP'],
16
+ [24, 'FINAL_GROUP']
17
17
  ])('places a finish of %i in %s', (position, band) => {
18
18
  expect(eventRewardBandFor(position)).toBe(band);
19
19
  });
@@ -24,9 +24,9 @@ describe('eventRewardBandFor()', () => {
24
24
  describe('eventPayoutFor()', () => {
25
25
  it.each([
26
26
  // position, packs, coins. Cumulative: every row from Participation down to the finish.
27
- [null, 1, 1000], // group stage: 1 pack + 1000
28
- [24, 1, 1000], // in the final but outside the top 20: still just participation
29
- [20, 1, 2000], // + top 20's 1000
27
+ [null, 1, 1000], // never reached the final (group stage): 1 pack + 1000
28
+ [24, 1, 2000], // last of the 24-team final: final group's 1000 on top of participation
29
+ [20, 1, 2000], // + final group's 1000
30
30
  [11, 1, 2000],
31
31
  [10, 2, 2000], // + top 10's pack
32
32
  [6, 2, 2000],
@@ -48,7 +48,7 @@ describe('eventPayoutFor()', () => {
48
48
  }
49
49
  });
50
50
  it('adds up to the owner-defined table for a winner', () => {
51
- // 1x pack + 1000 (participation), +1000 (top 20), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
51
+ // 1x pack + 1000 (participation), +1000 (final group), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
52
52
  const total = EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
53
53
  expect(eventPayoutFor(1)).toEqual(total);
54
54
  expect(total).toEqual({ packs: 3, coins: 3500 });
@@ -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.499",
3
+ "version": "0.0.501",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",