volleyballsimtypes 0.0.423 → 0.0.425

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.
@@ -16,7 +16,9 @@ export interface RallyMetricsSet {
16
16
  export interface RotationMetrics {
17
17
  setterZone: number | null;
18
18
  rotation: number;
19
+ serves: number;
19
20
  servePoints: number;
21
+ receptions: number;
20
22
  sideoutPoints: number;
21
23
  points: number;
22
24
  }
@@ -31,6 +33,9 @@ export interface TeamRallyMetrics {
31
33
  modReceptions: number;
32
34
  modSideoutWins: number;
33
35
  modSideoutPct: number;
36
+ fbsoWins: number;
37
+ fbsoPct: number;
38
+ modFbsoPct: number;
34
39
  rotations: RotationMetrics[];
35
40
  }
36
41
  export interface SetRallyMetrics {
@@ -4,12 +4,18 @@ exports.computeRallyMetrics = computeRallyMetrics;
4
4
  const event_1 = require("../event");
5
5
  const court_position_1 = require("./court-position");
6
6
  function newAcc() {
7
- return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, rotations: new Map() };
7
+ return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, fbsoWins: 0, rotations: new Map() };
8
8
  }
9
9
  function isServiceError(rally) {
10
10
  const serve = rally.events.find(e => e.eventType === event_1.EventTypeEnum.SERVE);
11
11
  return serve != null && (serve.failure ?? 0) > 0;
12
12
  }
13
+ // A first-ball sideout is won on the receiving team's first attack: the rally has exactly one spike (the
14
+ // engine scores a second spike as a transition). This counts first-ball kills and the block errors forced
15
+ // on that first attack, and excludes service-error sideouts (no spike) and dig-and-transition rallies (2+).
16
+ function spikeCount(rally) {
17
+ return rally.events.reduce((n, e) => e.eventType === event_1.EventTypeEnum.SPIKE ? n + 1 : n, 0);
18
+ }
13
19
  // The setter's zone after (rotationIndex - 1) rotations from their start zone, using the engine's own
14
20
  // rotatePosition (each rotation moves a player one zone: 2->1, 1->6, ...). null when no setter.
15
21
  function setterZoneAt(startZone, rotationIndex) {
@@ -20,17 +26,16 @@ function setterZoneAt(startZone, rotationIndex) {
20
26
  zone = (0, court_position_1.rotatePosition)(zone);
21
27
  return zone;
22
28
  }
23
- function addRotationPoint(acc, setterZone, rotation, kind) {
29
+ // Get (or create) a team's rotation bucket, keyed by setter zone when known (so the total sums by zone
30
+ // across sets), else by the rotation index.
31
+ function rotationEntry(acc, setterZone, rotation) {
24
32
  const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
25
33
  let entry = acc.rotations.get(key);
26
34
  if (entry == null) {
27
- entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
35
+ entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0, serves: 0, receptions: 0 };
28
36
  acc.rotations.set(key, entry);
29
37
  }
30
- if (kind === 'serve')
31
- entry.servePoints++;
32
- else
33
- entry.sideoutPoints++;
38
+ return entry;
34
39
  }
35
40
  // Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
36
41
  function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
@@ -47,22 +52,27 @@ function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
47
52
  const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
48
53
  const serving = accOf(servingId);
49
54
  const receiving = accOf(receivingId);
50
- const winner = accOf(winnerId);
51
- if (serving == null || receiving == null || winner == null)
55
+ if (serving == null || receiving == null || (winnerId !== servingId && winnerId !== receivingId))
52
56
  continue;
53
57
  serving.serves++;
54
58
  receiving.receptions++;
55
59
  if (isServiceError(rallies[i]))
56
60
  receiving.serviceErrorsReceived++;
57
- const rotIdx = rotation[winnerId];
58
- const zone = setterZoneAt(startZone[winnerId], rotIdx);
61
+ // Per-rotation attempts: both teams play this rally in their current rotation. The point below is
62
+ // attributed to the winner, whose bucket is the same serve (breakpoint) or reception (sideout) entry.
63
+ const serveEntry = rotationEntry(serving, setterZoneAt(startZone[servingId], rotation[servingId]), rotation[servingId]);
64
+ const recEntry = rotationEntry(receiving, setterZoneAt(startZone[receivingId], rotation[receivingId]), rotation[receivingId]);
65
+ serveEntry.serves++;
66
+ recEntry.receptions++;
59
67
  if (winnerId === servingId) {
60
68
  serving.breakpointWins++;
61
- addRotationPoint(winner, zone, rotIdx, 'serve');
69
+ serveEntry.servePoints++;
62
70
  }
63
71
  else {
64
72
  receiving.sideoutWins++;
65
- addRotationPoint(winner, zone, rotIdx, 'sideout');
73
+ if (spikeCount(rallies[i]) === 1)
74
+ receiving.fbsoWins++;
75
+ recEntry.sideoutPoints++;
66
76
  }
67
77
  // The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
68
78
  if (winnerId === receivingId)
@@ -86,6 +96,9 @@ function finalizeTeam(acc) {
86
96
  modReceptions,
87
97
  modSideoutWins,
88
98
  modSideoutPct: modReceptions > 0 ? (modSideoutWins / modReceptions) * 100 : 0,
99
+ fbsoWins: acc.fbsoWins,
100
+ fbsoPct: acc.receptions > 0 ? (acc.fbsoWins / acc.receptions) * 100 : 0,
101
+ modFbsoPct: modReceptions > 0 ? (acc.fbsoWins / modReceptions) * 100 : 0,
89
102
  rotations
90
103
  };
91
104
  }
