volleyballsimtypes 0.0.482 → 0.0.483

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 (59) hide show
  1. package/dist/cjs/src/api/index.d.ts +3 -17
  2. package/dist/cjs/src/data/models/tactics.d.ts +7 -17
  3. package/dist/cjs/src/data/transformers/match-preset-override.test.js +10 -1
  4. package/dist/cjs/src/data/transformers/rally-event.d.ts +2 -1
  5. package/dist/cjs/src/data/transformers/rally-event.js +12 -0
  6. package/dist/cjs/src/data/transformers/rally.js +2 -0
  7. package/dist/cjs/src/data/transformers/rally.test.d.ts +1 -0
  8. package/dist/cjs/src/data/transformers/rally.test.js +62 -0
  9. package/dist/cjs/src/data/transformers/tactics.js +26 -54
  10. package/dist/cjs/src/data/transformers/tactics.test.js +16 -11
  11. package/dist/cjs/src/service/team/designated-sub.d.ts +2 -4
  12. package/dist/cjs/src/service/team/index.d.ts +1 -0
  13. package/dist/cjs/src/service/team/index.js +1 -0
  14. package/dist/cjs/src/service/team/libero-sub.d.ts +0 -4
  15. package/dist/cjs/src/service/team/pinch-condition.d.ts +11 -3
  16. package/dist/cjs/src/service/team/pinch-condition.js +9 -4
  17. package/dist/cjs/src/service/team/schemas/designated-sub.z.d.ts +30 -32
  18. package/dist/cjs/src/service/team/schemas/designated-sub.z.js +27 -23
  19. package/dist/cjs/src/service/team/schemas/designated-sub.z.test.js +4 -2
  20. package/dist/cjs/src/service/team/schemas/libero-sub.z.d.ts +15 -34
  21. package/dist/cjs/src/service/team/schemas/libero-sub.z.js +10 -26
  22. package/dist/cjs/src/service/team/schemas/libero-sub.z.test.js +74 -35
  23. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +28 -36
  24. package/dist/cjs/src/service/team/schemas/tactics.z.js +4 -3
  25. package/dist/cjs/src/service/team/schemas/team.z.d.ts +28 -36
  26. package/dist/cjs/src/service/team/sub-config-convert.d.ts +13 -0
  27. package/dist/cjs/src/service/team/sub-config-convert.js +91 -0
  28. package/dist/cjs/src/service/team/sub-config-convert.test.d.ts +1 -0
  29. package/dist/cjs/src/service/team/sub-config-convert.test.js +84 -0
  30. package/dist/esm/src/api/index.d.ts +3 -17
  31. package/dist/esm/src/data/models/tactics.d.ts +7 -17
  32. package/dist/esm/src/data/transformers/match-preset-override.test.js +10 -1
  33. package/dist/esm/src/data/transformers/rally-event.d.ts +2 -1
  34. package/dist/esm/src/data/transformers/rally-event.js +12 -1
  35. package/dist/esm/src/data/transformers/rally.js +4 -2
  36. package/dist/esm/src/data/transformers/rally.test.d.ts +1 -0
  37. package/dist/esm/src/data/transformers/rally.test.js +60 -0
  38. package/dist/esm/src/data/transformers/tactics.js +27 -55
  39. package/dist/esm/src/data/transformers/tactics.test.js +16 -11
  40. package/dist/esm/src/service/team/designated-sub.d.ts +2 -4
  41. package/dist/esm/src/service/team/index.d.ts +1 -0
  42. package/dist/esm/src/service/team/index.js +1 -0
  43. package/dist/esm/src/service/team/libero-sub.d.ts +0 -4
  44. package/dist/esm/src/service/team/pinch-condition.d.ts +11 -3
  45. package/dist/esm/src/service/team/pinch-condition.js +9 -4
  46. package/dist/esm/src/service/team/schemas/designated-sub.z.d.ts +30 -32
  47. package/dist/esm/src/service/team/schemas/designated-sub.z.js +26 -22
  48. package/dist/esm/src/service/team/schemas/designated-sub.z.test.js +4 -2
  49. package/dist/esm/src/service/team/schemas/libero-sub.z.d.ts +15 -34
  50. package/dist/esm/src/service/team/schemas/libero-sub.z.js +9 -25
  51. package/dist/esm/src/service/team/schemas/libero-sub.z.test.js +75 -36
  52. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +28 -36
  53. package/dist/esm/src/service/team/schemas/tactics.z.js +4 -3
  54. package/dist/esm/src/service/team/schemas/team.z.d.ts +28 -36
  55. package/dist/esm/src/service/team/sub-config-convert.d.ts +13 -0
  56. package/dist/esm/src/service/team/sub-config-convert.js +85 -0
  57. package/dist/esm/src/service/team/sub-config-convert.test.d.ts +1 -0
  58. package/dist/esm/src/service/team/sub-config-convert.test.js +82 -0
  59. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { pack, unpack } from 'msgpackr';
2
2
  import { deflateRawSync, inflateRawSync } from 'zlib';
3
3
  import { decompressFromBase64 } from 'lz-string';
4
- import { Block, LiberoReplacement, Rally, Reception, Serve, Set as VolleySet, Spike, Substitution } from '../../service';
5
- import { transformToBlock, transformToCompact, transformToLiberoReplacement, transformToReception, transformToServe, transformToSet, transformToSpike, transformToSubstitution } from './rally-event';
4
+ import { Block, FreeBall, LiberoReplacement, Rally, Reception, Serve, Set as VolleySet, Spike, Substitution } from '../../service';
5
+ import { transformToBlock, transformToCompact, transformToFreeBall, transformToLiberoReplacement, transformToReception, transformToServe, transformToSet, transformToSpike, transformToSubstitution } from './rally-event';
6
6
  const VERSION_BYTE = 0x01;
