volleyballsimtypes 0.0.513 → 0.0.514

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 (33) hide show
  1. package/dist/cjs/src/data/transformers/keyframe-codec.d.ts +7 -0
  2. package/dist/cjs/src/data/transformers/keyframe-codec.js +66 -0
  3. package/dist/cjs/src/data/transformers/keyframe-codec.test.d.ts +1 -0
  4. package/dist/cjs/src/data/transformers/keyframe-codec.test.js +46 -0
  5. package/dist/cjs/src/data/transformers/rally.js +17 -4
  6. package/dist/cjs/src/data/transformers/rally.test.js +33 -0
  7. package/dist/cjs/src/service/match/index.d.ts +1 -0
  8. package/dist/cjs/src/service/match/index.js +1 -0
  9. package/dist/cjs/src/service/match/rally.d.ts +6 -0
  10. package/dist/cjs/src/service/match/rally.js +2 -1
  11. package/dist/cjs/src/service/match/replay-geometry.d.ts +8 -0
  12. package/dist/cjs/src/service/match/replay-geometry.js +45 -0
  13. package/dist/cjs/src/service/match/replay-geometry.test.d.ts +1 -0
  14. package/dist/cjs/src/service/match/replay-geometry.test.js +77 -0
  15. package/dist/cjs/src/service/match/schemas/rally.z.d.ts +5 -0
  16. package/dist/cjs/src/service/match/schemas/rally.z.js +4 -1
  17. package/dist/esm/src/data/transformers/keyframe-codec.d.ts +7 -0
  18. package/dist/esm/src/data/transformers/keyframe-codec.js +62 -0
  19. package/dist/esm/src/data/transformers/keyframe-codec.test.d.ts +1 -0
  20. package/dist/esm/src/data/transformers/keyframe-codec.test.js +44 -0
  21. package/dist/esm/src/data/transformers/rally.js +17 -4
  22. package/dist/esm/src/data/transformers/rally.test.js +33 -0
  23. package/dist/esm/src/service/match/index.d.ts +1 -0
  24. package/dist/esm/src/service/match/index.js +1 -0
  25. package/dist/esm/src/service/match/rally.d.ts +6 -0
  26. package/dist/esm/src/service/match/rally.js +2 -1
  27. package/dist/esm/src/service/match/replay-geometry.d.ts +8 -0
  28. package/dist/esm/src/service/match/replay-geometry.js +38 -0
  29. package/dist/esm/src/service/match/replay-geometry.test.d.ts +1 -0
  30. package/dist/esm/src/service/match/replay-geometry.test.js +75 -0
  31. package/dist/esm/src/service/match/schemas/rally.z.d.ts +5 -0
  32. package/dist/esm/src/service/match/schemas/rally.z.js +4 -1
  33. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ export interface KeyframeCoord {
2
+ readonly playerId: string;
3
+ readonly x: number;
4
+ readonly y: number;
5
+ }
6
+ export declare function encodeKeyframes(keyframes: Array<KeyframeCoord[] | undefined>, playerIndex: Map<string, number>): number[][];
7
+ export declare function decodeKeyframes(k: number[][], roster: string[]): KeyframeCoord[][];
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ // Compact codec for the replay position keyframes (owner 2026-08-11): quantize each coordinate to decimetres and
3
+ // delta-encode against the running frame, so a rally stores only the players who MOVED each event (players teleport
4
+ // only at phase changes, so most events add almost nothing). The result rides the existing msgpack + deflate rally
5
+ // blob. VISUALIZATION ONLY, additive + optional. See VolleyballSim/plans/positional-replay-keyframes.md.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.encodeKeyframes = encodeKeyframes;
8
+ exports.decodeKeyframes = decodeKeyframes;
9
+ // Decimetre quantization (~0.1 m, ample for a replay): x in [-4.5,4.5] -> qx in [-45,45], y in [-9,9] -> qy in [-90,90].
10
+ const SCALE = 10;
11
+ function quant(n) { return Math.round(n * SCALE); }
12
+ function unquant(n) { return n / SCALE; }
13
+ // Encode keyframes (aligned by index to a rally's events) into delta arrays: `k[i]` = flat `[idx, qx, qy, ...]` for the
14
+ // players whose quantized position CHANGED at event i vs the running map. An empty entry = carry the prior frame (a
15
+ // carry-forward event, or no movement). Trailing empty frames are trimmed. Players are referenced by `playerIndex`
16
+ // (the caller extends the rally roster to cover every keyframe player).
17
+ function encodeKeyframes(keyframes, playerIndex) {
18
+ const px = new Map();
19
+ const py = new Map();
20
+ const out = [];
21
+ for (const kf of keyframes) {
22
+ if (kf == null) {
23
+ out.push([]);
24
+ continue;
25
+ }
26
+ const delta = [];
27
+ for (const p of kf) {
28
+ const idx = playerIndex.get(p.playerId);
29
+ if (idx == null)
30
+ continue;
31
+ const qx = quant(p.x);
32
+ const qy = quant(p.y);
33
+ if (px.get(idx) === qx && py.get(idx) === qy)
34
+ continue;
35
+ px.set(idx, qx);
36
+ py.set(idx, qy);
37
+ delta.push(idx, qx, qy);
38
+ }
39
+ out.push(delta);
40
+ }
41
+ while (out.length > 0 && out[out.length - 1].length === 0)
42
+ out.pop();
43
+ return out;
44
+ }
45
+ // Decode the delta arrays back to a per-event keyframe. Frame i = the full running map after applying deltas 0..i
46
+ // (so a carry-forward frame repeats the prior positions). Returns one frame per encoded entry; events past the
47
+ // encoded length reuse the last frame.
48
+ function decodeKeyframes(k, roster) {
49
+ const px = new Map();
50
+ const py = new Map();
51
+ const out = [];
52
+ for (const delta of k) {
53
+ for (let j = 0; j + 2 < delta.length; j += 3) {
54
+ px.set(delta[j], delta[j + 1]);
55
+ py.set(delta[j], delta[j + 2]);
56
+ }
57
+ const frame = [];
58
+ for (const [idx, qx] of px) {
59
+ const id = roster[idx];
60
+ if (id != null)
61
+ frame.push({ playerId: id, x: unquant(qx), y: unquant(py.get(idx)) });
62
+ }
63
+ out.push(frame);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const keyframe_codec_1 = require("./keyframe-codec");
5
+ const idx = new Map([['A', 0], ['B', 1]]);
6
+ const roster = ['A', 'B'];
7
+ (0, globals_1.describe)('keyframe-codec round trip', () => {
8
+ (0, globals_1.it)('quantizes to decimetres and restores within 0.1 m', () => {
9
+ const frames = [[{ playerId: 'A', x: 1.23, y: -2.34 }, { playerId: 'B', x: 3, y: 4.5 }]];
10
+ const [out] = (0, keyframe_codec_1.decodeKeyframes)((0, keyframe_codec_1.encodeKeyframes)(frames, idx), roster);
11
+ (0, globals_1.expect)(out.find(p => p.playerId === 'A')).toEqual({ playerId: 'A', x: 1.2, y: -2.3 });
12
+ (0, globals_1.expect)(out.find(p => p.playerId === 'B')).toEqual({ playerId: 'B', x: 3, y: 4.5 });
13
+ });
14
+ (0, globals_1.it)('stores only the players who moved (delta), and carries the rest forward', () => {
15
+ const frames = [
16
+ [{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: 3, y: 3 }],
17
+ [{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: -1, y: 2 }] // A unchanged, B moved
18
+ ];
19
+ const k = (0, keyframe_codec_1.encodeKeyframes)(frames, idx);
20
+ (0, globals_1.expect)(k[0]).toEqual([0, 10, 10, 1, 30, 30]); // both players
21
+ (0, globals_1.expect)(k[1]).toEqual([1, -10, 20]); // only B
22
+ const dec = (0, keyframe_codec_1.decodeKeyframes)(k, roster);
23
+ (0, globals_1.expect)(dec[0]).toEqual([{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: 3, y: 3 }]);
24
+ (0, globals_1.expect)(dec[1]).toEqual([{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: -1, y: 2 }]); // A carried forward
25
+ });
26
+ (0, globals_1.it)('an empty (carry-forward) frame repeats the prior positions', () => {
27
+ const frames = [
28
+ [{ playerId: 'A', x: 1, y: 1 }],
29
+ undefined, // carry forward
30
+ [{ playerId: 'A', x: 2, y: 2 }]
31
+ ];
32
+ const k = (0, keyframe_codec_1.encodeKeyframes)(frames, idx);
33
+ (0, globals_1.expect)(k).toEqual([[0, 10, 10], [], [0, 20, 20]]);
34
+ const dec = (0, keyframe_codec_1.decodeKeyframes)(k, roster);
35
+ (0, globals_1.expect)(dec[1]).toEqual([{ playerId: 'A', x: 1, y: 1 }]); // unchanged at the carry-forward frame
36
+ (0, globals_1.expect)(dec[2]).toEqual([{ playerId: 'A', x: 2, y: 2 }]);
37
+ });
38
+ (0, globals_1.it)('trims trailing empty frames', () => {
39
+ const frames = [[{ playerId: 'A', x: 1, y: 1 }], undefined, undefined];
40
+ (0, globals_1.expect)((0, keyframe_codec_1.encodeKeyframes)(frames, idx)).toEqual([[0, 10, 10]]);
41
+ });
42
+ (0, globals_1.it)('skips players absent from the roster index', () => {
43
+ const frames = [[{ playerId: 'A', x: 1, y: 1 }, { playerId: 'GHOST', x: 5, y: 5 }]];
44
+ (0, globals_1.expect)((0, keyframe_codec_1.encodeKeyframes)(frames, idx)).toEqual([[0, 10, 10]]);
45
+ });
46
+ });
@@ -6,13 +6,14 @@ const msgpackr_1 = require("msgpackr");
6
6
  const zlib_1 = require("zlib");
7
7
  const lz_string_1 = require("lz-string");
8
8
  const service_1 = require("../../service");
9
+ const keyframe_codec_1 = require("./keyframe-codec");
9
10
  const rally_event_1 = require("./rally-event");
10
11
  const VERSION_BYTE = 0x01;
11
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
13
  function deflate(data) { return (0, zlib_1.deflateRawSync)(data); }
13
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
15
  function inflate(data) { return (0, zlib_1.inflateRawSync)(data); }
15
- function buildRoster(events) {
16
+ function buildRoster(events, keyframes) {
16
17
  const seen = [];
17
18
  const index = new Map();
18
19
  function add(id) {
@@ -33,14 +34,22 @@ function buildRoster(events) {
33
34
  add(evt.playerOut);
34
35
  }
35
36
  }
37
+ // Keyframes include non-touching on-court players, so extend the roster to cover them (appended after the event
38
+ // players, which keep their indices, so the compact events are unaffected).
39
+ if (keyframes != null)
40
+ for (const kf of keyframes)
41
+ if (kf != null)
42
+ for (const p of kf)
43
+ add(p.playerId);
36
44
  return index;
37
45
  }
38
46
  function transformToAttributes(rally, setId) {
39
- const playerIndex = buildRoster(rally.events);
47
+ const playerIndex = buildRoster(rally.events, rally.keyframes);
40
48
  const roster = Array.from(playerIndex.keys());
41
49
  const payload = {
42
50
  r: roster,
43
- e: rally.events.map(evt => (0, rally_event_1.transformToCompact)(evt, playerIndex))
51
+ e: rally.events.map(evt => (0, rally_event_1.transformToCompact)(evt, playerIndex)),
52
+ ...(rally.keyframes != null ? { k: (0, keyframe_codec_1.encodeKeyframes)(rally.keyframes, playerIndex) } : {})
44
53
  };
45
54
  const compressed = deflate((0, msgpackr_1.pack)(payload));
46
55
  const events = Buffer.from([VERSION_BYTE, ...compressed]);
@@ -81,9 +90,12 @@ function decodeLegacyEvent(event) {
81
90
  function transformToObject(model) {
82
91
  const raw = model.events;
83
92
  let events;
93
+ let keyframes;
84
94
  if (raw[0] === VERSION_BYTE) {
85
95
  const payload = (0, msgpackr_1.unpack)(inflate(raw.subarray(1)));
86
96
  events = payload.e.map(evt => decodeCompactEvent(evt, payload.r));
97
+ if (payload.k != null)
98
+ keyframes = (0, keyframe_codec_1.decodeKeyframes)(payload.k, payload.r);
87
99
  }
88
100
  else {
89
101
  // Legacy: lz-string base64 text stored as UTF-8 bytes in the bytea column
@@ -97,6 +109,7 @@ function transformToObject(model) {
97
109
  id: model.rally_id,
98
110
  order: model.order,
99
111
  servingTeamId: model.serving_team,
100
- events
112
+ events,
113
+ keyframes
101
114
  });
102
115
  }
@@ -60,3 +60,36 @@ const rally_1 = require("./rally");
60
60
  (0, globals_1.expect)(free.failure).toBe(service_1.FreeBallFailureEnum.NO_FAILURE);
61
61
  });
62
62
  });
63
+ (0, globals_1.describe)('rally compact round trip — position keyframes', () => {
64
+ const pA = (0, uuid_1.v4)(); // touches (serve)
65
+ const pB = (0, uuid_1.v4)(); // touches (reception)
66
+ const pC = (0, uuid_1.v4)(); // NON-touching: only appears in keyframes, so the roster must be extended to cover it
67
+ const setId = (0, uuid_1.v4)();
68
+ const events = [
69
+ service_1.Serve.create({ playerId: pA, target: 6, score: 55, failure: service_1.ServeFailureEnum.NO_FAILURE, type: service_1.ServeTypeEnum.JUMP_TOPSPIN }),
70
+ service_1.Reception.create({ playerId: pB, target: 5, score: 50, failure: service_1.ReceptionFailureEnum.NO_FAILURE, type: service_1.ReceptionTypeEnum.DIG })
71
+ ];
72
+ const keyframes = [
73
+ [{ playerId: pA, x: 1.23, y: -2.34 }, { playerId: pB, x: 3, y: 4.5 }, { playerId: pC, x: -4, y: 8 }],
74
+ [{ playerId: pA, x: 1.23, y: -2.34 }, { playerId: pB, x: -1, y: 2 }, { playerId: pC, x: -4, y: 8 }] // only pB moved
75
+ ];
76
+ const rally = service_1.Rally.create({ id: (0, uuid_1.v4)(), order: 0, servingTeamId: (0, uuid_1.v4)(), events, keyframes });
77
+ (0, globals_1.it)('encodes then decodes the keyframes through the bytea (quantized to 0.1 m, non-toucher included)', () => {
78
+ const decoded = (0, rally_1.transformToRally)((0, rally_1.transformFromRally)(rally, setId));
79
+ const kfs = decoded.keyframes ?? [];
80
+ (0, globals_1.expect)(kfs).toHaveLength(2);
81
+ const f0 = new Map(kfs[0].map(p => [p.playerId, p]));
82
+ (0, globals_1.expect)(f0.get(pA)).toEqual({ playerId: pA, x: 1.2, y: -2.3 });
83
+ (0, globals_1.expect)(f0.get(pB)).toEqual({ playerId: pB, x: 3, y: 4.5 });
84
+ (0, globals_1.expect)(f0.get(pC)).toEqual({ playerId: pC, x: -4, y: 8 }); // the non-touching player survived the roster extension
85
+ const f1 = new Map(kfs[1].map(p => [p.playerId, p]));
86
+ (0, globals_1.expect)(f1.get(pA)).toEqual({ playerId: pA, x: 1.2, y: -2.3 }); // carried forward (unchanged)
87
+ (0, globals_1.expect)(f1.get(pB)).toEqual({ playerId: pB, x: -1, y: 2 });
88
+ (0, globals_1.expect)(f1.get(pC)).toEqual({ playerId: pC, x: -4, y: 8 }); // carried forward
89
+ });
90
+ (0, globals_1.it)('omits the keyframe block entirely for a rally without keyframes', () => {
91
+ const plain = service_1.Rally.create({ id: (0, uuid_1.v4)(), order: 0, servingTeamId: (0, uuid_1.v4)(), events });
92
+ const decoded = (0, rally_1.transformToRally)((0, rally_1.transformFromRally)(plain, setId));
93
+ (0, globals_1.expect)(decoded.keyframes).toBeUndefined();
94
+ });
95
+ });
@@ -10,3 +10,4 @@ export * from './rally-metrics';
10
10
  export * from './court-position';
11
11
  export * from './match-team';
12
12
  export * from './vper';
13
+ export * from './replay-geometry';
@@ -26,3 +26,4 @@ __exportStar(require("./rally-metrics"), exports);
26
26
  __exportStar(require("./court-position"), exports);
27
27
  __exportStar(require("./match-team"), exports);
28
28
  __exportStar(require("./vper"), exports);
29
+ __exportStar(require("./replay-geometry"), exports);
@@ -4,11 +4,17 @@ export interface PlayerPosition {
4
4
  position: CourtPosition;
5
5
  playerId: string;
6
6
  }
7
+ export interface KeyframePosition {
8
+ readonly playerId: string;
9
+ readonly x: number;
10
+ readonly y: number;
11
+ }
7
12
  export declare class Rally {
8
13
  readonly id: string;
9
14
  readonly servingTeamId: string;
10
15
  readonly events: RallyEvent[];
11
16
  readonly order: number;
17
+ readonly keyframes?: KeyframePosition[][];
12
18
  static create(input: unknown): Rally;
13
19
  private constructor();
14
20
  addEvent(event: RallyEvent): void;
@@ -15,11 +15,12 @@ class Rally {
15
15
  }
16
16
  return new Rally(result.data);
17
17
  }
18
- constructor({ id, order, servingTeamId, events }) {
18
+ constructor({ id, order, servingTeamId, events, keyframes }) {
19
19
  this.id = id;
20
20
  this.order = order;
21
21
  this.servingTeamId = servingTeamId;
22
22
  this.events = events;
23
+ this.keyframes = keyframes;
23
24
  }
24
25
  addEvent(event) {
25
26
  this.events.push(event);
@@ -0,0 +1,8 @@
1
+ import { EventCoord } from '../event/in-play-event';
2
+ export declare const COURT_BASELINE = 9;
3
+ export declare const COURT_HALF_WIDTH = 4.5;
4
+ export type DefendSign = -1 | 1;
5
+ export declare function defendSign(defenderIsHome: boolean): DefendSign;
6
+ export declare function blockCenter(attackSpot: EventCoord, side: DefendSign): EventCoord;
7
+ export declare function joinedBlockerSpots(center: EventCoord, count: number): EventCoord[];
8
+ export declare function failedBlockerSpot(homeNetX: number, center: EventCoord, reach: number): EventCoord;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COURT_HALF_WIDTH = exports.COURT_BASELINE = void 0;
4
+ exports.defendSign = defendSign;
5
+ exports.blockCenter = blockCenter;
6
+ exports.joinedBlockerSpots = joinedBlockerSpots;
7
+ exports.failedBlockerSpot = failedBlockerSpot;
8
+ // A back line sits at |y| = 9; the wall stands just off the net on the defending side; the court is 9 m wide.
9
+ exports.COURT_BASELINE = 9;
10
+ exports.COURT_HALF_WIDTH = 4.5;
11
+ const NET_STANDOFF = 0.3; // the block sits this far onto the defending side of the net
12
+ const WALL_ANTENNA = exports.COURT_HALF_WIDTH - 0.1; // keep a drawn token inside the antennas
13
+ const WALL_GAP = 0.75; // side-by-side spacing of blockers in the wall (~shoulder width; also keeps drawn tokens from overlapping)
14
+ function defendSign(defenderIsHome) {
15
+ return defenderIsHome ? -1 : 1;
16
+ }
17
+ function clampX(x) {
18
+ return Math.max(-WALL_ANTENNA, Math.min(WALL_ANTENNA, x));
19
+ }
20
+ // The block group CENTER for an attack (owner 2026-08-11): draw the sight line from the DEFENDING back-line center
21
+ // (0, side*9) to the attack spot, and take where it crosses the net. Because the line pivots on the back-line center,
22
+ // a wider or deeper attack pulls the block off the straight-in-front line toward the middle (it takes the line and
23
+ // leaves the cross open), while a straight-on attack sits the block dead in front.
24
+ function blockCenter(attackSpot, side) {
25
+ const toNet = exports.COURT_BASELINE / (exports.COURT_BASELINE + Math.abs(attackSpot.y)); // fraction of the line from back line to net
26
+ return { x: clampX(attackSpot.x * toNet), y: side * NET_STANDOFF };
27
+ }
28
+ // The joined blockers stand side by side around the block center along the net. Order the caller's blockers by their
29
+ // base lateral position and hand each the matching slot.
30
+ function joinedBlockerSpots(center, count) {
31
+ const spots = [];
32
+ for (let i = 0; i < count; i++) {
33
+ spots.push({ x: clampX(center.x + (i - (count - 1) / 2) * WALL_GAP), y: center.y });
34
+ }
35
+ return spots;
36
+ }
37
+ // A candidate blocker that FAILED its join roll (owner 2026-08-11): if the block spot is within its reach it is drawn
38
+ // where it meant to block; otherwise it is caught out and drawn at the EDGE of its reach on the line toward the block
39
+ // spot. `homeNetX` = the blocker's own net-lane position (its base lateral coord). `reach` = how far along the net it
40
+ // can travel (the sim block model's reflex reach, passed in so this module carries no sim dependency).
41
+ function failedBlockerSpot(homeNetX, center, reach) {
42
+ const dx = center.x - homeNetX;
43
+ const step = Math.max(-reach, Math.min(reach, dx));
44
+ return { x: clampX(homeNetX + step), y: center.y };
45
+ }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const replay_geometry_1 = require("./replay-geometry");
5
+ (0, globals_1.describe)('defendSign', () => {
6
+ (0, globals_1.it)('maps HOME to -1 (own court y<0) and AWAY to +1', () => {
7
+ (0, globals_1.expect)((0, replay_geometry_1.defendSign)(true)).toBe(-1);
8
+ (0, globals_1.expect)((0, replay_geometry_1.defendSign)(false)).toBe(1);
9
+ });
10
+ });
11
+ (0, globals_1.describe)('blockCenter', () => {
12
+ (0, globals_1.it)('a straight-on attack (x=0) sits the block dead in front', () => {
13
+ const c = (0, replay_geometry_1.blockCenter)({ x: 0, y: 3 }, -1);
14
+ (0, globals_1.expect)(c.x).toBeCloseTo(0, 6);
15
+ (0, globals_1.expect)(c.y).toBeCloseTo(-0.3, 6); // on the defending (HOME) side of the net
16
+ });
17
+ (0, globals_1.it)('a front-row attack (y=3) draws the block from the back-line center', () => {
18
+ // toNet = 9 / (9 + 3) = 0.75 -> x = 4 * 0.75 = 3.0
19
+ const c = (0, replay_geometry_1.blockCenter)({ x: 4, y: 3 }, -1);
20
+ (0, globals_1.expect)(c.x).toBeCloseTo(3, 6);
21
+ (0, globals_1.expect)(c.y).toBeCloseTo(-0.3, 6);
22
+ });
23
+ (0, globals_1.it)('a deeper attack pulls the block further toward the middle (covers the cross)', () => {
24
+ const shallow = (0, replay_geometry_1.blockCenter)({ x: 4, y: 3 }, -1).x; // 3.0
25
+ const deep = (0, replay_geometry_1.blockCenter)({ x: 4, y: 9 }, -1).x; // 9/18 = 0.5 -> 2.0
26
+ (0, globals_1.expect)(deep).toBeCloseTo(2, 6);
27
+ (0, globals_1.expect)(deep).toBeLessThan(shallow); // deeper attack -> block closer to center
28
+ });
29
+ (0, globals_1.it)('mirrors for an AWAY defender (attacker on the y<0 side)', () => {
30
+ const c = (0, replay_geometry_1.blockCenter)({ x: -4, y: -3 }, 1);
31
+ (0, globals_1.expect)(c.x).toBeCloseTo(-3, 6);
32
+ (0, globals_1.expect)(c.y).toBeCloseTo(0.3, 6); // on the defending (AWAY) side of the net
33
+ });
34
+ (0, globals_1.it)('clamps the center inside the antennas', () => {
35
+ const c = (0, replay_geometry_1.blockCenter)({ x: 4.5, y: 0 }, -1); // toNet = 1 -> x would be 4.5
36
+ (0, globals_1.expect)(c.x).toBeCloseTo(4.4, 6);
37
+ });
38
+ });
39
+ (0, globals_1.describe)('joinedBlockerSpots', () => {
40
+ const center = { x: 3, y: -0.3 };
41
+ (0, globals_1.it)('a single blocker sits on the center', () => {
42
+ const [a] = (0, replay_geometry_1.joinedBlockerSpots)(center, 1);
43
+ (0, globals_1.expect)(a.x).toBeCloseTo(3, 6);
44
+ (0, globals_1.expect)(a.y).toBeCloseTo(-0.3, 6);
45
+ });
46
+ (0, globals_1.it)('a double straddles the center by half a gap each', () => {
47
+ const [a, b] = (0, replay_geometry_1.joinedBlockerSpots)(center, 2);
48
+ (0, globals_1.expect)(a.x).toBeCloseTo(2.625, 6);
49
+ (0, globals_1.expect)(b.x).toBeCloseTo(3.375, 6);
50
+ });
51
+ (0, globals_1.it)('a triple spreads one gap apart around the center', () => {
52
+ const [a, b, c] = (0, replay_geometry_1.joinedBlockerSpots)(center, 3);
53
+ (0, globals_1.expect)(a.x).toBeCloseTo(2.25, 6);
54
+ (0, globals_1.expect)(b.x).toBeCloseTo(3, 6);
55
+ (0, globals_1.expect)(c.x).toBeCloseTo(3.75, 6);
56
+ });
57
+ });
58
+ (0, globals_1.describe)('failedBlockerSpot', () => {
59
+ const center = { x: 3, y: -0.3 };
60
+ (0, globals_1.it)('within reach: drawn at the block spot it meant to reach', () => {
61
+ const s = (0, replay_geometry_1.failedBlockerSpot)(0, center, 5); // dx = 3, reach 5 -> reaches the center
62
+ (0, globals_1.expect)(s.x).toBeCloseTo(3, 6);
63
+ (0, globals_1.expect)(s.y).toBeCloseTo(-0.3, 6);
64
+ });
65
+ (0, globals_1.it)('out of reach: drawn at the edge of its reach toward the block spot', () => {
66
+ const s = (0, replay_geometry_1.failedBlockerSpot)(0, center, 1); // dx = 3, reach 1 -> stops 1 m along
67
+ (0, globals_1.expect)(s.x).toBeCloseTo(1, 6);
68
+ });
69
+ (0, globals_1.it)('moves left toward a block spot on its left', () => {
70
+ const s = (0, replay_geometry_1.failedBlockerSpot)(4, center, 0.5); // dx = -1, reach 0.5 -> 4 - 0.5 = 3.5
71
+ (0, globals_1.expect)(s.x).toBeCloseTo(3.5, 6);
72
+ });
73
+ (0, globals_1.it)('clamps inside the antennas', () => {
74
+ const s = (0, replay_geometry_1.failedBlockerSpot)(4.3, { x: 4.5, y: -0.3 }, 1); // 4.3 + 0.2 = 4.5 -> clamped
75
+ (0, globals_1.expect)(s.x).toBeCloseTo(4.4, 6);
76
+ });
77
+ });
@@ -5,5 +5,10 @@ export declare const RallyInputSchema: z.ZodObject<{
5
5
  order: z.ZodNumber;
6
6
  servingTeamId: z.ZodUUID;
7
7
  events: z.ZodArray<z.ZodCustom<RallyEvent, RallyEvent>>;
8
+ keyframes: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
9
+ playerId: z.ZodString;
10
+ x: z.ZodNumber;
11
+ y: z.ZodNumber;
12
+ }, z.core.$strip>>>>;
8
13
  }, z.core.$strip>;
9
14
  export type RallyInput = z.infer<typeof RallyInputSchema>;
@@ -10,5 +10,8 @@ exports.RallyInputSchema = zod_1.z.object({
10
10
  servingTeamId: zod_1.z.uuid(),
11
11
  events: zod_1.z.array(zod_1.z.custom((v) => v instanceof event_1.RallyEvent, {
12
12
  message: 'INVALID_RALLY_EVENT_INSTANCE'
13
- }))
13
+ })),
14
+ // Optional replay position keyframes (owner 2026-08-11), aligned by index to `events`: keyframes[i] = every
15
+ // on-court player's coord at events[i]. Present only for captured (user, non-event) matches; visualization only.
16
+ keyframes: zod_1.z.array(zod_1.z.array(zod_1.z.object({ playerId: zod_1.z.string(), x: zod_1.z.number(), y: zod_1.z.number() }))).optional()
14
17
  });