@@ -48,12 +48,45 @@ describe('computeRallyMetrics()', () => {
48
48
  it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
49
49
  expect(m.sets).toHaveLength(1);
50
50
  expect(m.sets[0].home.rotations).toEqual([
51
- { setterZone: 1, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 },
52
- { setterZone: 2, rotation: 1, servePoints: 1, sideoutPoints: 1, points: 2 }
51
+ { setterZone: 1, rotation: 2, serves: 1, servePoints: 1, receptions: 0, sideoutPoints: 0, points: 1 },
52
+ { setterZone: 2, rotation: 1, serves: 2, servePoints: 1, receptions: 2, sideoutPoints: 1, points: 2 }
53
53
  ]);
54
54
  expect(m.sets[0].away.rotations).toEqual([
55
- { setterZone: 1, rotation: 1, servePoints: 0, sideoutPoints: 1, points: 1 },
56
- { setterZone: 6, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 }
55
+ { setterZone: 1, rotation: 1, serves: 0, servePoints: 0, receptions: 2, sideoutPoints: 1, points: 1 },
56
+ { setterZone: 6, rotation: 2, serves: 2, servePoints: 1, receptions: 1, sideoutPoints: 0, points: 1 }
57
57
  ]);
58
58
  });
59
59
  });
60
+ // First ball sideout: a sideout won on the receiving team's first attack (exactly one spike in the rally).
61
+ const serveEv = (failure = 0) => ({ eventType: event_1.EventTypeEnum.SERVE, failure });
62
+ const spikeEv = () => ({ eventType: event_1.EventTypeEnum.SPIKE, failure: 0 });
63
+ // serving H, A, H, A ; winners A, H, A, A (A 3 - H 1). Setter-agnostic, so no setters.
64
+ const fbsoSet = {
65
+ homePoints: 1,
66
+ awayPoints: 3,
67
+ homeSetterStartZone: null,
68
+ awaySetterStartZone: null,
69
+ rallies: [
70
+ { servingTeamId: HOME, events: [serveEv(), spikeEv()] }, // A sideout on 1 spike -> A FBSO
71
+ { servingTeamId: AWAY, events: [serveEv(), spikeEv(), spikeEv()] }, // H sideout on 2 spikes -> transition, not FBSO
72
+ { servingTeamId: HOME, events: [serveEv(1)] }, // A sideout via H service error, 0 spikes -> not FBSO
73
+ { servingTeamId: AWAY, events: [serveEv(), spikeEv()] } // A breakpoint (A serving) -> not a reception win
74
+ ]
75
+ };
76
+ const fm = (0, rally_metrics_1.computeRallyMetrics)([fbsoSet], HOME, AWAY);
77
+ describe('computeRallyMetrics() first ball sideout', () => {
78
+ it('counts only sideouts won on the first attack (one spike)', () => {
79
+ expect(fm.total.away.receptions).toBe(2);
80
+ expect(fm.total.away.fbsoWins).toBe(1);
81
+ expect(fm.total.away.fbsoPct).toBeCloseTo(50, 5);
82
+ });
83
+ it('modified FBSO excludes opponent service errors from the denominator', () => {
84
+ expect(fm.total.away.serviceErrorsReceived).toBe(1);
85
+ expect(fm.total.away.modFbsoPct).toBeCloseTo(100, 5);
86
+ });
87
+ it('ignores transition sideouts (2+ spikes) and reception losses', () => {
88
+ expect(fm.total.home.fbsoWins).toBe(0);
89
+ expect(fm.total.home.fbsoPct).toBe(0);
90
+ expect(fm.total.home.modFbsoPct).toBe(0);
91
+ });
92
+ });
@@ -16,7 +16,9 @@ export interface RallyMetricsSet {
16
16
  export interface RotationMetrics {
17
17
  setterZone: number | null;
18
18
  rotation: number;
19
+ serves: number;
19
20
  servePoints: number;
21
+ receptions: number;
20
22
  sideoutPoints: number;
21
23
  points: number;
22
24
  }
@@ -31,6 +33,9 @@ export interface TeamRallyMetrics {
31
33
  modReceptions: number;
32
34
  modSideoutWins: number;
33
35
  modSideoutPct: number;
36
+ fbsoWins: number;
37
+ fbsoPct: number;
38
+ modFbsoPct: number;
34
39
  rotations: RotationMetrics[];
35
40
  }
36
41
  export interface SetRallyMetrics {
@@ -1,12 +1,18 @@
1
1
  import { EventTypeEnum } from '../event';
2
2
  import { rotatePosition } from './court-position';
3
3
  function newAcc() {
4
- return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, rotations: new Map() };
4
+ return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, fbsoWins: 0, rotations: new Map() };
5
5
  }
