volleyballsimtypes 0.0.479 → 0.0.481
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 -0
- package/dist/cjs/src/data/models/tactics.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event-tempo.test.js +40 -0
- package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event.js +5 -1
- package/dist/cjs/src/data/transformers/tactics.js +8 -4
- package/dist/cjs/src/data/transformers/tactics.test.js +13 -0
- package/dist/cjs/src/service/competition/standing.js +5 -0
- package/dist/cjs/src/service/competition/standing.test.js +19 -8
- package/dist/cjs/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/set.z.js +4 -1
- package/dist/cjs/src/service/event/schemas/set.z.test.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/set.z.test.js +29 -0
- package/dist/cjs/src/service/event/set.d.ts +7 -0
- package/dist/cjs/src/service/event/set.js +15 -3
- package/dist/cjs/src/service/team/offensive-preference.d.ts +2 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +8 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +12 -2
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +24 -0
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +8 -0
- package/dist/esm/src/api/index.d.ts +1 -0
- package/dist/esm/src/data/models/tactics.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event-tempo.test.js +38 -0
- package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event.js +5 -1
- package/dist/esm/src/data/transformers/tactics.js +8 -4
- package/dist/esm/src/data/transformers/tactics.test.js +13 -0
- package/dist/esm/src/service/competition/standing.js +5 -0
- package/dist/esm/src/service/competition/standing.test.js +19 -8
- package/dist/esm/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/set.z.js +4 -1
- package/dist/esm/src/service/event/schemas/set.z.test.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/set.z.test.js +27 -0
- package/dist/esm/src/service/event/set.d.ts +7 -0
- package/dist/esm/src/service/event/set.js +14 -2
- package/dist/esm/src/service/team/offensive-preference.d.ts +2 -0
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +8 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +12 -2
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +24 -0
- package/dist/esm/src/service/team/schemas/team.z.d.ts +8 -0
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ export interface DesignatedSubAttributes {
|
|
|
42
42
|
export interface OffensivePreferenceAttributes {
|
|
43
43
|
rotation: number;
|
|
44
44
|
order: PlayerId[];
|
|
45
|
+
tempos?: Record<PlayerId, 'FAST' | 'HIGH'>;
|
|
45
46
|
}
|
|
46
47
|
export interface SystemSetAttributes {
|
|
47
48
|
rotationSystem: RotationSystemEnum;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const globals_1 = require("@jest/globals");
|
|
4
|
+
const uuid_1 = require("uuid");
|
|
5
|
+
const service_1 = require("../../service");
|
|
6
|
+
const rally_event_1 = require("./rally-event");
|
|
7
|
+
(0, globals_1.describe)('rally-event compact round trip — set tempo marker', () => {
|
|
8
|
+
const playerId = (0, uuid_1.v4)();
|
|
9
|
+
const roster = [playerId];
|
|
10
|
+
const playerIndex = new Map(roster.map((id, i) => [id, i]));
|
|
11
|
+
function makeSet(tempo) {
|
|
12
|
+
return service_1.Set.create({
|
|
13
|
+
playerId,
|
|
14
|
+
score: 90,
|
|
15
|
+
target: 4,
|
|
16
|
+
failure: service_1.SetFailureEnum.NO_FAILURE,
|
|
17
|
+
type: service_1.SetTypeEnum.OVERHAND,
|
|
18
|
+
...(tempo !== undefined ? { tempo } : {})
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
(0, globals_1.it)('packs each tempo as m and round-trips it', () => {
|
|
22
|
+
for (const tempo of [service_1.SetTempoEnum.FIRST, service_1.SetTempoEnum.SECOND, service_1.SetTempoEnum.HIGH]) {
|
|
23
|
+
const compact = (0, rally_event_1.transformToCompact)(makeSet(tempo), playerIndex);
|
|
24
|
+
(0, globals_1.expect)(compact.m).toBe(tempo);
|
|
25
|
+
const decoded = (0, rally_event_1.transformToSet)(compact, roster);
|
|
26
|
+
(0, globals_1.expect)(decoded.tempo).toBe(tempo);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
(0, globals_1.it)('omits m entirely for a set without a tempo', () => {
|
|
30
|
+
const compact = (0, rally_event_1.transformToCompact)(makeSet(), playerIndex);
|
|
31
|
+
(0, globals_1.expect)('m' in compact).toBe(false);
|
|
32
|
+
const decoded = (0, rally_event_1.transformToSet)(compact, roster);
|
|
33
|
+
(0, globals_1.expect)(decoded.tempo).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
(0, globals_1.it)('decodes a legacy compact event (no m key) with no tempo', () => {
|
|
36
|
+
const legacy = { p: 0, e: service_1.EventTypeEnum.SET, f: 0, t: 0, a: 4, s: 90 };
|
|
37
|
+
const decoded = (0, rally_event_1.transformToSet)(legacy, roster);
|
|
38
|
+
(0, globals_1.expect)(decoded.tempo).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -12,6 +12,7 @@ export interface CompactEvent {
|
|
|
12
12
|
j?: number;
|
|
13
13
|
i?: number;
|
|
14
14
|
h?: number;
|
|
15
|
+
m?: number;
|
|
15
16
|
}
|
|
16
17
|
export declare function transformToCompact(evt: RallyEvent, playerIndex: Map<string, number>): CompactEvent;
|
|
17
18
|
export declare function transformToBlock(event: CompactEvent, roster: string[]): Block;
|
|
@@ -44,6 +44,9 @@ function transformToCompact(evt, playerIndex) {
|
|
|
44
44
|
if (evt instanceof service_1.Block) {
|
|
45
45
|
out.b = evt.blockers.map(id => playerIndex.get(id));
|
|
46
46
|
}
|
|
47
|
+
if (evt instanceof service_1.Set && evt.tempo !== undefined) {
|
|
48
|
+
out.m = evt.tempo;
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
else if (evt instanceof service_1.LiberoReplacement) {
|
|
49
52
|
out.t = evt.type;
|
|
@@ -101,7 +104,8 @@ function transformToSet(event, roster) {
|
|
|
101
104
|
playerId: roster[event.p],
|
|
102
105
|
target: event.a,
|
|
103
106
|
score: event.s,
|
|
104
|
-
incident: incidentFromCompact(event.i, event.h, roster)
|
|
107
|
+
incident: incidentFromCompact(event.i, event.h, roster),
|
|
108
|
+
tempo: event.m
|
|
105
109
|
});
|
|
106
110
|
}
|
|
107
111
|
function transformToSpike(event, roster) {
|
|
@@ -92,7 +92,8 @@ function transformToAttributes(tactics, teamId) {
|
|
|
92
92
|
designated_setters: tactics.designatedSetters.map((s) => s.id),
|
|
93
93
|
offensive_preferences: tactics.offensivePreferences.map(p => ({
|
|
94
94
|
rotation: p.rotation,
|
|
95
|
-
order: p.order.map((a) => a.id)
|
|
95
|
+
order: p.order.map((a) => a.id),
|
|
96
|
+
tempos: p.tempos
|
|
96
97
|
})),
|
|
97
98
|
system_sets: tactics.systemSets?.map(s => ({
|
|
98
99
|
rotationSystem: s.rotationSystem,
|
|
@@ -100,7 +101,8 @@ function transformToAttributes(tactics, teamId) {
|
|
|
100
101
|
})),
|
|
101
102
|
offensive_preference_sets: tactics.offensivePreferenceSets?.map(prefs => prefs.map(p => ({
|
|
102
103
|
rotation: p.rotation,
|
|
103
|
-
order: p.order.map((a) => a.id)
|
|
104
|
+
order: p.order.map((a) => a.id),
|
|
105
|
+
tempos: p.tempos
|
|
104
106
|
}))),
|
|
105
107
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
106
108
|
injury_replacements: tactics.injuryReplacements.map(ir => ({
|
|
@@ -190,7 +192,8 @@ function transformToObject(model, roster) {
|
|
|
190
192
|
designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
|
|
191
193
|
offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
|
|
192
194
|
rotation: p.rotation,
|
|
193
|
-
order: p.order.map((id) => findPlayer(id, roster))
|
|
195
|
+
order: p.order.map((id) => findPlayer(id, roster)),
|
|
196
|
+
tempos: p.tempos
|
|
194
197
|
})),
|
|
195
198
|
systemSets: model.system_sets?.map(s => ({
|
|
196
199
|
rotationSystem: s.rotationSystem,
|
|
@@ -198,7 +201,8 @@ function transformToObject(model, roster) {
|
|
|
198
201
|
})),
|
|
199
202
|
offensivePreferenceSets: model.offensive_preference_sets?.map(prefs => prefs.map(p => ({
|
|
200
203
|
rotation: p.rotation,
|
|
201
|
-
order: p.order.map((id) => findPlayer(id, roster))
|
|
204
|
+
order: p.order.map((id) => findPlayer(id, roster)),
|
|
205
|
+
tempos: p.tempos
|
|
202
206
|
}))),
|
|
203
207
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
204
208
|
// libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
|
|
@@ -184,6 +184,19 @@ function makeTactics(designatedSubs) {
|
|
|
184
184
|
(0, globals_1.expect)(back.systemSets).toBeUndefined();
|
|
185
185
|
(0, globals_1.expect)(back.offensivePreferenceSets).toBeUndefined();
|
|
186
186
|
});
|
|
187
|
+
(0, globals_1.it)('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
188
|
+
const withTempos = service_1.Tactics.create({
|
|
189
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
190
|
+
liberoReplacements: [],
|
|
191
|
+
offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
|
|
192
|
+
});
|
|
193
|
+
const attrs = (0, tactics_1.transformFromTactics)(withTempos, 'team-1');
|
|
194
|
+
(0, globals_1.expect)(attrs.offensive_preferences).toEqual([
|
|
195
|
+
{ rotation: 1, order: [p2.id, p3.id], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
|
|
196
|
+
]);
|
|
197
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
198
|
+
(0, globals_1.expect)(back.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
|
|
199
|
+
});
|
|
187
200
|
});
|
|
188
201
|
(0, globals_1.describe)('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
189
202
|
const country = (0, test_helpers_1.makeCountry)();
|
|
@@ -41,6 +41,11 @@ class Standing {
|
|
|
41
41
|
// effective ratio here that treats "won with nothing lost" as +Infinity, ranking it highest.
|
|
42
42
|
const effectiveRatio = (won, lost) => lost > 0 ? won / lost : (won > 0 ? Infinity : 0);
|
|
43
43
|
return (standingA, standingB) => {
|
|
44
|
+
// VNL/FIVB order: matches WON is the first criterion, then match points, then set ratio, point ratio, h2h.
|
|
45
|
+
if (standingB.matchesWon > standingA.matchesWon)
|
|
46
|
+
return 1;
|
|
47
|
+
if (standingA.matchesWon > standingB.matchesWon)
|
|
48
|
+
return -1;
|
|
44
49
|
if (standingB.points > standingA.points)
|
|
45
50
|
return 1;
|
|
46
51
|
if (standingA.points > standingB.points)
|
|
@@ -115,23 +115,34 @@ function makeStanding(teamId, opts = {}) {
|
|
|
115
115
|
});
|
|
116
116
|
// ─── sortFn ──────────────────────────────────────────────────────────────────
|
|
117
117
|
(0, globals_1.describe)('sortFn()', () => {
|
|
118
|
-
(0, globals_1.it)('
|
|
118
|
+
(0, globals_1.it)('ranks by matches won ahead of points (VNL first criterion)', () => {
|
|
119
119
|
const idA = (0, uuid_1.v4)();
|
|
120
120
|
const idB = (0, uuid_1.v4)();
|
|
121
|
-
// A
|
|
121
|
+
// A: won32=2 → 2 wins, 4 points. B: won30=1 + lost23=2 → 1 win, 5 points.
|
|
122
|
+
// Wins is the first criterion, so A ranks above B even though B has more points.
|
|
123
|
+
const a = makeStanding(idA, { won32: 2 });
|
|
124
|
+
const b = makeStanding(idB, { won30: 1, lost23: 2 });
|
|
125
|
+
const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
|
|
126
|
+
(0, globals_1.expect)(sorted[0].teamId).toBe(idA);
|
|
127
|
+
(0, globals_1.expect)(sorted[1].teamId).toBe(idB);
|
|
128
|
+
});
|
|
129
|
+
(0, globals_1.it)('breaks ties by points when wins are equal', () => {
|
|
130
|
+
const idA = (0, uuid_1.v4)();
|
|
131
|
+
const idB = (0, uuid_1.v4)();
|
|
132
|
+
// Both 1 win. A: won30=1 → 3 points. B: won32=1 → 2 points. Points decides once wins tie.
|
|
122
133
|
const a = makeStanding(idA, { won30: 1 });
|
|
123
|
-
const b = makeStanding(idB);
|
|
134
|
+
const b = makeStanding(idB, { won32: 1 });
|
|
124
135
|
const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
|
|
125
136
|
(0, globals_1.expect)(sorted[0].teamId).toBe(idA);
|
|
126
137
|
(0, globals_1.expect)(sorted[1].teamId).toBe(idB);
|
|
127
138
|
});
|
|
128
|
-
(0, globals_1.it)('breaks ties by setsRatio when points are equal', () => {
|
|
139
|
+
(0, globals_1.it)('breaks ties by setsRatio when wins and points are equal', () => {
|
|
129
140
|
const idA = (0, uuid_1.v4)();
|
|
130
141
|
const idB = (0, uuid_1.v4)();
|
|
131
|
-
// Both 4 points, but A has better setsRatio:
|
|
132
|
-
// A:
|
|
133
|
-
// B: won31=1, lost23=1 →
|
|
134
|
-
const a = makeStanding(idA, {
|
|
142
|
+
// Both 1 win and 4 points, but A has the better setsRatio:
|
|
143
|
+
// A: won30=1, lost23=1 → setsWon=5, setsLost=3, setsRatio≈1.67
|
|
144
|
+
// B: won31=1, lost23=1 → setsWon=5, setsLost=4, setsRatio=1.25
|
|
145
|
+
const a = makeStanding(idA, { won30: 1, lost23: 1 });
|
|
135
146
|
const b = makeStanding(idB, { won31: 1, lost23: 1 });
|
|
136
147
|
const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
|
|
137
148
|
(0, globals_1.expect)(sorted[0].teamId).toBe(idA);
|
|
@@ -6,6 +6,7 @@ const player_1 = require("../../player");
|
|
|
6
6
|
const incident_z_1 = require("./incident.z");
|
|
7
7
|
const setTypeValues = [0, 1];
|
|
8
8
|
const setFailureValues = [0, 1, 2, 3, 4];
|
|
9
|
+
const setTempoValues = [0, 1, 2];
|
|
9
10
|
const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
|
|
10
11
|
exports.SetInputSchema = zod_1.z.object({
|
|
11
12
|
playerId: zod_1.z.uuid(),
|
|
@@ -14,5 +15,7 @@ exports.SetInputSchema = zod_1.z.object({
|
|
|
14
15
|
activeTraits: zod_1.z.array(zod_1.z.enum(Object.values(player_1.TraitEnum))).default([]),
|
|
15
16
|
failure: zod_1.z.number().int().refine((v) => setFailureValues.includes(v), { message: 'INVALID_SET_FAILURE' }),
|
|
16
17
|
type: zod_1.z.number().int().refine((v) => setTypeValues.includes(v), { message: 'INVALID_SET_TYPE' }),
|
|
17
|
-
incident: incident_z_1.EventIncidentSchema.optional()
|
|
18
|
+
incident: incident_z_1.EventIncidentSchema.optional(),
|
|
19
|
+
// Set tempo (FIRST=0, SECOND=1, HIGH=2). Optional: absent on dumps, failed sets, and every pre-tempo event.
|
|
20
|
+
tempo: zod_1.z.number().int().refine((v) => setTempoValues.includes(v), { message: 'INVALID_SET_TEMPO' }).optional()
|
|
18
21
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const globals_1 = require("@jest/globals");
|
|
4
|
+
const set_z_1 = require("./set.z");
|
|
5
|
+
const base = {
|
|
6
|
+
playerId: '11111111-1111-4111-8111-111111111111',
|
|
7
|
+
score: 90,
|
|
8
|
+
target: 4,
|
|
9
|
+
activeTraits: [],
|
|
10
|
+
failure: 0,
|
|
11
|
+
type: 0
|
|
12
|
+
};
|
|
13
|
+
(0, globals_1.describe)('SetInputSchema — tempo', () => {
|
|
14
|
+
(0, globals_1.it)('accepts every tempo 0-2 (FIRST / SECOND / HIGH)', () => {
|
|
15
|
+
for (const tempo of [0, 1, 2]) {
|
|
16
|
+
(0, globals_1.expect)(set_z_1.SetInputSchema.safeParse({ ...base, tempo }).success).toBe(true);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
(0, globals_1.it)('accepts an absent tempo (Auto, dumps, failed sets)', () => {
|
|
20
|
+
(0, globals_1.expect)(set_z_1.SetInputSchema.safeParse(base).success).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
(0, globals_1.it)('rejects an out-of-range tempo', () => {
|
|
23
|
+
(0, globals_1.expect)(set_z_1.SetInputSchema.safeParse({ ...base, tempo: 3 }).success).toBe(false);
|
|
24
|
+
(0, globals_1.expect)(set_z_1.SetInputSchema.safeParse({ ...base, tempo: -1 }).success).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
(0, globals_1.it)('rejects a non-integer tempo', () => {
|
|
27
|
+
(0, globals_1.expect)(set_z_1.SetInputSchema.safeParse({ ...base, tempo: 1.5 }).success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -4,6 +4,12 @@ export declare enum SetTypeEnum {
|
|
|
4
4
|
DUMP = 1
|
|
5
5
|
}
|
|
6
6
|
export type SetType = SetTypeEnum.OVERHAND | SetTypeEnum.DUMP;
|
|
7
|
+
export declare enum SetTempoEnum {
|
|
8
|
+
FIRST = 0,
|
|
9
|
+
SECOND = 1,
|
|
10
|
+
HIGH = 2
|
|
11
|
+
}
|
|
12
|
+
export type SetTempo = SetTempoEnum.FIRST | SetTempoEnum.SECOND | SetTempoEnum.HIGH;
|
|
7
13
|
export declare enum SetFailureEnum {
|
|
8
14
|
NO_FAILURE = 0,
|
|
9
15
|
FAULT = 1,
|
|
@@ -15,6 +21,7 @@ export type SetFailure = SetFailureEnum.NO_FAILURE | SetFailureEnum.FAULT | SetF
|
|
|
15
21
|
export declare class Set extends InPlayEvent<SetFailure, SetType> {
|
|
16
22
|
readonly failure: SetFailure;
|
|
17
23
|
readonly type: SetType;
|
|
24
|
+
readonly tempo?: SetTempo;
|
|
18
25
|
static create(input: unknown): Set;
|
|
19
26
|
private constructor();
|
|
20
27
|
toString(): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Set = exports.SetFailureEnum = exports.SetTypeEnum = void 0;
|
|
3
|
+
exports.Set = exports.SetFailureEnum = exports.SetTempoEnum = exports.SetTypeEnum = void 0;
|
|
4
4
|
const rally_event_1 = require("./rally-event");
|
|
5
5
|
const in_play_event_1 = require("./in-play-event");
|
|
6
6
|
const set_z_1 = require("./schemas/set.z");
|
|
@@ -9,6 +9,16 @@ var SetTypeEnum;
|
|
|
9
9
|
SetTypeEnum[SetTypeEnum["OVERHAND"] = 0] = "OVERHAND";
|
|
10
10
|
SetTypeEnum[SetTypeEnum["DUMP"] = 1] = "DUMP";
|
|
11
11
|
})(SetTypeEnum || (exports.SetTypeEnum = SetTypeEnum = {}));
|
|
12
|
+
// The tempo of a set (set-tempo rework). Decided by the simulator (pass quality + target lane + setter skill)
|
|
13
|
+
// or forced by a per-player offensive preference. FIRST = quick (swings the `quick` stat, hardest to nail,
|
|
14
|
+
// hardest to block); SECOND = second tempo; HIGH = high ball (the fallback, easiest to block). Only present on
|
|
15
|
+
// a successful overhand set; absent for dumps, failed sets, and free-ball/scramble attacks.
|
|
16
|
+
var SetTempoEnum;
|
|
17
|
+
(function (SetTempoEnum) {
|
|
18
|
+
SetTempoEnum[SetTempoEnum["FIRST"] = 0] = "FIRST";
|
|
19
|
+
SetTempoEnum[SetTempoEnum["SECOND"] = 1] = "SECOND";
|
|
20
|
+
SetTempoEnum[SetTempoEnum["HIGH"] = 2] = "HIGH";
|
|
21
|
+
})(SetTempoEnum || (exports.SetTempoEnum = SetTempoEnum = {}));
|
|
12
22
|
var SetFailureEnum;
|
|
13
23
|
(function (SetFailureEnum) {
|
|
14
24
|
SetFailureEnum[SetFailureEnum["NO_FAILURE"] = 0] = "NO_FAILURE";
|
|
@@ -31,15 +41,17 @@ class Set extends in_play_event_1.InPlayEvent {
|
|
|
31
41
|
}
|
|
32
42
|
return new Set(result.data);
|
|
33
43
|
}
|
|
34
|
-
constructor({ score, playerId, target, failure, type, activeTraits, incident }) {
|
|
44
|
+
constructor({ score, playerId, target, failure, type, activeTraits, incident, tempo }) {
|
|
35
45
|
super({ score, playerId, target, activeTraits, incident, eventType });
|
|
36
46
|
this.type = type;
|
|
37
47
|
this.failure = failure;
|
|
48
|
+
this.tempo = tempo;
|
|
38
49
|
}
|
|
39
50
|
toString() {
|
|
40
51
|
const failure = `"failure":${this.failure}`;
|
|
41
52
|
const type = `"type":${this.type}`;
|
|
42
|
-
|
|
53
|
+
const tempo = this.tempo !== undefined ? `,"tempo":${this.tempo}` : '';
|
|
54
|
+
return `{${this._toString()},${failure},${type}${tempo}}`;
|
|
43
55
|
}
|
|
44
56
|
}
|
|
45
57
|
exports.Set = Set;
|
|
@@ -270,6 +270,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
270
270
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
271
271
|
rotation: z.ZodNumber;
|
|
272
272
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
273
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
274
|
+
FAST: "FAST";
|
|
275
|
+
HIGH: "HIGH";
|
|
276
|
+
}>>>;
|
|
273
277
|
}, z.core.$strip>>>;
|
|
274
278
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
275
279
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
@@ -278,6 +282,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
278
282
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
279
283
|
rotation: z.ZodNumber;
|
|
280
284
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
285
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
286
|
+
FAST: "FAST";
|
|
287
|
+
HIGH: "HIGH";
|
|
288
|
+
}>>>;
|
|
281
289
|
}, z.core.$strip>>>>;
|
|
282
290
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
283
291
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -48,11 +48,15 @@ const lineupSchema = zod_1.z.object({
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
|
+
// Per-player tempo preference (set-tempo rework): absent key = Auto.
|
|
52
|
+
const tempoPreferenceSchema = zod_1.z.enum(['FAST', 'HIGH']);
|
|
51
53
|
// One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
|
|
52
|
-
// the attackers ranked best-first.
|
|
54
|
+
// the attackers ranked best-first. `tempos` optionally forces a per-starter tempo (Auto when absent). Cross-field
|
|
55
|
+
// checks (starters only in both order and tempos, no dupes) live in the superRefine below.
|
|
53
56
|
const offensivePreferenceSchema = zod_1.z.object({
|
|
54
57
|
rotation: zod_1.z.number().int().min(1).max(6),
|
|
55
|
-
order: zod_1.z.array(playerInstanceSchema)
|
|
58
|
+
order: zod_1.z.array(playerInstanceSchema),
|
|
59
|
+
tempos: zod_1.z.record(zod_1.z.string(), tempoPreferenceSchema).optional()
|
|
56
60
|
});
|
|
57
61
|
// One set's rotation-system override (per-set tactics): system + its designated setters travel together.
|
|
58
62
|
const systemSetSchema = zod_1.z.object({
|
|
@@ -158,6 +162,12 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
158
162
|
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
|
|
159
163
|
}
|
|
160
164
|
}
|
|
165
|
+
// Tempo preference keys are starter ids too (same rule as order).
|
|
166
|
+
for (const id of Object.keys(pref.tempos ?? {})) {
|
|
167
|
+
if (!starterIds.has(id)) {
|
|
168
|
+
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
161
171
|
}
|
|
162
172
|
};
|
|
163
173
|
validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
|
|
@@ -170,6 +170,30 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
170
170
|
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
|
|
171
171
|
}
|
|
172
172
|
});
|
|
173
|
+
(0, globals_1.it)('accepts offensive preferences with a starter-keyed tempos map', () => {
|
|
174
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
175
|
+
offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
|
|
176
|
+
}));
|
|
177
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
178
|
+
if (res.success) {
|
|
179
|
+
(0, globals_1.expect)(res.data.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
(0, globals_1.it)('rejects a tempos map keyed by a non-starter', () => {
|
|
183
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
184
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
|
|
185
|
+
}));
|
|
186
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
187
|
+
if (!res.success) {
|
|
188
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER')).toBe(true);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
(0, globals_1.it)('rejects an invalid tempo value', () => {
|
|
192
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
193
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
|
|
194
|
+
}));
|
|
195
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
196
|
+
});
|
|
173
197
|
(0, globals_1.it)('still validates the flat fields when per-set overrides are present', () => {
|
|
174
198
|
// Flat 5-1 with no setter is invalid even though every set entry is valid.
|
|
175
199
|
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
@@ -278,6 +278,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
278
278
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
279
279
|
rotation: z.ZodNumber;
|
|
280
280
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
281
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
282
|
+
FAST: "FAST";
|
|
283
|
+
HIGH: "HIGH";
|
|
284
|
+
}>>>;
|
|
281
285
|
}, z.core.$strip>>>;
|
|
282
286
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
283
287
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
@@ -286,6 +290,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
286
290
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
287
291
|
rotation: z.ZodNumber;
|
|
288
292
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
293
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
294
|
+
FAST: "FAST";
|
|
295
|
+
HIGH: "HIGH";
|
|
296
|
+
}>>>;
|
|
289
297
|
}, z.core.$strip>>>>;
|
|
290
298
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
291
299
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -42,6 +42,7 @@ export interface DesignatedSubAttributes {
|
|
|
42
42
|
export interface OffensivePreferenceAttributes {
|
|
43
43
|
rotation: number;
|
|
44
44
|
order: PlayerId[];
|
|
45
|
+
tempos?: Record<PlayerId, 'FAST' | 'HIGH'>;
|
|
45
46
|
}
|
|
46
47
|
export interface SystemSetAttributes {
|
|
47
48
|
rotationSystem: RotationSystemEnum;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { EventTypeEnum, Set, SetFailureEnum, SetTempoEnum, SetTypeEnum } from '../../service';
|
|
4
|
+
import { transformToCompact, transformToSet } from './rally-event';
|
|
5
|
+
describe('rally-event compact round trip — set tempo marker', () => {
|
|
6
|
+
const playerId = uuidv4();
|
|
7
|
+
const roster = [playerId];
|
|
8
|
+
const playerIndex = new Map(roster.map((id, i) => [id, i]));
|
|
9
|
+
function makeSet(tempo) {
|
|
10
|
+
return Set.create({
|
|
11
|
+
playerId,
|
|
12
|
+
score: 90,
|
|
13
|
+
target: 4,
|
|
14
|
+
failure: SetFailureEnum.NO_FAILURE,
|
|
15
|
+
type: SetTypeEnum.OVERHAND,
|
|
16
|
+
...(tempo !== undefined ? { tempo } : {})
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
it('packs each tempo as m and round-trips it', () => {
|
|
20
|
+
for (const tempo of [SetTempoEnum.FIRST, SetTempoEnum.SECOND, SetTempoEnum.HIGH]) {
|
|
21
|
+
const compact = transformToCompact(makeSet(tempo), playerIndex);
|
|
22
|
+
expect(compact.m).toBe(tempo);
|
|
23
|
+
const decoded = transformToSet(compact, roster);
|
|
24
|
+
expect(decoded.tempo).toBe(tempo);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('omits m entirely for a set without a tempo', () => {
|
|
28
|
+
const compact = transformToCompact(makeSet(), playerIndex);
|
|
29
|
+
expect('m' in compact).toBe(false);
|
|
30
|
+
const decoded = transformToSet(compact, roster);
|
|
31
|
+
expect(decoded.tempo).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('decodes a legacy compact event (no m key) with no tempo', () => {
|
|
34
|
+
const legacy = { p: 0, e: EventTypeEnum.SET, f: 0, t: 0, a: 4, s: 90 };
|
|
35
|
+
const decoded = transformToSet(legacy, roster);
|
|
36
|
+
expect(decoded.tempo).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -12,6 +12,7 @@ export interface CompactEvent {
|
|
|
12
12
|
j?: number;
|
|
13
13
|
i?: number;
|
|
14
14
|
h?: number;
|
|
15
|
+
m?: number;
|
|
15
16
|
}
|
|
16
17
|
export declare function transformToCompact(evt: RallyEvent, playerIndex: Map<string, number>): CompactEvent;
|
|
17
18
|
export declare function transformToBlock(event: CompactEvent, roster: string[]): Block;
|
|
@@ -34,6 +34,9 @@ export function transformToCompact(evt, playerIndex) {
|
|
|
34
34
|
if (evt instanceof Block) {
|
|
35
35
|
out.b = evt.blockers.map(id => playerIndex.get(id));
|
|
36
36
|
}
|
|
37
|
+
if (evt instanceof Set && evt.tempo !== undefined) {
|
|
38
|
+
out.m = evt.tempo;
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
else if (evt instanceof LiberoReplacement) {
|
|
39
42
|
out.t = evt.type;
|
|
@@ -91,7 +94,8 @@ export function transformToSet(event, roster) {
|
|
|
91
94
|
playerId: roster[event.p],
|
|
92
95
|
target: event.a,
|
|
93
96
|
score: event.s,
|
|
94
|
-
incident: incidentFromCompact(event.i, event.h, roster)
|
|
97
|
+
incident: incidentFromCompact(event.i, event.h, roster),
|
|
98
|
+
tempo: event.m
|
|
95
99
|
});
|
|
96
100
|
}
|
|
97
101
|
export function transformToSpike(event, roster) {
|
|
@@ -86,7 +86,8 @@ function transformToAttributes(tactics, teamId) {
|
|
|
86
86
|
designated_setters: tactics.designatedSetters.map((s) => s.id),
|
|
87
87
|
offensive_preferences: tactics.offensivePreferences.map(p => ({
|
|
88
88
|
rotation: p.rotation,
|
|
89
|
-
order: p.order.map((a) => a.id)
|
|
89
|
+
order: p.order.map((a) => a.id),
|
|
90
|
+
tempos: p.tempos
|
|
90
91
|
})),
|
|
91
92
|
system_sets: tactics.systemSets?.map(s => ({
|
|
92
93
|
rotationSystem: s.rotationSystem,
|
|
@@ -94,7 +95,8 @@ function transformToAttributes(tactics, teamId) {
|
|
|
94
95
|
})),
|
|
95
96
|
offensive_preference_sets: tactics.offensivePreferenceSets?.map(prefs => prefs.map(p => ({
|
|
96
97
|
rotation: p.rotation,
|
|
97
|
-
order: p.order.map((a) => a.id)
|
|
98
|
+
order: p.order.map((a) => a.id),
|
|
99
|
+
tempos: p.tempos
|
|
98
100
|
}))),
|
|
99
101
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
100
102
|
injury_replacements: tactics.injuryReplacements.map(ir => ({
|
|
@@ -184,7 +186,8 @@ function transformToObject(model, roster) {
|
|
|
184
186
|
designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
|
|
185
187
|
offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
|
|
186
188
|
rotation: p.rotation,
|
|
187
|
-
order: p.order.map((id) => findPlayer(id, roster))
|
|
189
|
+
order: p.order.map((id) => findPlayer(id, roster)),
|
|
190
|
+
tempos: p.tempos
|
|
188
191
|
})),
|
|
189
192
|
systemSets: model.system_sets?.map(s => ({
|
|
190
193
|
rotationSystem: s.rotationSystem,
|
|
@@ -192,7 +195,8 @@ function transformToObject(model, roster) {
|
|
|
192
195
|
})),
|
|
193
196
|
offensivePreferenceSets: model.offensive_preference_sets?.map(prefs => prefs.map(p => ({
|
|
194
197
|
rotation: p.rotation,
|
|
195
|
-
order: p.order.map((id) => findPlayer(id, roster))
|
|
198
|
+
order: p.order.map((id) => findPlayer(id, roster)),
|
|
199
|
+
tempos: p.tempos
|
|
196
200
|
}))),
|
|
197
201
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
198
202
|
// libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
|
|
@@ -182,6 +182,19 @@ describe('per-set tactics — service <-> row round-trip', () => {
|
|
|
182
182
|
expect(back.systemSets).toBeUndefined();
|
|
183
183
|
expect(back.offensivePreferenceSets).toBeUndefined();
|
|
184
184
|
});
|
|
185
|
+
it('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
186
|
+
const withTempos = Tactics.create({
|
|
187
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
188
|
+
liberoReplacements: [],
|
|
189
|
+
offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
|
|
190
|
+
});
|
|
191
|
+
const attrs = transformFromTactics(withTempos, 'team-1');
|
|
192
|
+
expect(attrs.offensive_preferences).toEqual([
|
|
193
|
+
{ rotation: 1, order: [p2.id, p3.id], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
|
|
194
|
+
]);
|
|
195
|
+
const back = transformToTactics(attrs, roster);
|
|
196
|
+
expect(back.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
|
|
197
|
+
});
|
|
185
198
|
});
|
|
186
199
|
describe('libero injuries — service <-> row round-trip + lenient heal', () => {
|
|
187
200
|
const country = makeCountry();
|
|
@@ -38,6 +38,11 @@ export class Standing {
|
|
|
38
38
|
// effective ratio here that treats "won with nothing lost" as +Infinity, ranking it highest.
|
|
39
39
|
const effectiveRatio = (won, lost) => lost > 0 ? won / lost : (won > 0 ? Infinity : 0);
|
|
40
40
|
return (standingA, standingB) => {
|
|
41
|
+
// VNL/FIVB order: matches WON is the first criterion, then match points, then set ratio, point ratio, h2h.
|
|
42
|
+
if (standingB.matchesWon > standingA.matchesWon)
|
|
43
|
+
return 1;
|
|
44
|
+
if (standingA.matchesWon > standingB.matchesWon)
|
|
45
|
+
return -1;
|
|
41
46
|
if (standingB.points > standingA.points)
|
|
42
47
|
return 1;
|
|
43
48
|
if (standingA.points > standingB.points)
|
|
@@ -113,23 +113,34 @@ describe('Standing', () => {
|
|
|
113
113
|
});
|
|
114
114
|
// ─── sortFn ──────────────────────────────────────────────────────────────────
|
|
115
115
|
describe('sortFn()', () => {
|
|
116
|
-
it('
|
|
116
|
+
it('ranks by matches won ahead of points (VNL first criterion)', () => {
|
|
117
117
|
const idA = uuidv4();
|
|
118
118
|
const idB = uuidv4();
|
|
119
|
-
// A
|
|
119
|
+
// A: won32=2 → 2 wins, 4 points. B: won30=1 + lost23=2 → 1 win, 5 points.
|
|
120
|
+
// Wins is the first criterion, so A ranks above B even though B has more points.
|
|
121
|
+
const a = makeStanding(idA, { won32: 2 });
|
|
122
|
+
const b = makeStanding(idB, { won30: 1, lost23: 2 });
|
|
123
|
+
const sorted = [b, a].sort(Standing.sortFn([]));
|
|
124
|
+
expect(sorted[0].teamId).toBe(idA);
|
|
125
|
+
expect(sorted[1].teamId).toBe(idB);
|
|
126
|
+
});
|
|
127
|
+
it('breaks ties by points when wins are equal', () => {
|
|
128
|
+
const idA = uuidv4();
|
|
129
|
+
const idB = uuidv4();
|
|
130
|
+
// Both 1 win. A: won30=1 → 3 points. B: won32=1 → 2 points. Points decides once wins tie.
|
|
120
131
|
const a = makeStanding(idA, { won30: 1 });
|
|
121
|
-
const b = makeStanding(idB);
|
|
132
|
+
const b = makeStanding(idB, { won32: 1 });
|
|
122
133
|
const sorted = [b, a].sort(Standing.sortFn([]));
|
|
123
134
|
expect(sorted[0].teamId).toBe(idA);
|
|
124
135
|
expect(sorted[1].teamId).toBe(idB);
|
|
125
136
|
});
|
|
126
|
-
it('breaks ties by setsRatio when points are equal', () => {
|
|
137
|
+
it('breaks ties by setsRatio when wins and points are equal', () => {
|
|
127
138
|
const idA = uuidv4();
|
|
128
139
|
const idB = uuidv4();
|
|
129
|
-
// Both 4 points, but A has better setsRatio:
|
|
130
|
-
// A:
|
|
131
|
-
// B: won31=1, lost23=1 →
|
|
132
|
-
const a = makeStanding(idA, {
|
|
140
|
+
// Both 1 win and 4 points, but A has the better setsRatio:
|
|
141
|
+
// A: won30=1, lost23=1 → setsWon=5, setsLost=3, setsRatio≈1.67
|
|
142
|
+
// B: won31=1, lost23=1 → setsWon=5, setsLost=4, setsRatio=1.25
|
|
143
|
+
const a = makeStanding(idA, { won30: 1, lost23: 1 });
|
|
133
144
|
const b = makeStanding(idB, { won31: 1, lost23: 1 });
|
|
134
145
|
const sorted = [b, a].sort(Standing.sortFn([]));
|
|
135
146
|
expect(sorted[0].teamId).toBe(idA);
|
|
@@ -3,6 +3,7 @@ import { TraitEnum } from '../../player';
|
|
|
3
3
|
import { EventIncidentSchema } from './incident.z';
|
|
4
4
|
const setTypeValues = [0, 1];
|
|
5
5
|
const setFailureValues = [0, 1, 2, 3, 4];
|
|
6
|
+
const setTempoValues = [0, 1, 2];
|
|
6
7
|
const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
|
|
7
8
|
export const SetInputSchema = z.object({
|
|
8
9
|
playerId: z.uuid(),
|
|
@@ -11,5 +12,7 @@ export const SetInputSchema = z.object({
|
|
|
11
12
|
activeTraits: z.array(z.enum(Object.values(TraitEnum))).default([]),
|
|
12
13
|
failure: z.number().int().refine((v) => setFailureValues.includes(v), { message: 'INVALID_SET_FAILURE' }),
|
|
13
14
|
type: z.number().int().refine((v) => setTypeValues.includes(v), { message: 'INVALID_SET_TYPE' }),
|
|
14
|
-
incident: EventIncidentSchema.optional()
|
|
15
|
+
incident: EventIncidentSchema.optional(),
|
|
16
|
+
// Set tempo (FIRST=0, SECOND=1, HIGH=2). Optional: absent on dumps, failed sets, and every pre-tempo event.
|
|
17
|
+
tempo: z.number().int().refine((v) => setTempoValues.includes(v), { message: 'INVALID_SET_TEMPO' }).optional()
|
|
15
18
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { SetInputSchema } from './set.z';
|
|
3
|
+
const base = {
|
|
4
|
+
playerId: '11111111-1111-4111-8111-111111111111',
|
|
5
|
+
score: 90,
|
|
6
|
+
target: 4,
|
|
7
|
+
activeTraits: [],
|
|
8
|
+
failure: 0,
|
|
9
|
+
type: 0
|
|
10
|
+
};
|
|
11
|
+
describe('SetInputSchema — tempo', () => {
|
|
12
|
+
it('accepts every tempo 0-2 (FIRST / SECOND / HIGH)', () => {
|
|
13
|
+
for (const tempo of [0, 1, 2]) {
|
|
14
|
+
expect(SetInputSchema.safeParse({ ...base, tempo }).success).toBe(true);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('accepts an absent tempo (Auto, dumps, failed sets)', () => {
|
|
18
|
+
expect(SetInputSchema.safeParse(base).success).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it('rejects an out-of-range tempo', () => {
|
|
21
|
+
expect(SetInputSchema.safeParse({ ...base, tempo: 3 }).success).toBe(false);
|
|
22
|
+
expect(SetInputSchema.safeParse({ ...base, tempo: -1 }).success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it('rejects a non-integer tempo', () => {
|
|
25
|
+
expect(SetInputSchema.safeParse({ ...base, tempo: 1.5 }).success).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -4,6 +4,12 @@ export declare enum SetTypeEnum {
|
|
|
4
4
|
DUMP = 1
|
|
5
5
|
}
|
|
6
6
|
export type SetType = SetTypeEnum.OVERHAND | SetTypeEnum.DUMP;
|
|
7
|
+
export declare enum SetTempoEnum {
|
|
8
|
+
FIRST = 0,
|
|
9
|
+
SECOND = 1,
|
|
10
|
+
HIGH = 2
|
|
11
|
+
}
|
|
12
|
+
export type SetTempo = SetTempoEnum.FIRST | SetTempoEnum.SECOND | SetTempoEnum.HIGH;
|
|
7
13
|
export declare enum SetFailureEnum {
|
|
8
14
|
NO_FAILURE = 0,
|
|
9
15
|
FAULT = 1,
|
|
@@ -15,6 +21,7 @@ export type SetFailure = SetFailureEnum.NO_FAILURE | SetFailureEnum.FAULT | SetF
|
|
|
15
21
|
export declare class Set extends InPlayEvent<SetFailure, SetType> {
|
|
16
22
|
readonly failure: SetFailure;
|
|
17
23
|
readonly type: SetType;
|
|
24
|
+
readonly tempo?: SetTempo;
|
|
18
25
|
static create(input: unknown): Set;
|
|
19
26
|
private constructor();
|
|
20
27
|
toString(): string;
|
|
@@ -6,6 +6,16 @@ export var SetTypeEnum;
|
|
|
6
6
|
SetTypeEnum[SetTypeEnum["OVERHAND"] = 0] = "OVERHAND";
|
|
7
7
|
SetTypeEnum[SetTypeEnum["DUMP"] = 1] = "DUMP";
|
|
8
8
|
})(SetTypeEnum || (SetTypeEnum = {}));
|
|
9
|
+
// The tempo of a set (set-tempo rework). Decided by the simulator (pass quality + target lane + setter skill)
|
|
10
|
+
// or forced by a per-player offensive preference. FIRST = quick (swings the `quick` stat, hardest to nail,
|
|
11
|
+
// hardest to block); SECOND = second tempo; HIGH = high ball (the fallback, easiest to block). Only present on
|
|
12
|
+
// a successful overhand set; absent for dumps, failed sets, and free-ball/scramble attacks.
|
|
13
|
+
export var SetTempoEnum;
|
|
14
|
+
(function (SetTempoEnum) {
|
|
15
|
+
SetTempoEnum[SetTempoEnum["FIRST"] = 0] = "FIRST";
|
|
16
|
+
SetTempoEnum[SetTempoEnum["SECOND"] = 1] = "SECOND";
|
|
17
|
+
SetTempoEnum[SetTempoEnum["HIGH"] = 2] = "HIGH";
|
|
18
|
+
})(SetTempoEnum || (SetTempoEnum = {}));
|
|
9
19
|
export var SetFailureEnum;
|
|
10
20
|
(function (SetFailureEnum) {
|
|
11
21
|
SetFailureEnum[SetFailureEnum["NO_FAILURE"] = 0] = "NO_FAILURE";
|
|
@@ -28,14 +38,16 @@ export class Set extends InPlayEvent {
|
|
|
28
38
|
}
|
|
29
39
|
return new Set(result.data);
|
|
30
40
|
}
|
|
31
|
-
constructor({ score, playerId, target, failure, type, activeTraits, incident }) {
|
|
41
|
+
constructor({ score, playerId, target, failure, type, activeTraits, incident, tempo }) {
|
|
32
42
|
super({ score, playerId, target, activeTraits, incident, eventType });
|
|
33
43
|
this.type = type;
|
|
34
44
|
this.failure = failure;
|
|
45
|
+
this.tempo = tempo;
|
|
35
46
|
}
|
|
36
47
|
toString() {
|
|
37
48
|
const failure = `"failure":${this.failure}`;
|
|
38
49
|
const type = `"type":${this.type}`;
|
|
39
|
-
|
|
50
|
+
const tempo = this.tempo !== undefined ? `,"tempo":${this.tempo}` : '';
|
|
51
|
+
return `{${this._toString()},${failure},${type}${tempo}}`;
|
|
40
52
|
}
|
|
41
53
|
}
|
|
@@ -270,6 +270,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
270
270
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
271
271
|
rotation: z.ZodNumber;
|
|
272
272
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
273
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
274
|
+
FAST: "FAST";
|
|
275
|
+
HIGH: "HIGH";
|
|
276
|
+
}>>>;
|
|
273
277
|
}, z.core.$strip>>>;
|
|
274
278
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
275
279
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
@@ -278,6 +282,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
278
282
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
279
283
|
rotation: z.ZodNumber;
|
|
280
284
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
285
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
286
|
+
FAST: "FAST";
|
|
287
|
+
HIGH: "HIGH";
|
|
288
|
+
}>>>;
|
|
281
289
|
}, z.core.$strip>>>>;
|
|
282
290
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
283
291
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -45,11 +45,15 @@ const lineupSchema = z.object({
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
|
+
// Per-player tempo preference (set-tempo rework): absent key = Auto.
|
|
49
|
+
const tempoPreferenceSchema = z.enum(['FAST', 'HIGH']);
|
|
48
50
|
// One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
|
|
49
|
-
// the attackers ranked best-first.
|
|
51
|
+
// the attackers ranked best-first. `tempos` optionally forces a per-starter tempo (Auto when absent). Cross-field
|
|
52
|
+
// checks (starters only in both order and tempos, no dupes) live in the superRefine below.
|
|
50
53
|
const offensivePreferenceSchema = z.object({
|
|
51
54
|
rotation: z.number().int().min(1).max(6),
|
|
52
|
-
order: z.array(playerInstanceSchema)
|
|
55
|
+
order: z.array(playerInstanceSchema),
|
|
56
|
+
tempos: z.record(z.string(), tempoPreferenceSchema).optional()
|
|
53
57
|
});
|
|
54
58
|
// One set's rotation-system override (per-set tactics): system + its designated setters travel together.
|
|
55
59
|
const systemSetSchema = z.object({
|
|
@@ -155,6 +159,12 @@ export const TacticsInputSchema = z.object({
|
|
|
155
159
|
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
|
|
156
160
|
}
|
|
157
161
|
}
|
|
162
|
+
// Tempo preference keys are starter ids too (same rule as order).
|
|
163
|
+
for (const id of Object.keys(pref.tempos ?? {})) {
|
|
164
|
+
if (!starterIds.has(id)) {
|
|
165
|
+
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
158
168
|
}
|
|
159
169
|
};
|
|
160
170
|
validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
|
|
@@ -168,6 +168,30 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
|
|
|
168
168
|
expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
|
+
it('accepts offensive preferences with a starter-keyed tempos map', () => {
|
|
172
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
173
|
+
offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
|
|
174
|
+
}));
|
|
175
|
+
expect(res.success).toBe(true);
|
|
176
|
+
if (res.success) {
|
|
177
|
+
expect(res.data.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it('rejects a tempos map keyed by a non-starter', () => {
|
|
181
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
182
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
|
|
183
|
+
}));
|
|
184
|
+
expect(res.success).toBe(false);
|
|
185
|
+
if (!res.success) {
|
|
186
|
+
expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER')).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it('rejects an invalid tempo value', () => {
|
|
190
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
191
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
|
|
192
|
+
}));
|
|
193
|
+
expect(res.success).toBe(false);
|
|
194
|
+
});
|
|
171
195
|
it('still validates the flat fields when per-set overrides are present', () => {
|
|
172
196
|
// Flat 5-1 with no setter is invalid even though every set entry is valid.
|
|
173
197
|
const res = TacticsInputSchema.safeParse(baseInput({
|
|
@@ -278,6 +278,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
278
278
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
279
279
|
rotation: z.ZodNumber;
|
|
280
280
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
281
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
282
|
+
FAST: "FAST";
|
|
283
|
+
HIGH: "HIGH";
|
|
284
|
+
}>>>;
|
|
281
285
|
}, z.core.$strip>>>;
|
|
282
286
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
283
287
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
@@ -286,6 +290,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
286
290
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
287
291
|
rotation: z.ZodNumber;
|
|
288
292
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
293
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
294
|
+
FAST: "FAST";
|
|
295
|
+
HIGH: "HIGH";
|
|
296
|
+
}>>>;
|
|
289
297
|
}, z.core.$strip>>>>;
|
|
290
298
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
291
299
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|