@@ -0,0 +1,7 @@
1
+ export interface KeyframeCoord {
2
+ readonly playerId: string;
3
+ readonly x: number;
4
+ readonly y: number;
5
+ }
6
+ export declare function encodeKeyframes(keyframes: Array<KeyframeCoord[] | undefined>, playerIndex: Map<string, number>): number[][];
7
+ export declare function decodeKeyframes(k: number[][], roster: string[]): KeyframeCoord[][];
@@ -0,0 +1,62 @@
1
+ // Compact codec for the replay position keyframes (owner 2026-08-11): quantize each coordinate to decimetres and
2
+ // delta-encode against the running frame, so a rally stores only the players who MOVED each event (players teleport
3
+ // only at phase changes, so most events add almost nothing). The result rides the existing msgpack + deflate rally
4
+ // blob. VISUALIZATION ONLY, additive + optional. See VolleyballSim/plans/positional-replay-keyframes.md.
5
+ // Decimetre quantization (~0.1 m, ample for a replay): x in [-4.5,4.5] -> qx in [-45,45], y in [-9,9] -> qy in [-90,90].
6
+ const SCALE = 10;
7
+ function quant(n) { return Math.round(n * SCALE); }
8
+ function unquant(n) { return n / SCALE; }
9
+ // Encode keyframes (aligned by index to a rally's events) into delta arrays: `k[i]` = flat `[idx, qx, qy, ...]` for the
10
+ // players whose quantized position CHANGED at event i vs the running map. An empty entry = carry the prior frame (a
11
+ // carry-forward event, or no movement). Trailing empty frames are trimmed. Players are referenced by `playerIndex`
12
+ // (the caller extends the rally roster to cover every keyframe player).
13
+ export function encodeKeyframes(keyframes, playerIndex) {
14
+ const px = new Map();
15
+ const py = new Map();
16
+ const out = [];
17
+ for (const kf of keyframes) {
18
+ if (kf == null) {
19
+ out.push([]);
20
+ continue;
21
+ }
22
+ const delta = [];
23
+ for (const p of kf) {
24
+ const idx = playerIndex.get(p.playerId);
25
+ if (idx == null)
26
+ continue;
27
+ const qx = quant(p.x);
28
+ const qy = quant(p.y);
29
+ if (px.get(idx) === qx && py.get(idx) === qy)
30
+ continue;
31
+ px.set(idx, qx);
32
+ py.set(idx, qy);
33
+ delta.push(idx, qx, qy);
34
+ }
35
+ out.push(delta);
36
+ }
37
+ while (out.length > 0 && out[out.length - 1].length === 0)
38
+ out.pop();
39
+ return out;
40
+ }
41
+ // Decode the delta arrays back to a per-event keyframe. Frame i = the full running map after applying deltas 0..i
42
+ // (so a carry-forward frame repeats the prior positions). Returns one frame per encoded entry; events past the
43
+ // encoded length reuse the last frame.
44
+ export function decodeKeyframes(k, roster) {
45
+ const px = new Map();
46
+ const py = new Map();
47
+ const out = [];
48
+ for (const delta of k) {
49
+ for (let j = 0; j + 2 < delta.length; j += 3) {
50
+ px.set(delta[j], delta[j + 1]);
51
+ py.set(delta[j], delta[j + 2]);
52
+ }
53
+ const frame = [];
54
+ for (const [idx, qx] of px) {
55
+ const id = roster[idx];
56
+ if (id != null)
57
+ frame.push({ playerId: id, x: unquant(qx), y: unquant(py.get(idx)) });
58
+ }
59
+ out.push(frame);
60
+ }
61
+ return out;
62
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { encodeKeyframes, decodeKeyframes } from './keyframe-codec';
3
+ const idx = new Map([['A', 0], ['B', 1]]);
4
+ const roster = ['A', 'B'];
5
+ describe('keyframe-codec round trip', () => {
6
+ it('quantizes to decimetres and restores within 0.1 m', () => {
7
+ const frames = [[{ playerId: 'A', x: 1.23, y: -2.34 }, { playerId: 'B', x: 3, y: 4.5 }]];
8
+ const [out] = decodeKeyframes(encodeKeyframes(frames, idx), roster);
9
+ expect(out.find(p => p.playerId === 'A')).toEqual({ playerId: 'A', x: 1.2, y: -2.3 });
10
+ expect(out.find(p => p.playerId === 'B')).toEqual({ playerId: 'B', x: 3, y: 4.5 });
11
+ });
12
+ it('stores only the players who moved (delta), and carries the rest forward', () => {
13
+ const frames = [
14
+ [{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: 3, y: 3 }],
15
+ [{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: -1, y: 2 }] // A unchanged, B moved
16
+ ];
17
+ const k = encodeKeyframes(frames, idx);
18
+ expect(k[0]).toEqual([0, 10, 10, 1, 30, 30]); // both players
19
+ expect(k[1]).toEqual([1, -10, 20]); // only B
20
+ const dec = decodeKeyframes(k, roster);
21
+ expect(dec[0]).toEqual([{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: 3, y: 3 }]);
22
+ expect(dec[1]).toEqual([{ playerId: 'A', x: 1, y: 1 }, { playerId: 'B', x: -1, y: 2 }]); // A carried forward
23
+ });
24
+ it('an empty (carry-forward) frame repeats the prior positions', () => {
25
+ const frames = [
26
+ [{ playerId: 'A', x: 1, y: 1 }],
27
+ undefined, // carry forward
28
+ [{ playerId: 'A', x: 2, y: 2 }]
29
+ ];
30
+ const k = encodeKeyframes(frames, idx);
31
+ expect(k).toEqual([[0, 10, 10], [], [0, 20, 20]]);
32
+ const dec = decodeKeyframes(k, roster);
33
+ expect(dec[1]).toEqual([{ playerId: 'A', x: 1, y: 1 }]); // unchanged at the carry-forward frame
34
+ expect(dec[2]).toEqual([{ playerId: 'A', x: 2, y: 2 }]);
35
+ });
36
+ it('trims trailing empty frames', () => {
37
+ const frames = [[{ playerId: 'A', x: 1, y: 1 }], undefined, undefined];
38
+ expect(encodeKeyframes(frames, idx)).toEqual([[0, 10, 10]]);
39
+ });
40
+ it('skips players absent from the roster index', () => {
41
+ const frames = [[{ playerId: 'A', x: 1, y: 1 }, { playerId: 'GHOST', x: 5, y: 5 }]];
42
+ expect(encodeKeyframes(frames, idx)).toEqual([[0, 10, 10]]);
43
+ });
44
+ });
@@ -2,13 +2,14 @@ import { pack, unpack } from 'msgpackr';
2
2
  import { deflateRawSync, inflateRawSync } from 'zlib';
3
3
  import { decompressFromBase64 } from 'lz-string';
4
4
  import { Block, FreeBall, LiberoReplacement, Rally, Reception, Serve, Set as VolleySet, Spike, Substitution } from '../../service';
5
+ import { encodeKeyframes, decodeKeyframes } from './keyframe-codec';
5
6
  import { transformToBlock, transformToCompact, transformToFreeBall, transformToLiberoReplacement, transformToReception, transformToServe, transformToSet, transformToSpike, transformToSubstitution } from './rally-event';
6
7
  const VERSION_BYTE = 0x01;
7
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
9
  function deflate(data) { return deflateRawSync(data); }
9
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
11
  function inflate(data) { return inflateRawSync(data); }
11
- function buildRoster(events) {
12
+ function buildRoster(events, keyframes) {
12
13
  const seen = [];
13
14
  const index = new Map();
14
15
  function add(id) {
@@ -29,14 +30,22 @@ function buildRoster(events) {
29
30
  add(evt.playerOut);
30
31
  }
31
32
  }
33
+ // Keyframes include non-touching on-court players, so extend the roster to cover them (appended after the event
34
+ // players, which keep their indices, so the compact events are unaffected).
35
+ if (keyframes != null)
36
+ for (const kf of keyframes)
37
+ if (kf != null)
38
+ for (const p of kf)
39
+ add(p.playerId);
32
40
  return index;
33
41
  }
34
42
  function transformToAttributes(rally, setId) {
35
- const playerIndex = buildRoster(rally.events);
43
+ const playerIndex = buildRoster(rally.events, rally.keyframes);
36
44
  const roster = Array.from(playerIndex.keys());
37
45
  const payload = {
38
46
  r: roster,
39
- e: rally.events.map(evt => transformToCompact(evt, playerIndex))
47
+ e: rally.events.map(evt => transformToCompact(evt, playerIndex)),
48
+ ...(rally.keyframes != null ? { k: encodeKeyframes(rally.keyframes, playerIndex) } : {})
40
49
  };
41
50
  const compressed = deflate(pack(payload));
42
51
  const events = Buffer.from([VERSION_BYTE, ...compressed]);
@@ -77,9 +86,12 @@ function decodeLegacyEvent(event) {
77
86
  function transformToObject(model) {
78
87
  const raw = model.events;
79
88
  let events;
89
+ let keyframes;
80
90
  if (raw[0] === VERSION_BYTE) {
81
91
  const payload = unpack(inflate(raw.subarray(1)));
82
92
  events = payload.e.map(evt => decodeCompactEvent(evt, payload.r));
93
+ if (payload.k != null)
94
+ keyframes = decodeKeyframes(payload.k, payload.r);
83
95
  }
84
96
  else {
85
97
  // Legacy: lz-string base64 text stored as UTF-8 bytes in the bytea column
@@ -93,7 +105,8 @@ function transformToObject(model) {
93
105
  id: model.rally_id,
94
106
  order: model.order,
95
107
  servingTeamId: model.serving_team,
96
- events
108
+ events,
109
+ keyframes
97
110
  });
98
111
  }
99
112
  export { transformToObject as transformToRally, transformToAttributes as transformFromRally };
@@ -58,3 +58,36 @@ describe('rally compact round trip — every event type', () => {
58
58
  expect(free.failure).toBe(FreeBallFailureEnum.NO_FAILURE);
59
59
  });
60
60
  });
61
+ describe('rally compact round trip — position keyframes', () => {
62
+ const pA = uuidv4(); // touches (serve)
63
+ const pB = uuidv4(); // touches (reception)
64
+ const pC = uuidv4(); // NON-touching: only appears in keyframes, so the roster must be extended to cover it
65
+ const setId = uuidv4();
66
+ const events = [
67
+ Serve.create({ playerId: pA, target: 6, score: 55, failure: ServeFailureEnum.NO_FAILURE, type: ServeTypeEnum.JUMP_TOPSPIN }),
68
+ Reception.create({ playerId: pB, target: 5, score: 50, failure: ReceptionFailureEnum.NO_FAILURE, type: ReceptionTypeEnum.DIG })
69
+ ];
70
+ const keyframes = [
71
+ [{ playerId: pA, x: 1.23, y: -2.34 }, { playerId: pB, x: 3, y: 4.5 }, { playerId: pC, x: -4, y: 8 }],
72
+ [{ playerId: pA, x: 1.23, y: -2.34 }, { playerId: pB, x: -1, y: 2 }, { playerId: pC, x: -4, y: 8 }] // only pB moved
73
+ ];
74
+ const rally = Rally.create({ id: uuidv4(), order: 0, servingTeamId: uuidv4(), events, keyframes });
75
+ it('encodes then decodes the keyframes through the bytea (quantized to 0.1 m, non-toucher included)', () => {
76
+ const decoded = transformToRally(transformFromRally(rally, setId));
77
+ const kfs = decoded.keyframes ?? [];
78
+ expect(kfs).toHaveLength(2);
79
+ const f0 = new Map(kfs[0].map(p => [p.playerId, p]));
80
+ expect(f0.get(pA)).toEqual({ playerId: pA, x: 1.2, y: -2.3 });
81
+ expect(f0.get(pB)).toEqual({ playerId: pB, x: 3, y: 4.5 });
82
+ expect(f0.get(pC)).toEqual({ playerId: pC, x: -4, y: 8 }); // the non-touching player survived the roster extension
83
+ const f1 = new Map(kfs[1].map(p => [p.playerId, p]));
84
+ expect(f1.get(pA)).toEqual({ playerId: pA, x: 1.2, y: -2.3 }); // carried forward (unchanged)
85
+ expect(f1.get(pB)).toEqual({ playerId: pB, x: -1, y: 2 });
86
+ expect(f1.get(pC)).toEqual({ playerId: pC, x: -4, y: 8 }); // carried forward
87
+ });
88
+ it('omits the keyframe block entirely for a rally without keyframes', () => {
89
+ const plain = Rally.create({ id: uuidv4(), order: 0, servingTeamId: uuidv4(), events });
90
+ const decoded = transformToRally(transformFromRally(plain, setId));
91
+ expect(decoded.keyframes).toBeUndefined();
92
+ });
93
+ });
@@ -10,3 +10,4 @@ export * from './rally-metrics';
10
10
  export * from './court-position';
11
11
  export * from './match-team';
12
12
  export * from './vper';
13
+ export * from './replay-geometry';
@@ -10,3 +10,4 @@ export * from './rally-metrics';
10
10
  export * from './court-position';
11
11
  export * from './match-team';
12
12
  export * from './vper';
13
+ export * from './replay-geometry';
@@ -4,11 +4,17 @@ export interface PlayerPosition {
4
4
  position: CourtPosition;
5
5
  playerId: string;
6
6
  }
7
+ export interface KeyframePosition {
8
+ readonly playerId: string;
9
+ readonly x: number;
10
+ readonly y: number;
11
+ }
7
12
  export declare class Rally {
8
13
  readonly id: string;
9
14
  readonly servingTeamId: string;
10
15
  readonly events: RallyEvent[];
11
16
  readonly order: number;
17
+ readonly keyframes?: KeyframePosition[][];
12
18
  static create(input: unknown): Rally;
13
19
  private constructor();
14
20
  addEvent(event: RallyEvent): void;
@@ -12,11 +12,12 @@ export class Rally {
12
12
  }
13
13
  return new Rally(result.data);
14
14
  }
15
- constructor({ id, order, servingTeamId, events }) {
15
+ constructor({ id, order, servingTeamId, events, keyframes }) {
16
16
  this.id = id;
17
17
  this.order = order;
18
18
  this.servingTeamId = servingTeamId;
19
19
  this.events = events;
20
+ this.keyframes = keyframes;
20
21
  }
21
22
  addEvent(event) {
22
23
  this.events.push(event);
@@ -0,0 +1,8 @@
1
+ import { EventCoord } from '../event/in-play-event';
2
+ export declare const COURT_BASELINE = 9;
3
+ export declare const COURT_HALF_WIDTH = 4.5;
4
+ export type DefendSign = -1 | 1;
5
+ export declare function defendSign(defenderIsHome: boolean): DefendSign;
6
+ export declare function blockCenter(attackSpot: EventCoord, side: DefendSign): EventCoord;
7
+ export declare function joinedBlockerSpots(center: EventCoord, count: number): EventCoord[];
8
+ export declare function failedBlockerSpot(homeNetX: number, center: EventCoord, reach: number): EventCoord;
@@ -0,0 +1,38 @@
1
+ // A back line sits at |y| = 9; the wall stands just off the net on the defending side; the court is 9 m wide.
2
+ export const COURT_BASELINE = 9;
3
+ export const COURT_HALF_WIDTH = 4.5;
4
+ const NET_STANDOFF = 0.3; // the block sits this far onto the defending side of the net
5
+ const WALL_ANTENNA = COURT_HALF_WIDTH - 0.1; // keep a drawn token inside the antennas
6
+ const WALL_GAP = 0.75; // side-by-side spacing of blockers in the wall (~shoulder width; also keeps drawn tokens from overlapping)
7
+ export function defendSign(defenderIsHome) {
8
+ return defenderIsHome ? -1 : 1;
9
+ }
10
+ function clampX(x) {
11
+ return Math.max(-WALL_ANTENNA, Math.min(WALL_ANTENNA, x));
12
+ }
13
+ // The block group CENTER for an attack (owner 2026-08-11): draw the sight line from the DEFENDING back-line center
14
+ // (0, side*9) to the attack spot, and take where it crosses the net. Because the line pivots on the back-line center,
15
+ // a wider or deeper attack pulls the block off the straight-in-front line toward the middle (it takes the line and
16
+ // leaves the cross open), while a straight-on attack sits the block dead in front.
17
+ export function blockCenter(attackSpot, side) {
18
+ const toNet = COURT_BASELINE / (COURT_BASELINE + Math.abs(attackSpot.y)); // fraction of the line from back line to net
19
+ return { x: clampX(attackSpot.x * toNet), y: side * NET_STANDOFF };
20
+ }
21
+ // The joined blockers stand side by side around the block center along the net. Order the caller's blockers by their
22
+ // base lateral position and hand each the matching slot.
23
+ export function joinedBlockerSpots(center, count) {
24
+ const spots = [];
25
+ for (let i = 0; i < count; i++) {
26
+ spots.push({ x: clampX(center.x + (i - (count - 1) / 2) * WALL_GAP), y: center.y });
27
+ }
28
+ return spots;
29
+ }
30
+ // A candidate blocker that FAILED its join roll (owner 2026-08-11): if the block spot is within its reach it is drawn
31
+ // where it meant to block; otherwise it is caught out and drawn at the EDGE of its reach on the line toward the block
32
+ // spot. `homeNetX` = the blocker's own net-lane position (its base lateral coord). `reach` = how far along the net it
33
+ // can travel (the sim block model's reflex reach, passed in so this module carries no sim dependency).
34
+ export function failedBlockerSpot(homeNetX, center, reach) {
35
+ const dx = center.x - homeNetX;
36
+ const step = Math.max(-reach, Math.min(reach, dx));
37
+ return { x: clampX(homeNetX + step), y: center.y };
38
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { blockCenter, joinedBlockerSpots, failedBlockerSpot, defendSign } from './replay-geometry';
3
+ describe('defendSign', () => {
4
+ it('maps HOME to -1 (own court y<0) and AWAY to +1', () => {
5
+ expect(defendSign(true)).toBe(-1);
6
+ expect(defendSign(false)).toBe(1);
7
+ });
8
+ });
9
+ describe('blockCenter', () => {
10
+ it('a straight-on attack (x=0) sits the block dead in front', () => {
11
+ const c = blockCenter({ x: 0, y: 3 }, -1);
12
+ expect(c.x).toBeCloseTo(0, 6);
13
+ expect(c.y).toBeCloseTo(-0.3, 6); // on the defending (HOME) side of the net
14
+ });
15
+ it('a front-row attack (y=3) draws the block from the back-line center', () => {
16
+ // toNet = 9 / (9 + 3) = 0.75 -> x = 4 * 0.75 = 3.0
17
+ const c = blockCenter({ x: 4, y: 3 }, -1);
18
+ expect(c.x).toBeCloseTo(3, 6);
19
+ expect(c.y).toBeCloseTo(-0.3, 6);
20
+ });
21
+ it('a deeper attack pulls the block further toward the middle (covers the cross)', () => {
22
+ const shallow = blockCenter({ x: 4, y: 3 }, -1).x; // 3.0
23
+ const deep = blockCenter({ x: 4, y: 9 }, -1).x; // 9/18 = 0.5 -> 2.0
24
+ expect(deep).toBeCloseTo(2, 6);
25
+ expect(deep).toBeLessThan(shallow); // deeper attack -> block closer to center
26
+ });
27
+ it('mirrors for an AWAY defender (attacker on the y<0 side)', () => {
28
+ const c = blockCenter({ x: -4, y: -3 }, 1);
29
+ expect(c.x).toBeCloseTo(-3, 6);
30
+ expect(c.y).toBeCloseTo(0.3, 6); // on the defending (AWAY) side of the net
31
+ });
32
+ it('clamps the center inside the antennas', () => {
33
+ const c = blockCenter({ x: 4.5, y: 0 }, -1); // toNet = 1 -> x would be 4.5
34
+ expect(c.x).toBeCloseTo(4.4, 6);
35
+ });
36
+ });
37
+ describe('joinedBlockerSpots', () => {
38
+ const center = { x: 3, y: -0.3 };
39
+ it('a single blocker sits on the center', () => {
40
+ const [a] = joinedBlockerSpots(center, 1);
41
+ expect(a.x).toBeCloseTo(3, 6);
42
+ expect(a.y).toBeCloseTo(-0.3, 6);
43
+ });
44
+ it('a double straddles the center by half a gap each', () => {
45
+ const [a, b] = joinedBlockerSpots(center, 2);
46
+ expect(a.x).toBeCloseTo(2.625, 6);
47
+ expect(b.x).toBeCloseTo(3.375, 6);
48
+ });
49
+ it('a triple spreads one gap apart around the center', () => {
50
+ const [a, b, c] = joinedBlockerSpots(center, 3);
51
+ expect(a.x).toBeCloseTo(2.25, 6);
52
+ expect(b.x).toBeCloseTo(3, 6);
53
+ expect(c.x).toBeCloseTo(3.75, 6);
54
+ });
55
+ });
56
+ describe('failedBlockerSpot', () => {
57
+ const center = { x: 3, y: -0.3 };
58
+ it('within reach: drawn at the block spot it meant to reach', () => {
59
+ const s = failedBlockerSpot(0, center, 5); // dx = 3, reach 5 -> reaches the center
60
+ expect(s.x).toBeCloseTo(3, 6);
61
+ expect(s.y).toBeCloseTo(-0.3, 6);
62
+ });
63
+ it('out of reach: drawn at the edge of its reach toward the block spot', () => {
64
+ const s = failedBlockerSpot(0, center, 1); // dx = 3, reach 1 -> stops 1 m along
65
+ expect(s.x).toBeCloseTo(1, 6);
66
+ });
67
+ it('moves left toward a block spot on its left', () => {
68
+ const s = failedBlockerSpot(4, center, 0.5); // dx = -1, reach 0.5 -> 4 - 0.5 = 3.5
69
+ expect(s.x).toBeCloseTo(3.5, 6);
70
+ });
71
+ it('clamps inside the antennas', () => {
72
+ const s = failedBlockerSpot(4.3, { x: 4.5, y: -0.3 }, 1); // 4.3 + 0.2 = 4.5 -> clamped
73
+ expect(s.x).toBeCloseTo(4.4, 6);
74
+ });
75
+ });
@@ -5,5 +5,10 @@ export declare const RallyInputSchema: z.ZodObject<{
5
5
  order: z.ZodNumber;
6
6
  servingTeamId: z.ZodUUID;
7
7
  events: z.ZodArray<z.ZodCustom<RallyEvent, RallyEvent>>;
8
+ keyframes: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
9
+ playerId: z.ZodString;
10
+ x: z.ZodNumber;
11
+ y: z.ZodNumber;
12
+ }, z.core.$strip>>>>;
8
13
  }, z.core.$strip>;
9
14
  export type RallyInput = z.infer<typeof RallyInputSchema>;
@@ -7,5 +7,8 @@ export const RallyInputSchema = z.object({
7
7
  servingTeamId: z.uuid(),
8
8
  events: z.array(z.custom((v) => v instanceof RallyEvent, {
9
9
  message: 'INVALID_RALLY_EVENT_INSTANCE'
10
- }))
10
+ })),
11
+ // Optional replay position keyframes (owner 2026-08-11), aligned by index to `events`: keyframes[i] = every
12
+ // on-court player's coord at events[i]. Present only for captured (user, non-event) matches; visualization only.
13
+ keyframes: z.array(z.array(z.object({ playerId: z.string(), x: z.number(), y: z.number() }))).optional()
11
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.513",
3
+ "version": "0.0.514",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",