volleyballsimtypes 0.0.514 → 0.0.516

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 (43) hide show
  1. package/dist/cjs/src/api/index.d.ts +1 -2
  2. package/dist/cjs/src/api/tactics-player-refs.js +2 -2
  3. package/dist/cjs/src/data/models/tactics.d.ts +2 -4
  4. package/dist/cjs/src/data/models/tactics.js +0 -5
  5. package/dist/cjs/src/data/transformers/tactics.js +4 -36
  6. package/dist/cjs/src/data/transformers/tactics.test.js +9 -46
  7. package/dist/cjs/src/service/team/attack-priority.d.ts +3 -0
  8. package/dist/cjs/src/service/team/attack-priority.js +11 -0
  9. package/dist/cjs/src/service/team/base-config.d.ts +2 -0
  10. package/dist/cjs/src/service/team/default-base-config.js +59 -3
  11. package/dist/cjs/src/service/team/default-base-config.test.d.ts +1 -0
  12. package/dist/cjs/src/service/team/default-base-config.test.js +44 -0
  13. package/dist/cjs/src/service/team/index.d.ts +1 -0
  14. package/dist/cjs/src/service/team/index.js +1 -0
  15. package/dist/cjs/src/service/team/offensive-preference.d.ts +2 -1
  16. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +19 -5
  17. package/dist/cjs/src/service/team/schemas/tactics.z.js +32 -33
  18. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +25 -92
  19. package/dist/cjs/src/service/team/schemas/team.z.d.ts +19 -5
  20. package/dist/cjs/src/service/team/tactics.d.ts +0 -2
  21. package/dist/cjs/src/service/team/tactics.js +1 -2
  22. package/dist/esm/src/api/index.d.ts +1 -2
  23. package/dist/esm/src/api/tactics-player-refs.js +2 -2
  24. package/dist/esm/src/data/models/tactics.d.ts +2 -4
  25. package/dist/esm/src/data/models/tactics.js +0 -5
  26. package/dist/esm/src/data/transformers/tactics.js +4 -36
  27. package/dist/esm/src/data/transformers/tactics.test.js +9 -46
  28. package/dist/esm/src/service/team/attack-priority.d.ts +3 -0
  29. package/dist/esm/src/service/team/attack-priority.js +8 -0
  30. package/dist/esm/src/service/team/base-config.d.ts +2 -0
  31. package/dist/esm/src/service/team/default-base-config.js +59 -3
  32. package/dist/esm/src/service/team/default-base-config.test.d.ts +1 -0
  33. package/dist/esm/src/service/team/default-base-config.test.js +42 -0
  34. package/dist/esm/src/service/team/index.d.ts +1 -0
  35. package/dist/esm/src/service/team/index.js +1 -0
  36. package/dist/esm/src/service/team/offensive-preference.d.ts +2 -1
  37. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +19 -5
  38. package/dist/esm/src/service/team/schemas/tactics.z.js +32 -33
  39. package/dist/esm/src/service/team/schemas/tactics.z.test.js +25 -92
  40. package/dist/esm/src/service/team/schemas/team.z.d.ts +19 -5
  41. package/dist/esm/src/service/team/tactics.d.ts +0 -2
  42. package/dist/esm/src/service/team/tactics.js +1 -2
  43. package/package.json +1 -1
@@ -24,7 +24,7 @@ function makeTactics(designatedSubs) {
24
24
  libero_replacement_sets: undefined,
25
25
  rotation_system: '5-1',
26
26
  designated_setters: ['lf'],
27
- offensive_preferences: [{ rotation: 1, weights: { lf: 60, rf: 40 } }],
27
+ offensive_preferences: [{ rotation: 1, priorities: { lf: 'HIGH', rf: 'MID' } }],
28
28
  blocking_preferences: []
29
29
  };
30
30
  }