6
6
  function isServiceError(rally) {
7
7
  const serve = rally.events.find(e => e.eventType === EventTypeEnum.SERVE);
8
8
  return serve != null && (serve.failure ?? 0) > 0;
9
9
  }
10
+ // A first-ball sideout is won on the receiving team's first attack: the rally has exactly one spike (the
11
+ // engine scores a second spike as a transition). This counts first-ball kills and the block errors forced
12
+ // on that first attack, and excludes service-error sideouts (no spike) and dig-and-transition rallies (2+).
13
+ function spikeCount(rally) {
14
+ return rally.events.reduce((n, e) => e.eventType === EventTypeEnum.SPIKE ? n + 1 : n, 0);
15
+ }
10
16
  // The setter's zone after (rotationIndex - 1) rotations from their start zone, using the engine's own
11
17
  // rotatePosition (each rotation moves a player one zone: 2->1, 1->6, ...). null when no setter.
12
18
  function setterZoneAt(startZone, rotationIndex) {
@@ -17,17 +23,16 @@ function setterZoneAt(startZone, rotationIndex) {
17
23
  zone = rotatePosition(zone);
18
24
  return zone;
19
25
  }
20
- function addRotationPoint(acc, setterZone, rotation, kind) {
26
+ // Get (or create) a team's rotation bucket, keyed by setter zone when known (so the total sums by zone
27
+ // across sets), else by the rotation index.
28
+ function rotationEntry(acc, setterZone, rotation) {
21
29
  const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
22
30
  let entry = acc.rotations.get(key);
23
31
  if (entry == null) {
24
- entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
32
+ entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0, serves: 0, receptions: 0 };
25
33
  acc.rotations.set(key, entry);
26
34
  }
27
- if (kind === 'serve')
28
- entry.servePoints++;
29
- else
30
- entry.sideoutPoints++;
35
+ return entry;
31
36
  }
32
37
  // Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
33
38
  function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
@@ -44,22 +49,27 @@ function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
44
49
  const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
45
50
  const serving = accOf(servingId);
46
51
  const receiving = accOf(receivingId);
47
- const winner = accOf(winnerId);
48
- if (serving == null || receiving == null || winner == null)
52
+ if (serving == null || receiving == null || (winnerId !== servingId && winnerId !== receivingId))
49
53
  continue;
50
54
  serving.serves++;
51
55
  receiving.receptions++;
52
56
  if (isServiceError(rallies[i]))
53
57
  receiving.serviceErrorsReceived++;
54
- const rotIdx = rotation[winnerId];
55
- const zone = setterZoneAt(startZone[winnerId], rotIdx);
58
+ // Per-rotation attempts: both teams play this rally in their current rotation. The point below is
59
+ // attributed to the winner, whose bucket is the same serve (breakpoint) or reception (sideout) entry.
60
+ const serveEntry = rotationEntry(serving, setterZoneAt(startZone[servingId], rotation[servingId]), rotation[servingId]);
61
+ const recEntry = rotationEntry(receiving, setterZoneAt(startZone[receivingId], rotation[receivingId]), rotation[receivingId]);
62
+ serveEntry.serves++;
63
+ recEntry.receptions++;
56
64
  if (winnerId === servingId) {
57
65
  serving.breakpointWins++;
58
- addRotationPoint(winner, zone, rotIdx, 'serve');
66
+ serveEntry.servePoints++;
59
67
  }
60
68
  else {
61
69
  receiving.sideoutWins++;
62
- addRotationPoint(winner, zone, rotIdx, 'sideout');
70
+ if (spikeCount(rallies[i]) === 1)
71
+ receiving.fbsoWins++;
72
+ recEntry.sideoutPoints++;
63
73
  }
64
74
  // The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
65
75
  if (winnerId === receivingId)
@@ -83,6 +93,9 @@ function finalizeTeam(acc) {
83
93
  modReceptions,
84
94
  modSideoutWins,
85
95
  modSideoutPct: modReceptions > 0 ? (modSideoutWins / modReceptions) * 100 : 0,
96
+ fbsoWins: acc.fbsoWins,
97
+ fbsoPct: acc.receptions > 0 ? (acc.fbsoWins / acc.receptions) * 100 : 0,
98
+ modFbsoPct: modReceptions > 0 ? (acc.fbsoWins / modReceptions) * 100 : 0,
86
99
  rotations
87
100
  };
88
101
  }