7
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
8
  function deflate(data) { return deflateRawSync(data); }
@@ -57,6 +57,7 @@ function decodeCompactEvent(event, roster) {
57
57
  case 4: return transformToSet(event, roster);
58
58
  case 5: return transformToSpike(event, roster);
59
59
  case 6: return transformToBlock(event, roster);
60
+ case 7: return transformToFreeBall(event, roster);
60
61
  default: throw new Error(`UNKNOWN_EVENT_TYPE: ${String(event.e)}`);
61
62
  }
62
63
  }
@@ -69,6 +70,7 @@ function decodeLegacyEvent(event) {
69
70
  case 4: return VolleySet.create({ failure: event.f, type: event.t, playerId: event.p, target: event.a, score: event.s });
70
71
  case 5: return Spike.create({ failure: event.f, type: event.t, playerId: event.p, target: event.a, score: event.s });
71
72
  case 6: return Block.create({ failure: event.f, type: event.t, playerId: event.p, target: event.a, blockers: event.b ?? [], score: event.s });
73
+ case 7: return FreeBall.create({ failure: event.f, type: event.t, playerId: event.p, target: event.a, score: event.s });
72
74
  default: throw new Error(`UNKNOWN_EVENT_TYPE: ${String(event.e)}`);
73
75
  }
74
76
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { Block, BlockFailureEnum, BlockTypeEnum, EventTypeEnum, FreeBall, FreeBallFailureEnum, FreeBallTypeEnum, LiberoReplacement, LiberoReplacementTypeEnum, Rally, Reception, ReceptionFailureEnum, ReceptionTypeEnum, Serve, ServeFailureEnum, ServeTypeEnum, Set as VolleySet, SetFailureEnum, SetTempoEnum, SetTypeEnum, Spike, SpikeFailureEnum, SpikeTypeEnum, Substitution } from '../../service';
4
+ import { transformFromRally, transformToRally } from './rally';
5
+ // End-to-end rally round trip (encode -> bytea -> decode) exercising the decodeCompactEvent switch, with one
6
+ // event of EVERY EventTypeEnum member. This is the guard that would have caught the FREE_BALL decoder gap: the
7
+ // encoder is generic (out.e = evt.eventType) but the decoder is a hardcoded switch, so a new event type that is
8
+ // not wired into the switch throws UNKNOWN_EVENT_TYPE here. The coverage test forces new members into this list.
9
+ describe('rally compact round trip — every event type', () => {
10
+ const pA = uuidv4();
11
+ const pB = uuidv4();
12
+ const pC = uuidv4();
13
+ const setId = uuidv4();
14
+ const events = [
15
+ LiberoReplacement.create({ playerId: pA, libero: pB, type: LiberoReplacementTypeEnum.LIBERO_IN }),
16
+ Substitution.create({ playerId: pA, playerOut: pC, injury: true }),
17
+ Serve.create({ playerId: pA, target: 6, score: 55, failure: ServeFailureEnum.NO_FAILURE, type: ServeTypeEnum.JUMP_TOPSPIN }),
18
+ Reception.create({ playerId: pB, target: 5, score: 50, failure: ReceptionFailureEnum.NO_FAILURE, type: ReceptionTypeEnum.DIG }),
19
+ VolleySet.create({ playerId: pC, target: 3, score: 48, failure: SetFailureEnum.NO_FAILURE, type: SetTypeEnum.OVERHAND, tempo: SetTempoEnum.FIRST }),
20
+ Spike.create({ playerId: pA, target: 4, score: 62.5, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.CROSS_SHOT }),
21
+ Block.create({ playerId: pB, blockers: [pB, pC], target: 8, score: 48, failure: BlockFailureEnum.NO_FAILURE, type: BlockTypeEnum.DOUBLE }),
22
+ FreeBall.create({ playerId: pC, target: 6, score: 45, failure: FreeBallFailureEnum.NO_FAILURE, type: FreeBallTypeEnum.OVER })
23
+ ];
24
+ const rally = Rally.create({ id: uuidv4(), order: 0, servingTeamId: uuidv4(), events });
25
+ it('covers every EventTypeEnum member (adding a new event type must extend this list)', () => {
26
+ const numericTypes = Object.values(EventTypeEnum).filter((v) => typeof v === 'number');
27
+ const present = new Set(events.map(e => e.eventType));
28
+ for (const t of numericTypes)
29
+ expect(present.has(t)).toBe(true);
30
+ expect(present.size).toBe(numericTypes.length);
31
+ });
32
+ it('encodes then decodes every event type back to an equal event (guards the decoder switch)', () => {
33
+ const attrs = transformFromRally(rally, setId);
34
+ const decoded = transformToRally(attrs);
35
+ expect(decoded.events).toHaveLength(events.length);
36
+ expect(decoded.events.map(e => e.eventType)).toEqual(events.map(e => e.eventType));
37
+ const [lib, sub, serve, rec, set, spike, block, free] = decoded.events;
38
+ expect(lib.playerId).toBe(pA);
39
+ expect(lib.libero).toBe(pB);
40
+ expect(lib.type).toBe(LiberoReplacementTypeEnum.LIBERO_IN);
41
+ expect(sub.playerOut).toBe(pC);
42
+ expect(sub.injury).toBe(true);
43
+ expect(serve.target).toBe(6);
44
+ expect(serve.score).toBe(55);
45
+ expect(serve.type).toBe(ServeTypeEnum.JUMP_TOPSPIN);
46
+ expect(rec.target).toBe(5);
47
+ expect(rec.type).toBe(ReceptionTypeEnum.DIG);
48
+ expect(set.type).toBe(SetTypeEnum.OVERHAND);
49
+ expect(set.tempo).toBe(SetTempoEnum.FIRST);
50
+ expect(spike.type).toBe(SpikeTypeEnum.CROSS_SHOT);
51
+ expect(spike.score).toBe(62.5);
52
+ expect(block.type).toBe(BlockTypeEnum.DOUBLE);
53
+ expect(block.blockers).toEqual([pB, pC]);
54
+ expect(free.playerId).toBe(pC);
55
+ expect(free.target).toBe(6);
56
+ expect(free.score).toBe(45);
57
+ expect(free.type).toBe(FreeBallTypeEnum.OVER);
58
+ expect(free.failure).toBe(FreeBallFailureEnum.NO_FAILURE);
59
+ });
60
+ });
@@ -1,22 +1,7 @@
1
- import { CourtPosition, Tactics } from '../../service';
2
- // Back-compat: new rows persist `mode`; rows written before the mode change persist `isPinchServer` (+ a
3
- // 'NEVER' fatigueBand for the old "never fatigue-sub" state). Derive the mode so existing data keeps working
4
- // until the next tactics save rewrites it in the new shape.
5
- function subModeFromAttributes(d) {
6
- if (d.mode != null)
7
- return d.mode;
8
- if (d.isPinchServer === true)
9
- return 'PINCH';
10
- if (d.fatigueBand === 'NEVER')
11
- return 'NEVER';
12
- return 'FATIGUE';
13
- }
14
- // pinchServer only applies to PINCH ("Conditions") mode. A LEGACY PINCH row (no flag) defaults to true so
15
- // existing pinch servers keep serve-and-return behavior; a new conditional sub persists an explicit false. This
16
- // default must resolve identically on every READ path (both transformToObject and tacticsColumnsToApiDto).
17
- function effectivePinchServer(mode, pinchServer) {
18
- return mode === 'PINCH' ? (pinchServer ?? true) : undefined;
19
- }
1
+ import { convertLegacyLiberoSubConfig, convertLegacySetSubConfig, CourtPosition, Tactics } from '../../service';
2
+ // Unified condition engine (owner 2026-07-11): the READ path passes legacy fields (mode / fatigueBand /
3
+ // isPinchServer) through RAW; the schema preprocess in Tactics.create converts every legacy shape (see
4
+ // service/team/sub-config-convert.ts). The WRITE path persists only the unified shape.
20
5
  function findPlayer(id, roster) {
21
6
  const player = roster.find((p) => p.id === id);
22
7
  if (player == null)
@@ -60,23 +45,19 @@ function transformToAttributes(tactics, teamId) {
60
45
  designated_subs: tactics.designatedSubs.map(ds => ({
61
46
  starterId: ds.starter.id,
62
47
  benchId: ds.bench?.id,
63
- mode: ds.mode,
64
- fatigueBand: ds.fatigueBand,
65
- benchFatigueBand: ds.benchFatigueBand,
66
48
  conditions: ds.conditions,
67
49
  conditionLogic: ds.conditionLogic,
68
50
  pinchServer: ds.pinchServer,
51
+ benchFatigueBand: ds.benchFatigueBand,
69
52
  subBackMode: ds.subBackMode,
70
53
  subBackConditions: ds.subBackConditions,
71
54
  subBackConditionLogic: ds.subBackConditionLogic,
72
55
  sets: ds.sets?.map(s => ({
73
- mode: s.mode,
74
56
  benchId: s.bench?.id,
75
- fatigueBand: s.fatigueBand,
76
- benchFatigueBand: s.benchFatigueBand,
77
57
  conditions: s.conditions,
78
58
  conditionLogic: s.conditionLogic,
79
59
  pinchServer: s.pinchServer,
60
+ benchFatigueBand: s.benchFatigueBand,
80
61
  subBackMode: s.subBackMode,
81
62
  subBackConditions: s.subBackConditions,
82
63
  subBackConditionLogic: s.subBackConditionLogic
@@ -159,15 +140,19 @@ function transformToObject(model, roster) {
159
140
  secondLibero,
160
141
  liberoSub: secondLibero != null ? (model.libero_sub ?? undefined) : undefined,
161
142
  liberoReplacementSets,
143
+ // Legacy fields (mode / fatigueBand / isPinchServer) pass through RAW: the DesignatedSubSchema preprocess
144
+ // inside Tactics.create converts every legacy shape (mode-era AND isPinchServer-era rows) to the unified
145
+ // condition engine. Only the player references are resolved here.
162
146
  designatedSubs: (model.designated_subs ?? []).map(d => ({
163
147
  starter: findPlayer(d.starterId, roster),
164
148
  bench: d.benchId != null ? findPlayer(d.benchId, roster) : undefined,
165
- mode: subModeFromAttributes(d),
166
- fatigueBand: d.fatigueBand === 'NEVER' ? undefined : d.fatigueBand,
149
+ mode: d.mode,
150
+ isPinchServer: d.isPinchServer,
151
+ fatigueBand: d.fatigueBand,
167
152
  benchFatigueBand: d.benchFatigueBand,
168
153
  conditions: d.conditions,
169
154
  conditionLogic: d.conditionLogic,
170
- pinchServer: effectivePinchServer(subModeFromAttributes(d), d.pinchServer),
155
+ pinchServer: d.pinchServer,
171
156
  subBackMode: d.subBackMode,
172
157
  subBackConditions: d.subBackConditions,
173
158
  subBackConditionLogic: d.subBackConditionLogic,
@@ -178,7 +163,7 @@ function transformToObject(model, roster) {
178
163
  benchFatigueBand: s.benchFatigueBand,
179
164
  conditions: s.conditions,
180
165
  conditionLogic: s.conditionLogic,
181
- pinchServer: effectivePinchServer(s.mode, s.pinchServer),
166
+ pinchServer: s.pinchServer,
182
167
  subBackMode: s.subBackMode,
183
168
  subBackConditions: s.subBackConditions,
184
169
  subBackConditionLogic: s.subBackConditionLogic
@@ -326,33 +311,20 @@ function tacticsColumnsToApiDto(model) {
326
311
  receiveRotationOffset: model.receive_rotation_offset,
327
312
  substitutionBand: model.substitution_band,
328
313
  secondLibero: validSecondLibero,
329
- liberoSub: validSecondLibero != null ? model.libero_sub : undefined,
314
+ // The API surfaces the UNIFIED condition-engine shape: legacy rows (mode-era / isPinchServer-era) convert
315
+ // here via the same service converters the schema preprocess uses, so clients never see a mode.
316
+ liberoSub: validSecondLibero != null
317
+ ? (model.libero_sub != null ? convertLegacyLiberoSubConfig(model.libero_sub) : undefined)
318
+ : undefined,
330
319
  liberoReplacementSets: validLiberoReplacementSets,
331
- designatedSubs: (model.designated_subs ?? []).map(d => ({
332
- starterId: d.starterId,
333
- benchId: d.benchId,
334
- mode: subModeFromAttributes(d),
335
- fatigueBand: d.fatigueBand === 'NEVER' ? undefined : d.fatigueBand,
336
- benchFatigueBand: d.benchFatigueBand,
337
- conditions: d.conditions,
338
- conditionLogic: d.conditionLogic,
339
- pinchServer: effectivePinchServer(subModeFromAttributes(d), d.pinchServer),
340
- subBackMode: d.subBackMode,
341
- subBackConditions: d.subBackConditions,
342
- subBackConditionLogic: d.subBackConditionLogic,
343
- sets: d.sets?.map(s => ({
344
- mode: s.mode,
345
- benchId: s.benchId,
346
- fatigueBand: s.fatigueBand,
347
- benchFatigueBand: s.benchFatigueBand,
348
- conditions: s.conditions,
349
- conditionLogic: s.conditionLogic,
350
- pinchServer: effectivePinchServer(s.mode, s.pinchServer),
351
- subBackMode: s.subBackMode,
352
- subBackConditions: s.subBackConditions,
353
- subBackConditionLogic: s.subBackConditionLogic
354
- }))
355
- })),
320
+ designatedSubs: (model.designated_subs ?? []).map(d => {
321
+ const { starterId, sets, ...cfg } = d;
322
+ return {
323
+ ...convertLegacySetSubConfig(cfg),
324
+ starterId,
325
+ sets: sets?.map(s => convertLegacySetSubConfig(s))
326
+ };
327
+ }),
356
328
  rotationSystem: model.rotation_system,
357
329
  designatedSetters: model.designated_setters,
358
330
  offensivePreferences: model.offensive_preferences,
@@ -38,25 +38,28 @@ describe('tacticsColumnsToApiDto()', () => {
38
38
  expect(dto.lineup.bench).toEqual(['b1', 'b2']);
39
39
  expect(dto.offensivePreferences).toEqual([{ rotation: 1, weights: { lf: 60, rf: 40 } }]);
40
40
  });
41
- it("resolves a legacy isPinchServer entry to mode 'PINCH'", () => {
41
+ it('converts a legacy isPinchServer entry to a unified pinch server (conditions kept, no mode)', () => {
42
42
  const dto = tacticsColumnsToApiDto(makeTactics([
43
43
  { starterId: 's1', benchId: 'b1', isPinchServer: true, conditions: [{ type: 'ASAP' }] }
44
44
  ]));
45
- expect(dto.designatedSubs[0].mode).toBe('PINCH');
45
+ expect(dto.designatedSubs[0].pinchServer).toBe(true);
46
+ expect(dto.designatedSubs[0].conditions).toEqual([{ type: 'ASAP' }]);
47
+ expect(dto.designatedSubs[0].mode).toBeUndefined();
46
48
  });
47
- it("resolves a legacy 'NEVER' fatigueBand to mode NEVER and drops the band", () => {
49
+ it("converts a legacy 'NEVER' fatigueBand to an explicit empty condition list", () => {
48
50
  const dto = tacticsColumnsToApiDto(makeTactics([
49
51
  { starterId: 's1', fatigueBand: 'NEVER' }
50
52
  ]));
51
- expect(dto.designatedSubs[0].mode).toBe('NEVER');
53
+ expect(dto.designatedSubs[0].conditions).toEqual([]);
52
54
  expect(dto.designatedSubs[0].fatigueBand).toBeUndefined();
53
55
  });
54
- it('passes a fatigue entry with its own bands through unchanged', () => {
56
+ it('converts a legacy fatigue entry to a FATIGUE condition carrying its band', () => {
55
57
  const dto = tacticsColumnsToApiDto(makeTactics([
56
58
  { starterId: 's1', benchId: 'b1', mode: 'FATIGUE', fatigueBand: EnergyBand.WORN, benchFatigueBand: EnergyBand.TIRED }
57
59
  ]));
58
- expect(dto.designatedSubs[0].mode).toBe('FATIGUE');
59
- expect(dto.designatedSubs[0].fatigueBand).toBe(EnergyBand.WORN);
60
+ expect(dto.designatedSubs[0].conditions).toEqual([{ type: 'FATIGUE', band: EnergyBand.WORN }]);
61
+ expect(dto.designatedSubs[0].subBackMode).toBe('FATIGUE');
62
+ expect(dto.designatedSubs[0].pinchServer).toBe(false);
60
63
  expect(dto.designatedSubs[0].benchFatigueBand).toBe(EnergyBand.TIRED);
61
64
  });
62
65
  it('defaults a legacy PINCH row (no pinchServer flag) to pinchServer true', () => {
@@ -65,11 +68,12 @@ describe('tacticsColumnsToApiDto()', () => {
65
68
  ]));
66
69
  expect(dto.designatedSubs[0].pinchServer).toBe(true);
67
70
  });
68
- it('leaves pinchServer undefined for a non-PINCH row', () => {
71
+ it('passes an already-unified entry through untouched', () => {
69
72
  const dto = tacticsColumnsToApiDto(makeTactics([
70
- { starterId: 's1', benchId: 'b1', mode: 'FATIGUE', fatigueBand: EnergyBand.TIRED }
73
+ { starterId: 's1', benchId: 'b1', conditions: [{ type: 'FATIGUE', band: EnergyBand.TIRED }], conditionLogic: 'ALL', pinchServer: false, subBackMode: 'FATIGUE' }
71
74
  ]));
72
- expect(dto.designatedSubs[0].pinchServer).toBeUndefined();
75
+ expect(dto.designatedSubs[0].conditions).toEqual([{ type: 'FATIGUE', band: EnergyBand.TIRED }]);
76
+ expect(dto.designatedSubs[0].pinchServer).toBe(false);
73
77
  });
74
78
  it('passes a conditional regular sub (pinchServer false + condition sub-back) through unchanged', () => {
75
79
  const dto = tacticsColumnsToApiDto(makeTactics([
@@ -106,7 +110,8 @@ describe('tacticsColumnsToApiDto()', () => {
106
110
  model.libero_sub = { mode: 'FATIGUE' };
107
111
  const dto = tacticsColumnsToApiDto(model);
108
112
  expect(dto.secondLibero).toBe('reserve1');
109
- expect(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
113
+ // Legacy FATIGUE libero rule converts to the unified condition shape on the API read path.
114
+ expect(dto.liberoSub).toEqual({ conditions: [{ type: 'FATIGUE' }], conditionLogic: 'ALL' });
110
115
  });
111
116
  it('keeps a libero-keyed replaceKnockedImmediately entry when a second libero exists (L2-only feature)', () => {
112
117
  const model = makeTactics([]);
@@ -1,15 +1,13 @@
1
1
  import { Player } from '../player';
2
- import { EnergyBand, SubMode } from './energy-band';
2
+ import { EnergyBand } from './energy-band';
3
3
  import { ConditionLogic, PinchCondition } from './pinch-condition';
4
4
  export type SubBackMode = 'FATIGUE' | 'CONDITIONS';
5
5
  export interface SetSubConfig {
6
- readonly mode: SubMode;
7
6
  readonly bench?: Player;
8
- readonly fatigueBand?: EnergyBand;
9
- readonly benchFatigueBand?: EnergyBand;
10
7
  readonly conditions?: PinchCondition[];
11
8
  readonly conditionLogic?: ConditionLogic;
12
9
  readonly pinchServer?: boolean;
10
+ readonly benchFatigueBand?: EnergyBand;
13
11
  readonly subBackMode?: SubBackMode;
14
12
  readonly subBackConditions?: PinchCondition[];
15
13
  readonly subBackConditionLogic?: ConditionLogic;
@@ -7,6 +7,7 @@ export * from './energy-band';
7
7
  export * from './pinch-condition';
8
8
  export * from './designated-sub';
9
9
  export * from './libero-sub';
10
+ export * from './sub-config-convert';
10
11
  export * from './rotation-system';
11
12
  export * from './lineup-function';
12
13
  export * from './base-position';
@@ -7,6 +7,7 @@ export * from './energy-band';
7
7
  export * from './pinch-condition';
8
8
  export * from './designated-sub';
9
9
  export * from './libero-sub';
10
+ export * from './sub-config-convert';
10
11
  export * from './rotation-system';
11
12
  export * from './lineup-function';
12
13
  export * from './base-position';
@@ -1,9 +1,5 @@
1
- import { EnergyBand } from './energy-band';
2
1
  import { ConditionLogic, PinchCondition } from './pinch-condition';
3
- export type LiberoSubMode = 'NEVER' | 'FATIGUE' | 'ALWAYS' | 'CONDITIONS';
4
2
  export interface LiberoSetSubConfig {
5
- readonly mode: LiberoSubMode;
6
- readonly fatigueBand?: EnergyBand;
7
3
  readonly conditions?: PinchCondition[];
8
4
  readonly conditionLogic?: ConditionLogic;
9
5
  }
@@ -1,5 +1,6 @@
1
+ import { EnergyBand, SubBand } from './energy-band';
1
2
  export declare enum PinchConditionType {
2
- ASAP = "ASAP",// every time the linked starter wins back the serve
3
+ ASAP = "ASAP",// unconditionally true (pinch server: every serve gain; libero rule: "Always")
3
4
  TEAM_SET_POINT = "TEAM_SET_POINT",// team within `margin` of winning the set
4
5
  OPPONENT_SET_POINT = "OPPONENT_SET_POINT",// opponent within `margin` of winning the set
5
6
  SCORE_DIFF = "SCORE_DIFF",// team trailing/leading by at least `points`
@@ -7,7 +8,8 @@ export declare enum PinchConditionType {
7
8
  FROM_SET = "FROM_SET",// only set N or later
8
9
  MIN_OWN_SCORE = "MIN_OWN_SCORE",// only once own score >= N
9
10
  MIN_OPPONENT_SCORE = "MIN_OPPONENT_SCORE",// only once opponent score >= N
10
- PHASE = "PHASE"
11
+ PHASE = "PHASE",// the team is serving or receiving this rally (used by the libero deployment rule)
12
+ FATIGUE = "FATIGUE"
11
13
  }
12
14
  export declare enum MatchPhase {
13
15
  SERVING = "SERVING",
@@ -66,7 +68,11 @@ export interface PhaseCondition {
66
68
  readonly type: PinchConditionType.PHASE;
67
69
  readonly phase: MatchPhase;
68
70
  }
69
- export type PinchCondition = AsapCondition | TeamSetPointCondition | OpponentSetPointCondition | ScoreDiffCondition | AfterRotationsCondition | FromSetCondition | MinOwnScoreCondition | MinOpponentScoreCondition | PhaseCondition;
71
+ export interface FatigueCondition {
72
+ readonly type: PinchConditionType.FATIGUE;
73
+ readonly band?: EnergyBand;
74
+ }
75
+ export type PinchCondition = AsapCondition | TeamSetPointCondition | OpponentSetPointCondition | ScoreDiffCondition | AfterRotationsCondition | FromSetCondition | MinOwnScoreCondition | MinOpponentScoreCondition | PhaseCondition | FatigueCondition;
70
76
  export type ConditionLogic = 'ALL' | 'ANY';
71
77
  export interface PinchSetState {
72
78
  readonly ownScore: number;
@@ -75,4 +81,6 @@ export interface PinchSetState {
75
81
  readonly setNumber: number;
76
82
  readonly ownServeTurns: number;
77
83
  readonly phase: MatchPhase;
84
+ readonly subjectBand?: EnergyBand;
85
+ readonly defaultBand?: SubBand;
78
86
  }
@@ -1,6 +1,7 @@
1
- // Conditions that gate when a pinch server is forced in to serve (in for its linked starter when the team
2
- // wins back the serve, out the moment the serve is lost). Each condition is evaluated against the live set
3
- // state; a pinch entry combines them with ALL (every condition must hold) or ANY (one is enough).
1
+ // The ONE condition vocabulary of the substitution engine (owner 2026-07-11): designated substitutions,
2
+ // pinch servers, sub-backs, and the libero deployment rule all evaluate the same catalog against the live
3
+ // set state, combined with ALL (every condition must hold) or ANY (one is enough). A config with NO
4
+ // conditions never fires.
4
5
  export var PinchConditionType;
5
6
  (function (PinchConditionType) {
6
7
  PinchConditionType["ASAP"] = "ASAP";
@@ -11,7 +12,11 @@ export var PinchConditionType;
11
12
  PinchConditionType["FROM_SET"] = "FROM_SET";
12
13
  PinchConditionType["MIN_OWN_SCORE"] = "MIN_OWN_SCORE";
13
14
  PinchConditionType["MIN_OPPONENT_SCORE"] = "MIN_OPPONENT_SCORE";
14
- PinchConditionType["PHASE"] = "PHASE"; // the team is serving or receiving this rally (used by the libero deployment rule)
15
+ PinchConditionType["PHASE"] = "PHASE";
16
+ // The SUBJECT player's energy is at-or-worse than `band` (absent band = the team `substitutionBand`). The
17
+ // subject comes from the evaluation context, never from the condition: sub-in conditions judge the STARTER
18
+ // on court, sub-back conditions judge the SUBSTITUTE on court, the libero rule judges the STARTING libero.
19
+ PinchConditionType["FATIGUE"] = "FATIGUE";
15
20
  })(PinchConditionType || (PinchConditionType = {}));
16
21
  // Which side of the rally the team is on. Known at each dead ball: the serving team is SERVING, the other
17
22
  // RECEIVING. Consumed by the PHASE condition (libero deployment); pinch servers always evaluate while SERVING.
@@ -34,26 +34,17 @@ export declare const PinchConditionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
34
34
  }, z.core.$strip>, z.ZodObject<{
35
35
  type: z.ZodLiteral<PinchConditionType.PHASE>;
36
36
  phase: z.ZodEnum<typeof MatchPhase>;
37
+ }, z.core.$strip>, z.ZodObject<{
38
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
39
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
37
40
  }, z.core.$strip>], "type">;
38
41
  export declare const ConditionLogicSchema: z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>;
39
- export declare const SubModeSchema: z.ZodEnum<{
40
- NEVER: "NEVER";
41
- FATIGUE: "FATIGUE";
42
- PINCH: "PINCH";
43
- }>;
44
42
  export declare const SubBackModeSchema: z.ZodEnum<{
45
43
  FATIGUE: "FATIGUE";
46
44
  CONDITIONS: "CONDITIONS";
47
45
  }>;
48
- export declare const SetSubConfigSchema: z.ZodObject<{
49
- mode: z.ZodEnum<{
50
- NEVER: "NEVER";
51
- FATIGUE: "FATIGUE";
52
- PINCH: "PINCH";
53
- }>;
46
+ export declare const SetSubConfigSchema: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodObject<{
54
47
  bench: z.ZodOptional<z.ZodCustom<Player, Player>>;
55
- fatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
56
- benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
57
48
  conditions: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
58
49
  type: z.ZodLiteral<PinchConditionType.ASAP>;
59
50
  }, z.core.$strip>, z.ZodObject<{
@@ -85,9 +76,13 @@ export declare const SetSubConfigSchema: z.ZodObject<{
85
76
  }, z.core.$strip>, z.ZodObject<{
86
77
  type: z.ZodLiteral<PinchConditionType.PHASE>;
87
78
  phase: z.ZodEnum<typeof MatchPhase>;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
81
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
88
82
  }, z.core.$strip>], "type">>>;
89
83
  conditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
90
84
  pinchServer: z.ZodOptional<z.ZodBoolean>;
85
+ benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
91
86
  subBackMode: z.ZodOptional<z.ZodEnum<{
92
87
  FATIGUE: "FATIGUE";
93
88
  CONDITIONS: "CONDITIONS";
@@ -123,19 +118,15 @@ export declare const SetSubConfigSchema: z.ZodObject<{
123
118
  }, z.core.$strip>, z.ZodObject<{
124
119
  type: z.ZodLiteral<PinchConditionType.PHASE>;
125
120
  phase: z.ZodEnum<typeof MatchPhase>;
121
+ }, z.core.$strip>, z.ZodObject<{
122
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
123
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
126
124
  }, z.core.$strip>], "type">>>;
127
125
  subBackConditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
128
- }, z.core.$strip>;
129
- export declare const DesignatedSubSchema: z.ZodObject<{
130
- sets: z.ZodOptional<z.ZodArray<z.ZodObject<{
131
- mode: z.ZodEnum<{
132
- NEVER: "NEVER";
133
- FATIGUE: "FATIGUE";
134
- PINCH: "PINCH";
135
- }>;
126
+ }, z.core.$strip>>;
127
+ export declare const DesignatedSubSchema: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodObject<{
128
+ sets: z.ZodOptional<z.ZodArray<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodObject<{
136
129
  bench: z.ZodOptional<z.ZodCustom<Player, Player>>;
137
- fatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
138
- benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
139
130
  conditions: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
140
131
  type: z.ZodLiteral<PinchConditionType.ASAP>;
141
132
  }, z.core.$strip>, z.ZodObject<{
@@ -167,9 +158,13 @@ export declare const DesignatedSubSchema: z.ZodObject<{
167
158
  }, z.core.$strip>, z.ZodObject<{
168
159
  type: z.ZodLiteral<PinchConditionType.PHASE>;
169
160
  phase: z.ZodEnum<typeof MatchPhase>;
161
+ }, z.core.$strip>, z.ZodObject<{
162
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
163
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
170
164
  }, z.core.$strip>], "type">>>;
171
165
  conditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
172
166
  pinchServer: z.ZodOptional<z.ZodBoolean>;
167
+ benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
173
168
  subBackMode: z.ZodOptional<z.ZodEnum<{
174
169
  FATIGUE: "FATIGUE";
175
170
  CONDITIONS: "CONDITIONS";
@@ -205,17 +200,13 @@ export declare const DesignatedSubSchema: z.ZodObject<{
205
200
  }, z.core.$strip>, z.ZodObject<{
206
201
  type: z.ZodLiteral<PinchConditionType.PHASE>;
207
202
  phase: z.ZodEnum<typeof MatchPhase>;
203
+ }, z.core.$strip>, z.ZodObject<{
204
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
205
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
208
206
  }, z.core.$strip>], "type">>>;
209
207
  subBackConditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
210
- }, z.core.$strip>>>;
211
- mode: z.ZodEnum<{
212
- NEVER: "NEVER";
213
- FATIGUE: "FATIGUE";
214
- PINCH: "PINCH";
215
- }>;
208
+ }, z.core.$strip>>>>;
216
209
  bench: z.ZodOptional<z.ZodCustom<Player, Player>>;
217
- fatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
218
- benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
219
210
  conditions: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
220
211
  type: z.ZodLiteral<PinchConditionType.ASAP>;
221
212
  }, z.core.$strip>, z.ZodObject<{
@@ -247,9 +238,13 @@ export declare const DesignatedSubSchema: z.ZodObject<{
247
238
  }, z.core.$strip>, z.ZodObject<{
248
239
  type: z.ZodLiteral<PinchConditionType.PHASE>;
249
240
  phase: z.ZodEnum<typeof MatchPhase>;
241
+ }, z.core.$strip>, z.ZodObject<{
242
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
243
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
250
244
  }, z.core.$strip>], "type">>>;
251
245
  conditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
252
246
  pinchServer: z.ZodOptional<z.ZodBoolean>;
247
+ benchFatigueBand: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
253
248
  subBackMode: z.ZodOptional<z.ZodEnum<{
254
249
  FATIGUE: "FATIGUE";
255
250
  CONDITIONS: "CONDITIONS";
@@ -285,7 +280,10 @@ export declare const DesignatedSubSchema: z.ZodObject<{
285
280
  }, z.core.$strip>, z.ZodObject<{
286
281
  type: z.ZodLiteral<PinchConditionType.PHASE>;
287
282
  phase: z.ZodEnum<typeof MatchPhase>;
283
+ }, z.core.$strip>, z.ZodObject<{
284
+ type: z.ZodLiteral<PinchConditionType.FATIGUE>;
285
+ band: z.ZodOptional<z.ZodEnum<typeof EnergyBand>>;
288
286
  }, z.core.$strip>], "type">>>;
289
287
  subBackConditionLogic: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"ALL">, z.ZodLiteral<"ANY">]>>;
290
288
  starter: z.ZodCustom<Player, Player>;
291
- }, z.core.$strip>;
289
+ }, z.core.$strip>>;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { Player } from '../../player';
3
3
  import { EnergyBand } from '../energy-band';
4
4
  import { Comparison, MatchPhase, OpponentRelation, PinchConditionType, ScoreDirection } from '../pinch-condition';
5
+ import { convertLegacyDesignatedSub, convertLegacySetSubConfig } from '../sub-config-convert';
5
6
  const playerInstanceSchema = z.custom((v) => v instanceof Player, {
6
7
  message: 'INVALID_PLAYER_INSTANCE'
7
8
  });
@@ -18,44 +19,47 @@ export const PinchConditionSchema = z.discriminatedUnion('type', [
18
19
  z.object({ type: z.literal(PinchConditionType.MIN_OWN_SCORE), score: z.number().int().min(0).max(40), comparison: z.nativeEnum(Comparison).optional() }),
19
20
  z.object({ type: z.literal(PinchConditionType.MIN_OPPONENT_SCORE), score: z.number().int().min(0).max(40), comparison: z.nativeEnum(Comparison).optional() }),
20
21
  // PHASE is used by the libero deployment rule (which reuses this schema); pinch servers never offer it.
21
- z.object({ type: z.literal(PinchConditionType.PHASE), phase: z.nativeEnum(MatchPhase) })
22
+ z.object({ type: z.literal(PinchConditionType.PHASE), phase: z.nativeEnum(MatchPhase) }),
23
+ // The subject player's energy is at-or-worse than `band` (absent = the team `substitutionBand`); the
24
+ // subject comes from the evaluation context (starter / substitute / starting libero).
25
+ z.object({ type: z.literal(PinchConditionType.FATIGUE), band: z.nativeEnum(EnergyBand).optional() })
22
26
  ]);
23
27
  export const ConditionLogicSchema = z.union([z.literal('ALL'), z.literal('ANY')]);
24
- export const SubModeSchema = z.enum(['FATIGUE', 'PINCH', 'NEVER']);
25
28
  export const SubBackModeSchema = z.enum(['FATIGUE', 'CONDITIONS']);
26
- // Fields shared by the all-sets config and each per-set config.
29
+ // Fields shared by the all-sets config and each per-set config. Pure condition engine (owner 2026-07-11):
30
+ // no mode; `conditions` is the sub-in trigger and an EMPTY/absent list means the starter is never substituted
31
+ // by this rule. Legacy mode-shaped inputs are auto-converted by the preprocess below.
27
32
  const setSubConfigShape = {
28
- mode: SubModeSchema,
29
33
  bench: playerInstanceSchema.optional(),
30
- fatigueBand: z.nativeEnum(EnergyBand).optional(),
31
- benchFatigueBand: z.nativeEnum(EnergyBand).optional(),
32
34
  conditions: z.array(PinchConditionSchema).optional(),
33
35
  conditionLogic: ConditionLogicSchema.optional(),
34
- // pinch ("Conditions") mode. true = serve-and-return pinch server; false/absent = regular conditional sub.
36
+ // true = serve-and-return pinch server; false/absent = regular conditional sub (stays until sub-back).
35
37
  pinchServer: z.boolean().optional(),
36
- // a regular conditional sub's sub-back trigger + its condition set (subBackMode === 'CONDITIONS').
38
+ // The substitute's own pull band for FATIGUE sub-backs (absent = the team `substitutionBand`).
39
+ benchFatigueBand: z.nativeEnum(EnergyBand).optional(),
37
40
  subBackMode: SubBackModeSchema.optional(),
38
41
  subBackConditions: z.array(PinchConditionSchema).optional(),
39
42
  subBackConditionLogic: ConditionLogicSchema.optional()
40
43
  };
41
- // Pinch ("Conditions") mode needs a designated bench player and at least one sub-in condition. A regular
42
- // conditional sub (pinchServer !== true) with a CONDITIONS sub-back also needs at least one sub-back condition.
43
- function refinePinch(data, ctx) {
44
- if (data.mode !== 'PINCH')
45
- return;
46
- if (data.bench == null) {
47
- ctx.addIssue({ code: 'custom', message: 'PINCH_SERVER_REQUIRES_BENCH', path: ['bench'] });
48
- }
49
- if (data.conditions == null || data.conditions.length === 0) {
50
- ctx.addIssue({ code: 'custom', message: 'PINCH_SERVER_REQUIRES_CONDITIONS', path: ['conditions'] });
44
+ // A pinch server needs a designated bench player and at least one sub-in condition. A regular conditional sub
45
+ // with a CONDITIONS sub-back needs at least one sub-back condition. Empty sub-in conditions are otherwise
46
+ // VALID (= never fires).
47
+ function refineSubConfig(data, ctx) {
48
+ if (data.pinchServer === true) {
49
+ if (data.bench == null) {
50
+ ctx.addIssue({ code: 'custom', message: 'PINCH_SERVER_REQUIRES_BENCH', path: ['bench'] });
51
+ }
52
+ if (data.conditions == null || data.conditions.length === 0) {
53
+ ctx.addIssue({ code: 'custom', message: 'PINCH_SERVER_REQUIRES_CONDITIONS', path: ['conditions'] });
54
+ }
51
55
  }
52
56
  if (data.pinchServer !== true && data.subBackMode === 'CONDITIONS' &&
53
57
  (data.subBackConditions == null || data.subBackConditions.length === 0)) {
54
58
  ctx.addIssue({ code: 'custom', message: 'CONDITIONAL_SUB_BACK_REQUIRES_CONDITIONS', path: ['subBackConditions'] });
55
59
  }
56
60
  }
57
- export const SetSubConfigSchema = z.object(setSubConfigShape).superRefine(refinePinch);
58
- export const DesignatedSubSchema = z.object({
61
+ export const SetSubConfigSchema = z.preprocess(convertLegacySetSubConfig, z.object(setSubConfigShape).superRefine(refineSubConfig));
62
+ export const DesignatedSubSchema = z.preprocess(convertLegacyDesignatedSub, z.object({
59
63
  starter: playerInstanceSchema,
60
64
  ...setSubConfigShape,
61
65
  // Optional per-set overrides (index 0 = set 1 ... index 4 = set 5); absent => the flat config for all sets.
@@ -64,5 +68,5 @@ export const DesignatedSubSchema = z.object({
64
68
  // The flat config is the fallback for any set a partial `sets` array doesn't cover, so validate it whenever
65
69
  // `sets` is absent or covers fewer than 5 sets (each present per-set config is validated by SetSubConfigSchema).
66
70
  if (data.sets == null || data.sets.length < 5)
67
- refinePinch(data, ctx);
68
- });
71
+ refineSubConfig(data, ctx);
72
+ }));
@@ -114,12 +114,14 @@ describe('DesignatedSubSchema', () => {
114
114
  });
115
115
  expect(res.success).toBe(true);
116
116
  });
117
- it('rejects an unknown mode', () => {
117
+ it('heals an unknown legacy mode by stripping it (lenient conversion, config stays valid)', () => {
118
118
  const res = DesignatedSubSchema.safeParse({
119
119
  starter,
120
120
  mode: 'SOMETIMES'
121
121
  });
122
- expect(res.success).toBe(false);
122
+ expect(res.success).toBe(true);
123
+ if (res.success)
124
+ expect(res.data.mode).toBeUndefined();
123
125
  });
124
126
  it('accepts a pinch-mode entry with a bench server and conditions', () => {
125
127
  const res = DesignatedSubSchema.safeParse({