@@ -36,7 +36,7 @@ describe('tacticsColumnsToApiDto()', () => {
36
36
  expect(dto.rotationSystem).toBe('5-1');
37
37
  expect(dto.liberoReplacements).toEqual(['lf']);
38
38
  expect(dto.lineup.bench).toEqual(['b1', 'b2']);
39
- expect(dto.offensivePreferences).toEqual([{ rotation: 1, weights: { lf: 60, rf: 40 } }]);
39
+ expect(dto.offensivePreferences).toEqual([{ rotation: 1, priorities: { lf: 'HIGH', rf: 'MID' } }]);
40
40
  });
41
41
  it('converts a legacy isPinchServer entry to a unified pinch server (conditions kept, no mode)', () => {
42
42
  const dto = tacticsColumnsToApiDto(makeTactics([
@@ -131,32 +131,16 @@ describe('tacticsColumnsToApiDto()', () => {
131
131
  it('passes per-set tactics columns through to the API DTO shape', () => {
132
132
  const model = makeTactics([]);
133
133
  model.system_sets = [{ rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
134
- model.offensive_preference_sets = [[{ rotation: 1, weights: { rf: 55, mf: 45 } }]];
134
+ model.offensive_preference_sets = [[{ rotation: 1, priorities: { rf: 'HIGH', mf: 'MID' } }]];
135
135
  const dto = tacticsColumnsToApiDto(model);
136
136
  expect(dto.systemSets).toEqual([{ rotationSystem: '5-1', designatedSetters: ['lf'] }]);
137
- expect(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, weights: { rf: 55, mf: 45 } }]]);
137
+ expect(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, priorities: { rf: 'HIGH', mf: 'MID' } }]]);
138
138
  });
139
139
  it('leaves the per-set DTO fields undefined when the columns are absent', () => {
140
140
  const dto = tacticsColumnsToApiDto(makeTactics([]));
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
- });
160
144
  });
161
145
  describe('per-set tactics — service <-> row round-trip', () => {
162
146
  const country = makeCountry();
@@ -174,7 +158,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
174
158
  { rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: [p1] },
175
159
  { rotationSystem: RotationSystemEnum.SIX_ZERO, designatedSetters: [] }
176
160
  ],
177
- offensivePreferenceSets: [[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]
161
+ offensivePreferenceSets: [[{ rotation: 1, priorities: { [p2.id]: 'HIGH', [p3.id]: 'MID' } }]]
178
162
  });
179
163
  it('transformFromTactics writes the per-set overrides as id-based jsonb columns', () => {
180
164
  const attrs = transformFromTactics(tactics, 'team-1');
@@ -182,7 +166,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
182
166
  { rotationSystem: '5-1', designatedSetters: [p1.id] },
183
167
  { rotationSystem: '6-0', designatedSetters: [] }
184
168
  ]);
185
- expect(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]);
169
+ expect(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, priorities: { [p2.id]: 'HIGH', [p3.id]: 'MID' } }]]);
186
170
  });
187
171
  it('transformToTactics resolves the stored ids back to roster players', () => {
188
172
  const attrs = transformFromTactics(tactics, 'team-1');
@@ -190,7 +174,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
190
174
  expect(back.systemSets?.[0].rotationSystem).toBe(RotationSystemEnum.FIVE_ONE);
191
175
  expect(back.systemSets?.[0].designatedSetters[0].id).toBe(p1.id);
192
176
  expect(back.systemSets?.[1].designatedSetters).toEqual([]);
193
- expect(back.offensivePreferenceSets?.[0][0].weights).toEqual({ [p2.id]: 60, [p3.id]: 40 });
177
+ expect(back.offensivePreferenceSets?.[0][0].priorities).toEqual({ [p2.id]: 'HIGH', [p3.id]: 'MID' });
194
178
  });
