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.
- package/dist/cjs/src/api/index.d.ts +1 -2
- package/dist/cjs/src/api/tactics-player-refs.js +2 -2
- package/dist/cjs/src/data/models/tactics.d.ts +2 -4
- package/dist/cjs/src/data/models/tactics.js +0 -5
- package/dist/cjs/src/data/transformers/tactics.js +4 -36
- package/dist/cjs/src/data/transformers/tactics.test.js +9 -46
- package/dist/cjs/src/service/team/attack-priority.d.ts +3 -0
- package/dist/cjs/src/service/team/attack-priority.js +11 -0
- package/dist/cjs/src/service/team/base-config.d.ts +2 -0
- package/dist/cjs/src/service/team/default-base-config.js +59 -3
- package/dist/cjs/src/service/team/default-base-config.test.d.ts +1 -0
- package/dist/cjs/src/service/team/default-base-config.test.js +44 -0
- package/dist/cjs/src/service/team/index.d.ts +1 -0
- package/dist/cjs/src/service/team/index.js +1 -0
- package/dist/cjs/src/service/team/offensive-preference.d.ts +2 -1
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +19 -5
- package/dist/cjs/src/service/team/schemas/tactics.z.js +32 -33
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +25 -92
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +19 -5
- package/dist/cjs/src/service/team/tactics.d.ts +0 -2
- package/dist/cjs/src/service/team/tactics.js +1 -2
- package/dist/esm/src/api/index.d.ts +1 -2
- package/dist/esm/src/api/tactics-player-refs.js +2 -2
- package/dist/esm/src/data/models/tactics.d.ts +2 -4
- package/dist/esm/src/data/models/tactics.js +0 -5
- package/dist/esm/src/data/transformers/tactics.js +4 -36
- package/dist/esm/src/data/transformers/tactics.test.js +9 -46
- package/dist/esm/src/service/team/attack-priority.d.ts +3 -0
- package/dist/esm/src/service/team/attack-priority.js +8 -0
- package/dist/esm/src/service/team/base-config.d.ts +2 -0
- package/dist/esm/src/service/team/default-base-config.js +59 -3
- package/dist/esm/src/service/team/default-base-config.test.d.ts +1 -0
- package/dist/esm/src/service/team/default-base-config.test.js +42 -0
- package/dist/esm/src/service/team/index.d.ts +1 -0
- package/dist/esm/src/service/team/index.js +1 -0
- package/dist/esm/src/service/team/offensive-preference.d.ts +2 -1
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +19 -5
- package/dist/esm/src/service/team/schemas/tactics.z.js +32 -33
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +25 -92
- package/dist/esm/src/service/team/schemas/team.z.d.ts +19 -5
- package/dist/esm/src/service/team/tactics.d.ts +0 -2
- package/dist/esm/src/service/team/tactics.js +1 -2
- 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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].
|
|
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,
|
|
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,
|
|
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' });
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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); `
|
|
51
|
-
//
|
|
52
|
-
// optionally forces a per-
|
|
53
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|