@@ -46,12 +46,45 @@ describe('computeRallyMetrics()', () => {
46
46
  it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
47
47
  expect(m.sets).toHaveLength(1);
48
48
  expect(m.sets[0].home.rotations).toEqual([
49
- { setterZone: 1, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 },
50
- { setterZone: 2, rotation: 1, servePoints: 1, sideoutPoints: 1, points: 2 }
49
+ { setterZone: 1, rotation: 2, serves: 1, servePoints: 1, receptions: 0, sideoutPoints: 0, points: 1 },
50
+ { setterZone: 2, rotation: 1, serves: 2, servePoints: 1, receptions: 2, sideoutPoints: 1, points: 2 }
51
51
  ]);
52
52
  expect(m.sets[0].away.rotations).toEqual([
53
- { setterZone: 1, rotation: 1, servePoints: 0, sideoutPoints: 1, points: 1 },
54
- { setterZone: 6, rotation: 2, servePoints: 1, sideoutPoints: 0, points: 1 }
53
+ { setterZone: 1, rotation: 1, serves: 0, servePoints: 0, receptions: 2, sideoutPoints: 1, points: 1 },
54
+ { setterZone: 6, rotation: 2, serves: 2, servePoints: 1, receptions: 1, sideoutPoints: 0, points: 1 }
55
55
  ]);
56
56
  });
57
57
  });
58
+ // First ball sideout: a sideout won on the receiving team's first attack (exactly one spike in the rally).
59
+ const serveEv = (failure = 0) => ({ eventType: EventTypeEnum.SERVE, failure });
60
+ const spikeEv = () => ({ eventType: EventTypeEnum.SPIKE, failure: 0 });
61
+ // serving H, A, H, A ; winners A, H, A, A (A 3 - H 1). Setter-agnostic, so no setters.
62
+ const fbsoSet = {
63
+ homePoints: 1,
64
+ awayPoints: 3,
65
+ homeSetterStartZone: null,
66
+ awaySetterStartZone: null,
67
+ rallies: [
68
+ { servingTeamId: HOME, events: [serveEv(), spikeEv()] }, // A sideout on 1 spike -> A FBSO
69
+ { servingTeamId: AWAY, events: [serveEv(), spikeEv(), spikeEv()] }, // H sideout on 2 spikes -> transition, not FBSO
70
+ { servingTeamId: HOME, events: [serveEv(1)] }, // A sideout via H service error, 0 spikes -> not FBSO
71
+ { servingTeamId: AWAY, events: [serveEv(), spikeEv()] } // A breakpoint (A serving) -> not a reception win
72
+ ]
73
+ };
74
+ const fm = computeRallyMetrics([fbsoSet], HOME, AWAY);
75
+ describe('computeRallyMetrics() first ball sideout', () => {
76
+ it('counts only sideouts won on the first attack (one spike)', () => {
77
+ expect(fm.total.away.receptions).toBe(2);
78
+ expect(fm.total.away.fbsoWins).toBe(1);
79
+ expect(fm.total.away.fbsoPct).toBeCloseTo(50, 5);
80
+ });
81
+ it('modified FBSO excludes opponent service errors from the denominator', () => {
82
+ expect(fm.total.away.serviceErrorsReceived).toBe(1);
83
+ expect(fm.total.away.modFbsoPct).toBeCloseTo(100, 5);
84
+ });
85
+ it('ignores transition sideouts (2+ spikes) and reception losses', () => {
86
+ expect(fm.total.home.fbsoWins).toBe(0);
87
+ expect(fm.total.home.fbsoPct).toBe(0);
88
+ expect(fm.total.home.modFbsoPct).toBe(0);
89
+ });
90
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.423",
3
+ "version": "0.0.425",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",