195
179
  it('omits the per-set columns entirely for an all-sets config', () => {
196
180
  const flat = Tactics.create({
@@ -204,27 +188,6 @@ describe('per-set tactics — service <-> row round-trip', () => {
204
188
  expect(back.systemSets).toBeUndefined();
205
189
  expect(back.offensivePreferenceSets).toBeUndefined();
206
190
  });
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
- });
228
191
  it('round-trips base positions (coords + reception participation)', () => {
229
192
  const config = {
230
193
  coords: { RECEIVE: { 1: { 5: { d: 5.5, r: -3 }, 6: { d: 6.5, r: 0 } } }, DEFENCE: { 3: { 2: { d: 0.9, r: 3 } } } },
@@ -253,11 +216,11 @@ describe('per-set tactics — service <-> row round-trip', () => {
253
216
  const withTempos = Tactics.create({
254
217
  lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
255
218
  liberoReplacements: [],
256
- offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
219
+ offensivePreferences: [{ rotation: 1, priorities: { [p2.id]: 'HIGH', [p3.id]: 'MID' }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
257
220
  });
258
221
  const attrs = transformFromTactics(withTempos, 'team-1');
259
222
  expect(attrs.offensive_preferences).toEqual([
260
- { rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
223
+ { rotation: 1, priorities: { [p2.id]: 'HIGH', [p3.id]: 'MID' }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
261
224
  ]);
262
225
  const back = transformToTactics(attrs, roster);
263
226
  expect(back.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
@@ -0,0 +1,3 @@
1
+ export type AttackPriority = 'HIGH' | 'MID' | 'LOW' | 'NONE';
2
+ export declare const ATTACK_PRIORITIES: readonly AttackPriority[];
3
+ export declare const PRIORITY_WEIGHT: Record<AttackPriority, number>;
@@ -0,0 +1,8 @@
1
+ export const ATTACK_PRIORITIES = ['HIGH', 'MID', 'LOW', 'NONE'];
2
+ // The relative pull of each priority. Tunable in one place; the engine reads these to weight the setter's target.
3
+ export const PRIORITY_WEIGHT = {
4
+ HIGH: 20,
5
+ MID: 10,
6
+ LOW: 5,
7
+ NONE: 0
8
+ };
@@ -7,5 +7,7 @@ export interface BaseCoord {
7
7
  export interface BaseConfig {
8
8
  readonly coords: Record<string, Record<string, Record<string, BaseCoord>>>;
9
9
  readonly receive: Record<string, Record<string, boolean>>;
10
+ readonly receiveByPlayer?: Record<string, Record<string, boolean>>;
10
11
  readonly blocking?: Record<string, Record<string, Record<string, boolean>>>;
12
+ readonly attackContact?: Record<string, Record<string, BaseCoord>>;
11
13
  }
@@ -84,11 +84,40 @@ const COORDS = {
84
84
  6: { 1: { d: 5.35, r: 2.35 }, 2: { d: 2.05, r: 2.1 }, 3: { d: 0.8, r: 4 }, 4: { d: 2.1, r: -1.95 }, 5: { d: 4.9, r: -1.4 }, 6: { d: 0.4, r: 1.3 } }
85
85
  }
86
86
  };
87
+ // Per-attacker attack subbases (owner 2026-08-12): the point-of-attack ATK_L/M/R coverage shapes are replaced by ONE
88
+ // subbase per attacker, keyed by the attacker's ZONE (ATK_Z1..ATK_Z6). Each is the whole-team formation used when the
89
+ // attacker at that zone gets the ball. A FRONT-row zone (2/3/4) reuses its lane's old ATK_L/M/R shape, where the lane
90
+ // is ranked by that zone's ATTACK-base lateral this rotation (leftmost r -> ATK_L, middle -> ATK_M, rightmost -> ATK_R);
91
+ // a BACK-row zone (1/5/6) replicates the ATTACK base. Derived from COORDS so the code default and the migration agree.
92
+ // The old ATK_L/M/R stay in COORDS as the derivation SOURCE but are NOT exposed in the default (below).
93
+ const FRONT_ZONES = [2, 3, 4];
94
+ const BACK_ZONES = [1, 5, 6];
95
+ function defaultAttackSubbases() {
96
+ const cloneFormation = (zones) => {
97
+ const out = {};
98
+ for (const [zone, c] of Object.entries(zones))
99
+ out[zone] = { d: c.d, r: c.r };
100
+ return out;
101
+ };
102
+ const out = { ATK_Z1: {}, ATK_Z2: {}, ATK_Z3: {}, ATK_Z4: {}, ATK_Z5: {}, ATK_Z6: {} };
103
+ for (let rot = 1; rot <= 6; rot++) {
104
+ const ranked = [...FRONT_ZONES].sort((a, b) => COORDS.ATTACK[rot][a].r - COORDS.ATTACK[rot][b].r);
105
+ const laneFor = { [ranked[0]]: 'ATK_L', [ranked[1]]: 'ATK_M', [ranked[2]]: 'ATK_R' };
106
+ for (const z of FRONT_ZONES)
107
+ out['ATK_Z' + String(z)][String(rot)] = cloneFormation(COORDS[laneFor[z]][rot]);
108
+ for (const z of BACK_ZONES)
109
+ out['ATK_Z' + String(z)][String(rot)] = cloneFormation(COORDS.ATTACK[rot]);
110
+ }
111
+ return out;
112
+ }
87
113
  // The default coordinate table as a BaseConfig.coords (jsonb string keys). Shared, readonly; materialized (and so
88
- // serialized) per team on save, so sharing the reference in memory is safe.
114
+ // serialized) per team on save, so sharing the reference in memory is safe. ATK_L/M/R are dropped from the exposed
115
+ // default (replaced by ATK_Z<n>); everything else is copied verbatim from COORDS.
89
116
  const DEFAULT_COORDS = (() => {
90
117
  const out = {};
91
118
  for (const [formation, rotations] of Object.entries(COORDS)) {
119
+ if (formation === 'ATK_L' || formation === 'ATK_M' || formation === 'ATK_R')
120
+ continue; // replaced by ATK_Z<n>
92
121
  out[formation] = {};
93
122
  for (const [rotation, zones] of Object.entries(rotations)) {
94
123
  out[formation][rotation] = {};
@@ -96,8 +125,35 @@ const DEFAULT_COORDS = (() => {
96
125
  out[formation][rotation][zone] = { d: coord.d, r: coord.r };
97
126
  }
98
127
  }
99
- return out;
128
+ return { ...out, ...defaultAttackSubbases() };
100
129
  })();
130
+ // The default desired-contact per attacker (trajectory-set, owner 2026-08-12 decision D9): a near-net hitting point
131
+ // in each attacker's lane, NOT the old "set to the standing dot" location. `contact.d` = a near-net depth for the
132
+ // front row or the pipe depth for the back row; `contact.r` = the attacker's ATTACK-dot lateral clamped to the
133
+ // antenna. Keyed [rotation][zone] -> {d,r}. The near-net depth + antenna are first-cut calibration knobs. The sim
134
+ // does not read this yet (added with the engine phase); storing it here is inert. See plans/trajectory-set-and-approach.md.
135
+ const NEAR_NET_CONTACT_DEPTH = 0.5; // front-row contact, m off the net
136
+ const PIPE_CONTACT_DEPTH = 1.8; // back-row (pipe) contact, m off the net (matches the sim's BACKROW_SET_DEPTH)
137
+ const CONTACT_ANTENNA = 4.3; // keep the contact just inside the sideline / antenna
138
+ // LAZY + memoized (not an eager IIFE): `isBackRow` lives in the match module, which sits in a load cycle with the
139
+ // team barrel, so calling it at module-load time hits an uninitialised import. Computed once on first use, then the
140
+ // shared readonly reference is reused (like DEFAULT_COORDS; serialized per team on save, so sharing is safe).
141
+ let cachedAttackContact;
142
+ function defaultAttackContact() {
143
+ if (cachedAttackContact != null)
144
+ return cachedAttackContact;
145
+ const out = {};
146
+ for (const [rotation, zones] of Object.entries(COORDS.ATTACK)) {
147
+ out[rotation] = {};
148
+ for (const [zone, coord] of Object.entries(zones)) {
149
+ const backRow = isBackRow(Number(zone));
150
+ const r = Math.max(-CONTACT_ANTENNA, Math.min(CONTACT_ANTENNA, coord.r));
151
+ out[rotation][zone] = { d: backRow ? PIPE_CONTACT_DEPTH : NEAR_NET_CONTACT_DEPTH, r };
152
+ }
153
+ }
154
+ cachedAttackContact = out;
155
+ return out;
156
+ }
101
157
  // The owner's corrected per-point-of-attack block map for the 5-1 (exported 2026-08-09), keyed
102
158
  // [DEF_L|DEF_M|DEF_R][rotation][zone] -> canBlock. `false` = that front-row zone sits OUT of the block for an attack
103
159
  // from that side; `true` = reads/joins the wall. Only the front-row zones in play for the rotation are tagged. Same
@@ -163,5 +219,5 @@ export function defaultReceiveMap(system) {
163
219
  // 5-1 uses the owner's exported receive map, the other systems derive their receive map per system.
164
220
  export function defaultBaseConfig(system) {
165
221
  const receive = system === RotationSystemEnum.FIVE_ONE ? DEFAULT_5_1_RECEIVE : defaultReceiveMap(system);
166
- return { coords: DEFAULT_COORDS, receive, blocking: DEFAULT_BLOCKING };
222
+ return { coords: DEFAULT_COORDS, receive, blocking: DEFAULT_BLOCKING, attackContact: defaultAttackContact() };
167
223
  }
@@ -0,0 +1,42 @@
1
+ import { RotationSystemEnum } from './rotation-system';
2
+ import { defaultBaseConfig } from './default-base-config';
3
+ // Back-row rotational zones (right/left/middle back). Front row is {2,3,4}.
4
+ const BACK_ROW = new Set([1, 5, 6]);
5
+ // The trajectory-set default desired-contact (owner D9, 2026-08-12): a near-net hitting point in each attacker's
6
+ // lane, NOT the old set-to-the-standing-dot location. Front row contacts sit ~0.5 m off the net; back row on the
7
+ // ~1.8 m pipe line; the lateral is the ATTACK-dot's r clamped inside the antenna (|r| <= 4.3).
8
+ describe('defaultBaseConfig attackContact (trajectory-set default)', () => {
9
+ const cfg = defaultBaseConfig(RotationSystemEnum.FIVE_ONE);
10
+ it('provides a finite desired contact for every rotation and zone', () => {
11
+ expect(cfg.attackContact).toBeDefined();
12
+ for (let rot = 1; rot <= 6; rot++) {
13
+ for (let zone = 1; zone <= 6; zone++) {
14
+ const c = cfg.attackContact?.[String(rot)]?.[String(zone)];
15
+ expect(c).toBeDefined();
16
+ expect(Number.isFinite(c?.d)).toBe(true);
17
+ expect(Number.isFinite(c?.r)).toBe(true);
18
+ }
19
+ }
20
+ });
21
+ it('places front-row contacts near the net (0.5 m) and back-row on the pipe line (1.8 m)', () => {
22
+ for (let rot = 1; rot <= 6; rot++) {
23
+ for (let zone = 1; zone <= 6; zone++) {
24
+ const c = cfg.attackContact?.[String(rot)]?.[String(zone)];
25
+ expect(c.d).toBeCloseTo(BACK_ROW.has(zone) ? 1.8 : 0.5);
26
+ }
27
+ }
28
+ });
29
+ it('clamps every contact inside the antenna (|r| <= 4.3), even for a wide-pin attack dot', () => {
30
+ for (let rot = 1; rot <= 6; rot++) {
31
+ for (let zone = 1; zone <= 6; zone++) {
32
+ const c = cfg.attackContact?.[String(rot)]?.[String(zone)];
33
+ expect(Math.abs(c.r)).toBeLessThanOrEqual(4.3 + 1e-9);
34
+ }
35
+ }
36
+ });
37
+ it('keeps the contact on the attacker approach-lane side (rotation 1 OH wide dot r=5 -> +4.3)', () => {
38
+ const c = cfg.attackContact?.['1']?.['4'];
39
+ expect(c.d).toBeCloseTo(0.5); // front row, near the net
40
+ expect(c.r).toBeCloseTo(4.3); // clamped from the OH's wide ATTACK dot (r=5), same (positive) side
41
+ });
42
+ });
@@ -3,6 +3,7 @@ export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
5
  export * from './base-config';
6
+ export * from './attack-priority';
6
7
  export * from './default-base-config';
7
8
  export * from './receive-overlap';
8
9
  export * from './team-name';
@@ -3,6 +3,7 @@ export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
5
  export * from './base-config';
6
+ export * from './attack-priority';
6
7
  export * from './default-base-config';
7
8
  export * from './receive-overlap';
8
9
  export * from './team-name';
@@ -1,6 +1,7 @@
1
+ import { AttackPriority } from './attack-priority';
1
2
  export type TempoPreference = 'FAST' | 'HIGH';
2
3
  export interface OffensivePreference {
3
4
  readonly rotation: number;
4
- readonly weights: Record<string, number>;
5
+ readonly priorities: Record<string, AttackPriority>;
5
6
  readonly tempos?: Record<string, TempoPreference>;
6
7
  }
@@ -261,23 +261,32 @@ export declare const TacticsInputSchema: z.ZodObject<{
261
261
  designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
262
262
  offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
263
263
  rotation: z.ZodNumber;
264
- weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
264
+ priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
265
+ HIGH: "HIGH";
266
+ MID: "MID";
267
+ LOW: "LOW";
268
+ NONE: "NONE";
269
+ }>>;
265
270
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
266
- FAST: "FAST";
267
271
  HIGH: "HIGH";
272
+ FAST: "FAST";
268
273
  }>>>;
269
274
  }, z.core.$strip>>>;
270
- serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
271
275
  systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
272
276
  rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
273
277
  designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
274
278
  }, z.core.$strip>>>;
275
279
  offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
276
280
  rotation: z.ZodNumber;
277
- weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
281
+ priorities: z.ZodRecord<z.ZodString, z.ZodEnum<{
282
+ HIGH: "HIGH";
283
+ MID: "MID";
284
+ LOW: "LOW";
285
+ NONE: "NONE";
286
+ }>>;
278
287
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
279
- FAST: "FAST";
280
288
  HIGH: "HIGH";
289
+ FAST: "FAST";
281
290
  }>>>;
282
291
  }, z.core.$strip>>>>;
283
292
  blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -304,7 +313,12 @@ export declare const TacticsInputSchema: z.ZodObject<{
304
313
  r: z.ZodNumber;
305
314
  }, z.core.$strip>>>>;
306
315
  receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
316
+ receiveByPlayer: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>>;
307
317
  blocking: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>>>;
318
+ attackContact: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
319
+ d: z.ZodNumber;
320
+ r: z.ZodNumber;
321
+ }, z.core.$strip>>>>;
308
322
  }, z.core.$strip>>;
309
323
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
310
324
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -47,13 +47,14 @@ const lineupSchema = z.object({
47
47
  });
48
48
  // Per-player tempo preference (set-tempo rework): absent key = Auto.
49
49
  const tempoPreferenceSchema = z.enum(['FAST', 'HIGH']);
50
- // One rotation's attacker weighting. `rotation` is the main setter's rotational slot (1..6); `weights` maps each
51
- // attacker's starter id to an integer percentage share (values sum to 100; 0 removes an attacker). `tempos`
52
- // optionally forces a per-starter tempo (Auto when absent). Cross-field checks (keys are starters, weights sum to
53
- // 100) live in the superRefine below.
50
+ // One rotation's attacker weighting. `rotation` is the main setter's rotational slot (1..6); `priorities` maps a
51
+ // player id (a starter, or a designated substitute that overrides its slot once on court) to an AttackPriority.
52
+ // `tempos` optionally forces a per-player tempo (Auto when absent). Cross-field checks (keys are starters or
53
+ // designated subs) live in the superRefine below. No sum constraint: the setter weights by PRIORITY_WEIGHT.
54
+ const attackPrioritySchema = z.enum(['HIGH', 'MID', 'LOW', 'NONE']);
54
55
  const offensivePreferenceSchema = z.object({
55
56
  rotation: z.number().int().min(1).max(6),
56
- weights: z.record(z.string(), z.number().int().min(0)),
57
+ priorities: z.record(z.string(), attackPrioritySchema),
57
58
  tempos: z.record(z.string(), tempoPreferenceSchema).optional()
58
59
  });
59
60
  // An opposing attacking function a front-row starter can commit to (every RoleEnum except the libero, which never
@@ -87,9 +88,15 @@ const baseCoordSchema = z.object({ d: z.number().finite(), r: z.number().finite(
87
88
  const baseConfigSchema = z.object({
88
89
  coords: z.record(z.string(), z.record(z.string(), z.record(z.string(), baseCoordSchema))),
89
90
  receive: z.record(z.string(), z.record(z.string(), z.boolean())),
91
+ // Optional per-player receive override (owner 2026-08-13): [rotation][playerId] -> passes. Keys (designated subs)
92
+ // are validated in the superRefine. Optional so configs saved before this field validate unchanged.
93
+ receiveByPlayer: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
90
94
  // Optional per-point-of-attack blocking (owner 2026-08-07): [DEF_L|DEF_M|DEF_R][rotation][zone] -> canBlock.
91
95
  // Optional so presets/tactics saved before this field validate unchanged.
92
- blocking: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.boolean()))).optional()
96
+ blocking: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.boolean()))).optional(),
97
+ // Desired attack contact per attacker (trajectory-set, owner 2026-08-12): [rotation][zone] -> {d,r}. Optional so
98
+ // configs saved before this field validate unchanged.
99
+ attackContact: z.record(z.string(), z.record(z.string(), baseCoordSchema)).optional()
93
100
  });
94
101
  export const TacticsInputSchema = z.object({
95
102
  lineup: lineupSchema,
@@ -111,9 +118,6 @@ export const TacticsInputSchema = z.object({
111
118
  offensivePreferences: z.array(offensivePreferenceSchema).default([]),
112
119
  // Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
113
120
  // libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
114
- // This is NOT part of the offensivePreferences sum-to-100; its band (0-100) and libero-replacement-key rules
115
- // are validated independently in the superRefine below.
116
- serveAttackShares: z.record(z.string(), z.number().int()).default({}),
117
121
  // Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
118
122
  // validated with the same cross-field rules as the flat fields (see superRefine below).
119
123
  systemSets: z.array(systemSetSchema).max(5).optional(),
@@ -161,6 +165,11 @@ export const TacticsInputSchema = z.object({
161
165
  const starters = slots.map(([position, player]) => ({ position, id: player.id }));
162
166
  const starterIds = new Set(starters.map(s => s.id));
163
167
  const positionById = new Map(starters.map(s => [s.id, s.position]));
168
+ // Any bench player may carry its OWN per-player attack priority / tempo / block / receive (owner 2026-08-13):
169
+ // those maps are keyed by a player id, and the engine resolves them for whoever is on court. So the attack /
170
+ // block / receive key checks accept a starter OR any bench player (every bench player gets its own settings, not
171
+ // just the designated subs).
172
+ const allowedPlayerKeys = new Set([...starterIds, ...data.lineup.bench.map(p => p.id)]);
164
173
  // Designated setters: count must match the system, each must be an on-court starter, and in 4-2 / 6-2 the two
165
174
  // setters sit in opposite slots (3 rotations apart). Run on the flat fields and on every per-set entry.
166
175
  const validateSystem = (rotationSystem, designatedSetters, path) => {
@@ -191,19 +200,15 @@ export const TacticsInputSchema = z.object({
191
200
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_DUPLICATE_ROTATION', path });
192
201
  }
193
202
  seenRotations.add(pref.rotation);
194
- // Weight keys are on-court starters, and the percentages must sum to exactly 100 (0 removes an attacker).
195
- for (const id of Object.keys(pref.weights)) {
196
- if (!starterIds.has(id)) {
203
+ // Priority + tempo keys are an on-court starter or a designated sub (no sum-to-100: the setter weights by
204
+ // PRIORITY_WEIGHT and normalizes by the real total).
205
+ for (const id of Object.keys(pref.priorities)) {
206
+ if (!allowedPlayerKeys.has(id)) {
197
207
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
198
208
  }
199
209
  }
200
- const total = Object.values(pref.weights).reduce((sum, w) => sum + w, 0);
201
- if (total !== 100) {
202
- ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_WEIGHTS_MUST_SUM_TO_100', path });
203
- }
204
- // Tempo preference keys are starter ids too.
205
210
  for (const id of Object.keys(pref.tempos ?? {})) {
206
- if (!starterIds.has(id)) {
211
+ if (!allowedPlayerKeys.has(id)) {
207
212
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
208
213
  }
209
214
  }
@@ -220,13 +225,13 @@ export const TacticsInputSchema = z.object({
220
225
  }
221
226
  seenRotations.add(pref.rotation);
222
227
  for (const id of Object.keys(pref.commit)) {
223
- if (!starterIds.has(id)) {
228
+ if (!allowedPlayerKeys.has(id)) {
224
229
  ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
225
230
  }
226
231
  }
227
232
  for (const form of ['DEF_L', 'DEF_M', 'DEF_R']) {
228
233
  for (const id of pref.noBlock[form] ?? []) {
229
- if (!starterIds.has(id)) {
234
+ if (!allowedPlayerKeys.has(id)) {
230
235
  ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
231
236
  }
232
237
  }
@@ -245,19 +250,13 @@ export const TacticsInputSchema = z.object({
245
250
  for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
246
251
  validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
247
252
  }
248
- // Serve attack shares (libero-serve feature): only a current libero-replacement starter (flat or per-set) may
249
- // carry one, and each value must be in the 0-100 band. Validated independently of the offensive-preference
250
- // sum-to-100 (a serve share never enters that total).
251
- const liberoReplacementIds = new Set([
252
- ...data.liberoReplacements.map(p => p.id),
253
- ...(data.liberoReplacementSets ?? []).flatMap(set => set.map(p => p.id))
254
- ]);
255
- for (const [id, value] of Object.entries(data.serveAttackShares)) {
256
- if (!liberoReplacementIds.has(id)) {
257
- ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT', path: ['serveAttackShares'] });
258
- }
259
- if (value < 0 || value > 100) {
260
- ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_OUT_OF_BAND', path: ['serveAttackShares'] });
253
+ // Per-player receive override (substitute settings, owner 2026-08-13): [rotation][playerId] -> passes. Each inner
254
+ // key is an on-court starter or a designated sub.
255
+ for (const byPlayer of Object.values(data.basePositions?.receiveByPlayer ?? {})) {
256
+ for (const id of Object.keys(byPlayer)) {
257
+ if (!allowedPlayerKeys.has(id)) {
258
+ ctx.addIssue({ code: 'custom', message: 'RECEIVE_OVERRIDE_NOT_A_STARTER', path: ['basePositions', 'receiveByPlayer'] });
259
+ }
261
260
  }
262
261
  }
263
262
  // Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap