volleyballsimtypes 0.0.422 → 0.0.424
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 +2 -1
- package/dist/cjs/src/service/match/index.d.ts +1 -0
- package/dist/cjs/src/service/match/index.js +1 -0
- package/dist/cjs/src/service/match/rally-metrics.d.ts +47 -0
- package/dist/cjs/src/service/match/rally-metrics.js +115 -0
- package/dist/cjs/src/service/match/rally-metrics.test.d.ts +1 -0
- package/dist/cjs/src/service/match/rally-metrics.test.js +92 -0
- package/dist/esm/src/api/index.d.ts +2 -1
- package/dist/esm/src/service/match/index.d.ts +1 -0
- package/dist/esm/src/service/match/index.js +1 -0
- package/dist/esm/src/service/match/rally-metrics.d.ts +47 -0
- package/dist/esm/src/service/match/rally-metrics.js +112 -0
- package/dist/esm/src/service/match/rally-metrics.test.d.ts +1 -0
- package/dist/esm/src/service/match/rally-metrics.test.js +90 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, PerformanceStats, PinchCondition, RotationSystemEnum, SubBand, SubMode } from '../service';
|
|
1
|
+
import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, RallyMetrics, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, PerformanceStats, PinchCondition, RotationSystemEnum, SubBand, SubMode } from '../service';
|
|
2
2
|
export type Rally = DataProps<_Rally> & {
|
|
3
3
|
homePlayerPosition: PlayerPosition[];
|
|
4
4
|
awayPlayerPosition: PlayerPosition[];
|
|
@@ -36,6 +36,7 @@ export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | '
|
|
|
36
36
|
national?: National;
|
|
37
37
|
results?: Results;
|
|
38
38
|
competition?: MatchCompetition;
|
|
39
|
+
rallyMetrics?: RallyMetrics;
|
|
39
40
|
};
|
|
40
41
|
export type RallyEvent = DataProps<_RallyEvent> & {
|
|
41
42
|
team?: Team;
|
|
@@ -21,6 +21,7 @@ __exportStar(require("./match-rating"), exports);
|
|
|
21
21
|
__exportStar(require("./match-set"), exports);
|
|
22
22
|
__exportStar(require("./rally"), exports);
|
|
23
23
|
__exportStar(require("./point-breakdown"), exports);
|
|
24
|
+
__exportStar(require("./rally-metrics"), exports);
|
|
24
25
|
__exportStar(require("./court-position"), exports);
|
|
25
26
|
__exportStar(require("./match-team"), exports);
|
|
26
27
|
__exportStar(require("./vper"), exports);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RallyMetricsEvent {
|
|
2
|
+
readonly eventType: number;
|
|
3
|
+
readonly failure?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface RallyMetricsRally {
|
|
6
|
+
readonly servingTeamId: string;
|
|
7
|
+
readonly events: readonly RallyMetricsEvent[];
|
|
8
|
+
}
|
|
9
|
+
export interface RallyMetricsSet {
|
|
10
|
+
readonly rallies: readonly RallyMetricsRally[];
|
|
11
|
+
readonly homePoints: number;
|
|
12
|
+
readonly awayPoints: number;
|
|
13
|
+
readonly homeSetterStartZone: number | null;
|
|
14
|
+
readonly awaySetterStartZone: number | null;
|
|
15
|
+
}
|
|
16
|
+
export interface RotationMetrics {
|
|
17
|
+
setterZone: number | null;
|
|
18
|
+
rotation: number;
|
|
19
|
+
servePoints: number;
|
|
20
|
+
sideoutPoints: number;
|
|
21
|
+
points: number;
|
|
22
|
+
}
|
|
23
|
+
export interface TeamRallyMetrics {
|
|
24
|
+
serves: number;
|
|
25
|
+
breakpointWins: number;
|
|
26
|
+
breakpointPct: number;
|
|
27
|
+
receptions: number;
|
|
28
|
+
sideoutWins: number;
|
|
29
|
+
sideoutPct: number;
|
|
30
|
+
serviceErrorsReceived: number;
|
|
31
|
+
modReceptions: number;
|
|
32
|
+
modSideoutWins: number;
|
|
33
|
+
modSideoutPct: number;
|
|
34
|
+
fbsoWins: number;
|
|
35
|
+
fbsoPct: number;
|
|
36
|
+
modFbsoPct: number;
|
|
37
|
+
rotations: RotationMetrics[];
|
|
38
|
+
}
|
|
39
|
+
export interface SetRallyMetrics {
|
|
40
|
+
home: TeamRallyMetrics;
|
|
41
|
+
away: TeamRallyMetrics;
|
|
42
|
+
}
|
|
43
|
+
export interface RallyMetrics {
|
|
44
|
+
total: SetRallyMetrics;
|
|
45
|
+
sets: SetRallyMetrics[];
|
|
46
|
+
}
|
|
47
|
+
export declare function computeRallyMetrics(sets: readonly RallyMetricsSet[], homeTeamId: string, awayTeamId: string): RallyMetrics;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeRallyMetrics = computeRallyMetrics;
|
|
4
|
+
const event_1 = require("../event");
|
|
5
|
+
const court_position_1 = require("./court-position");
|
|
6
|
+
function newAcc() {
|
|
7
|
+
return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, fbsoWins: 0, rotations: new Map() };
|
|
8
|
+
}
|
|
9
|
+
function isServiceError(rally) {
|
|
10
|
+
const serve = rally.events.find(e => e.eventType === event_1.EventTypeEnum.SERVE);
|
|
11
|
+
return serve != null && (serve.failure ?? 0) > 0;
|
|
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
|
+
}
|
|
19
|
+
// The setter's zone after (rotationIndex - 1) rotations from their start zone, using the engine's own
|
|
20
|
+
// rotatePosition (each rotation moves a player one zone: 2->1, 1->6, ...). null when no setter.
|
|
21
|
+
function setterZoneAt(startZone, rotationIndex) {
|
|
22
|
+
if (startZone == null)
|
|
23
|
+
return null;
|
|
24
|
+
let zone = startZone;
|
|
25
|
+
for (let r = 1; r < rotationIndex; r++)
|
|
26
|
+
zone = (0, court_position_1.rotatePosition)(zone);
|
|
27
|
+
return zone;
|
|
28
|
+
}
|
|
29
|
+
function addRotationPoint(acc, setterZone, rotation, kind) {
|
|
30
|
+
const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
|
|
31
|
+
let entry = acc.rotations.get(key);
|
|
32
|
+
if (entry == null) {
|
|
33
|
+
entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
|
|
34
|
+
acc.rotations.set(key, entry);
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'serve')
|
|
37
|
+
entry.servePoints++;
|
|
38
|
+
else
|
|
39
|
+
entry.sideoutPoints++;
|
|
40
|
+
}
|
|
41
|
+
// Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
|
|
42
|
+
function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
|
|
43
|
+
const { rallies } = set;
|
|
44
|
+
if (rallies.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
const setWinnerId = set.homePoints > set.awayPoints ? homeId : awayId;
|
|
47
|
+
const rotation = { [homeId]: 1, [awayId]: 1 };
|
|
48
|
+
const startZone = { [homeId]: set.homeSetterStartZone, [awayId]: set.awaySetterStartZone };
|
|
49
|
+
const accOf = (id) => id === homeId ? homeAcc : id === awayId ? awayAcc : null;
|
|
50
|
+
for (let i = 0; i < rallies.length; i++) {
|
|
51
|
+
const servingId = rallies[i].servingTeamId;
|
|
52
|
+
const receivingId = servingId === homeId ? awayId : homeId;
|
|
53
|
+
const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
|
|
54
|
+
const serving = accOf(servingId);
|
|
55
|
+
const receiving = accOf(receivingId);
|
|
56
|
+
const winner = accOf(winnerId);
|
|
57
|
+
if (serving == null || receiving == null || winner == null)
|
|
58
|
+
continue;
|
|
59
|
+
serving.serves++;
|
|
60
|
+
receiving.receptions++;
|
|
61
|
+
if (isServiceError(rallies[i]))
|
|
62
|
+
receiving.serviceErrorsReceived++;
|
|
63
|
+
const rotIdx = rotation[winnerId];
|
|
64
|
+
const zone = setterZoneAt(startZone[winnerId], rotIdx);
|
|
65
|
+
if (winnerId === servingId) {
|
|
66
|
+
serving.breakpointWins++;
|
|
67
|
+
addRotationPoint(winner, zone, rotIdx, 'serve');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
receiving.sideoutWins++;
|
|
71
|
+
if (spikeCount(rallies[i]) === 1)
|
|
72
|
+
receiving.fbsoWins++;
|
|
73
|
+
addRotationPoint(winner, zone, rotIdx, 'sideout');
|
|
74
|
+
}
|
|
75
|
+
// The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
|
|
76
|
+
if (winnerId === receivingId)
|
|
77
|
+
rotation[winnerId] = (rotation[winnerId] % 6) + 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function finalizeTeam(acc) {
|
|
81
|
+
const modReceptions = acc.receptions - acc.serviceErrorsReceived;
|
|
82
|
+
const modSideoutWins = acc.sideoutWins - acc.serviceErrorsReceived;
|
|
83
|
+
const rotations = [...acc.rotations.values()]
|
|
84
|
+
.map(e => ({ ...e, points: e.servePoints + e.sideoutPoints }))
|
|
85
|
+
.sort((a, b) => (a.setterZone ?? a.rotation) - (b.setterZone ?? b.rotation));
|
|
86
|
+
return {
|
|
87
|
+
serves: acc.serves,
|
|
88
|
+
breakpointWins: acc.breakpointWins,
|
|
89
|
+
breakpointPct: acc.serves > 0 ? (acc.breakpointWins / acc.serves) * 100 : 0,
|
|
90
|
+
receptions: acc.receptions,
|
|
91
|
+
sideoutWins: acc.sideoutWins,
|
|
92
|
+
sideoutPct: acc.receptions > 0 ? (acc.sideoutWins / acc.receptions) * 100 : 0,
|
|
93
|
+
serviceErrorsReceived: acc.serviceErrorsReceived,
|
|
94
|
+
modReceptions,
|
|
95
|
+
modSideoutWins,
|
|
96
|
+
modSideoutPct: modReceptions > 0 ? (modSideoutWins / modReceptions) * 100 : 0,
|
|
97
|
+
fbsoWins: acc.fbsoWins,
|
|
98
|
+
fbsoPct: acc.receptions > 0 ? (acc.fbsoWins / acc.receptions) * 100 : 0,
|
|
99
|
+
modFbsoPct: modReceptions > 0 ? (acc.fbsoWins / modReceptions) * 100 : 0,
|
|
100
|
+
rotations
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function computeRallyMetrics(sets, homeTeamId, awayTeamId) {
|
|
104
|
+
const totalHome = newAcc();
|
|
105
|
+
const totalAway = newAcc();
|
|
106
|
+
const setResults = [];
|
|
107
|
+
for (const set of sets) {
|
|
108
|
+
const setHome = newAcc();
|
|
109
|
+
const setAway = newAcc();
|
|
110
|
+
accumulateSet(set, homeTeamId, awayTeamId, setHome, setAway);
|
|
111
|
+
accumulateSet(set, homeTeamId, awayTeamId, totalHome, totalAway);
|
|
112
|
+
setResults.push({ home: finalizeTeam(setHome), away: finalizeTeam(setAway) });
|
|
113
|
+
}
|
|
114
|
+
return { total: { home: finalizeTeam(totalHome), away: finalizeTeam(totalAway) }, sets: setResults };
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const rally_metrics_1 = require("./rally-metrics");
|
|
4
|
+
const event_1 = require("../event");
|
|
5
|
+
const serve = (failure = 0) => ({
|
|
6
|
+
events: [{ eventType: event_1.EventTypeEnum.SERVE, failure }]
|
|
7
|
+
});
|
|
8
|
+
const HOME = 'H';
|
|
9
|
+
const AWAY = 'A';
|
|
10
|
+
// Serving sequence H, H, A, A, H. Rally 1 (H's serve) is a service error. Final H 3 - 2 A resolves the
|
|
11
|
+
// last rally -> H. Winners: H, A, A, H, H. Home setter starts in zone 2, away setter in zone 1.
|
|
12
|
+
const set = {
|
|
13
|
+
homePoints: 3,
|
|
14
|
+
awayPoints: 2,
|
|
15
|
+
homeSetterStartZone: 2,
|
|
16
|
+
awaySetterStartZone: 1,
|
|
17
|
+
rallies: [
|
|
18
|
+
{ servingTeamId: HOME, ...serve() }, // 0: H breakpoint, H setter z2
|
|
19
|
+
{ servingTeamId: HOME, ...serve(1) }, // 1: A sideout via H service error, A setter z1
|
|
20
|
+
{ servingTeamId: AWAY, ...serve() }, // 2: A breakpoint, A rotated to z6
|
|
21
|
+
{ servingTeamId: AWAY, ...serve() }, // 3: H sideout, H still z2
|
|
22
|
+
{ servingTeamId: HOME, ...serve() } // 4: H breakpoint, H rotated to z1
|
|
23
|
+
]
|
|
24
|
+
};
|
|
25
|
+
const m = (0, rally_metrics_1.computeRallyMetrics)([set], HOME, AWAY);
|
|
26
|
+
describe('computeRallyMetrics()', () => {
|
|
27
|
+
it('serve / sideout / breakpoint counts and percentages', () => {
|
|
28
|
+
expect(m.total.home.serves).toBe(3);
|
|
29
|
+
expect(m.total.home.breakpointWins).toBe(2);
|
|
30
|
+
expect(m.total.home.breakpointPct).toBeCloseTo(66.6667, 3);
|
|
31
|
+
expect(m.total.home.receptions).toBe(2);
|
|
32
|
+
expect(m.total.home.sideoutWins).toBe(1);
|
|
33
|
+
expect(m.total.home.sideoutPct).toBeCloseTo(50, 5);
|
|
34
|
+
expect(m.total.away.serves).toBe(2);
|
|
35
|
+
expect(m.total.away.breakpointWins).toBe(1);
|
|
36
|
+
expect(m.total.away.receptions).toBe(3);
|
|
37
|
+
expect(m.total.away.sideoutWins).toBe(1);
|
|
38
|
+
expect(m.total.away.sideoutPct).toBeCloseTo(33.3333, 3);
|
|
39
|
+
});
|
|
40
|
+
it('modified sideout excludes the opponent service error', () => {
|
|
41
|
+
expect(m.total.away.serviceErrorsReceived).toBe(1);
|
|
42
|
+
expect(m.total.away.modReceptions).toBe(2);
|
|
43
|
+
expect(m.total.away.modSideoutWins).toBe(0);
|
|
44
|
+
expect(m.total.away.modSideoutPct).toBe(0);
|
|
45
|
+
expect(m.total.home.serviceErrorsReceived).toBe(0);
|
|
46
|
+
expect(m.total.home.modSideoutPct).toBeCloseTo(50, 5);
|
|
47
|
+
});
|
|
48
|
+
it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
|
|
49
|
+
expect(m.sets).toHaveLength(1);
|
|
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 }
|
|
53
|
+
]);
|
|
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 }
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, PerformanceStats, PinchCondition, RotationSystemEnum, SubBand, SubMode } from '../service';
|
|
1
|
+
import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, RallyMetrics, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, PerformanceStats, PinchCondition, RotationSystemEnum, SubBand, SubMode } from '../service';
|
|
2
2
|
export type Rally = DataProps<_Rally> & {
|
|
3
3
|
homePlayerPosition: PlayerPosition[];
|
|
4
4
|
awayPlayerPosition: PlayerPosition[];
|
|
@@ -36,6 +36,7 @@ export type Match = Omit<DataProps<_Match>, 'sets' | 'homeTeam' | 'awayTeam' | '
|
|
|
36
36
|
national?: National;
|
|
37
37
|
results?: Results;
|
|
38
38
|
competition?: MatchCompetition;
|
|
39
|
+
rallyMetrics?: RallyMetrics;
|
|
39
40
|
};
|
|
40
41
|
export type RallyEvent = DataProps<_RallyEvent> & {
|
|
41
42
|
team?: Team;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RallyMetricsEvent {
|
|
2
|
+
readonly eventType: number;
|
|
3
|
+
readonly failure?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface RallyMetricsRally {
|
|
6
|
+
readonly servingTeamId: string;
|
|
7
|
+
readonly events: readonly RallyMetricsEvent[];
|
|
8
|
+
}
|
|
9
|
+
export interface RallyMetricsSet {
|
|
10
|
+
readonly rallies: readonly RallyMetricsRally[];
|
|
11
|
+
readonly homePoints: number;
|
|
12
|
+
readonly awayPoints: number;
|
|
13
|
+
readonly homeSetterStartZone: number | null;
|
|
14
|
+
readonly awaySetterStartZone: number | null;
|
|
15
|
+
}
|
|
16
|
+
export interface RotationMetrics {
|
|
17
|
+
setterZone: number | null;
|
|
18
|
+
rotation: number;
|
|
19
|
+
servePoints: number;
|
|
20
|
+
sideoutPoints: number;
|
|
21
|
+
points: number;
|
|
22
|
+
}
|
|
23
|
+
export interface TeamRallyMetrics {
|
|
24
|
+
serves: number;
|
|
25
|
+
breakpointWins: number;
|
|
26
|
+
breakpointPct: number;
|
|
27
|
+
receptions: number;
|
|
28
|
+
sideoutWins: number;
|
|
29
|
+
sideoutPct: number;
|
|
30
|
+
serviceErrorsReceived: number;
|
|
31
|
+
modReceptions: number;
|
|
32
|
+
modSideoutWins: number;
|
|
33
|
+
modSideoutPct: number;
|
|
34
|
+
fbsoWins: number;
|
|
35
|
+
fbsoPct: number;
|
|
36
|
+
modFbsoPct: number;
|
|
37
|
+
rotations: RotationMetrics[];
|
|
38
|
+
}
|
|
39
|
+
export interface SetRallyMetrics {
|
|
40
|
+
home: TeamRallyMetrics;
|
|
41
|
+
away: TeamRallyMetrics;
|
|
42
|
+
}
|
|
43
|
+
export interface RallyMetrics {
|
|
44
|
+
total: SetRallyMetrics;
|
|
45
|
+
sets: SetRallyMetrics[];
|
|
46
|
+
}
|
|
47
|
+
export declare function computeRallyMetrics(sets: readonly RallyMetricsSet[], homeTeamId: string, awayTeamId: string): RallyMetrics;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { EventTypeEnum } from '../event';
|
|
2
|
+
import { rotatePosition } from './court-position';
|
|
3
|
+
function newAcc() {
|
|
4
|
+
return { serves: 0, breakpointWins: 0, receptions: 0, sideoutWins: 0, serviceErrorsReceived: 0, fbsoWins: 0, rotations: new Map() };
|
|
5
|
+
}
|
|
6
|
+
function isServiceError(rally) {
|
|
7
|
+
const serve = rally.events.find(e => e.eventType === EventTypeEnum.SERVE);
|
|
8
|
+
return serve != null && (serve.failure ?? 0) > 0;
|
|
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
|
+
}
|
|
16
|
+
// The setter's zone after (rotationIndex - 1) rotations from their start zone, using the engine's own
|
|
17
|
+
// rotatePosition (each rotation moves a player one zone: 2->1, 1->6, ...). null when no setter.
|
|
18
|
+
function setterZoneAt(startZone, rotationIndex) {
|
|
19
|
+
if (startZone == null)
|
|
20
|
+
return null;
|
|
21
|
+
let zone = startZone;
|
|
22
|
+
for (let r = 1; r < rotationIndex; r++)
|
|
23
|
+
zone = rotatePosition(zone);
|
|
24
|
+
return zone;
|
|
25
|
+
}
|
|
26
|
+
function addRotationPoint(acc, setterZone, rotation, kind) {
|
|
27
|
+
const key = setterZone != null ? `z${setterZone}` : `r${rotation}`;
|
|
28
|
+
let entry = acc.rotations.get(key);
|
|
29
|
+
if (entry == null) {
|
|
30
|
+
entry = { setterZone, rotation, servePoints: 0, sideoutPoints: 0 };
|
|
31
|
+
acc.rotations.set(key, entry);
|
|
32
|
+
}
|
|
33
|
+
if (kind === 'serve')
|
|
34
|
+
entry.servePoints++;
|
|
35
|
+
else
|
|
36
|
+
entry.sideoutPoints++;
|
|
37
|
+
}
|
|
38
|
+
// Walk one set, folding its rallies into the home/away accumulators (used for both per-set and total).
|
|
39
|
+
function accumulateSet(set, homeId, awayId, homeAcc, awayAcc) {
|
|
40
|
+
const { rallies } = set;
|
|
41
|
+
if (rallies.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
const setWinnerId = set.homePoints > set.awayPoints ? homeId : awayId;
|
|
44
|
+
const rotation = { [homeId]: 1, [awayId]: 1 };
|
|
45
|
+
const startZone = { [homeId]: set.homeSetterStartZone, [awayId]: set.awaySetterStartZone };
|
|
46
|
+
const accOf = (id) => id === homeId ? homeAcc : id === awayId ? awayAcc : null;
|
|
47
|
+
for (let i = 0; i < rallies.length; i++) {
|
|
48
|
+
const servingId = rallies[i].servingTeamId;
|
|
49
|
+
const receivingId = servingId === homeId ? awayId : homeId;
|
|
50
|
+
const winnerId = i < rallies.length - 1 ? rallies[i + 1].servingTeamId : setWinnerId;
|
|
51
|
+
const serving = accOf(servingId);
|
|
52
|
+
const receiving = accOf(receivingId);
|
|
53
|
+
const winner = accOf(winnerId);
|
|
54
|
+
if (serving == null || receiving == null || winner == null)
|
|
55
|
+
continue;
|
|
56
|
+
serving.serves++;
|
|
57
|
+
receiving.receptions++;
|
|
58
|
+
if (isServiceError(rallies[i]))
|
|
59
|
+
receiving.serviceErrorsReceived++;
|
|
60
|
+
const rotIdx = rotation[winnerId];
|
|
61
|
+
const zone = setterZoneAt(startZone[winnerId], rotIdx);
|
|
62
|
+
if (winnerId === servingId) {
|
|
63
|
+
serving.breakpointWins++;
|
|
64
|
+
addRotationPoint(winner, zone, rotIdx, 'serve');
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
receiving.sideoutWins++;
|
|
68
|
+
if (spikeCount(rallies[i]) === 1)
|
|
69
|
+
receiving.fbsoWins++;
|
|
70
|
+
addRotationPoint(winner, zone, rotIdx, 'sideout');
|
|
71
|
+
}
|
|
72
|
+
// The sideout winner gains serve, so they rotate (after the point is attributed to their old rotation).
|
|
73
|
+
if (winnerId === receivingId)
|
|
74
|
+
rotation[winnerId] = (rotation[winnerId] % 6) + 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function finalizeTeam(acc) {
|
|
78
|
+
const modReceptions = acc.receptions - acc.serviceErrorsReceived;
|
|
79
|
+
const modSideoutWins = acc.sideoutWins - acc.serviceErrorsReceived;
|
|
80
|
+
const rotations = [...acc.rotations.values()]
|
|
81
|
+
.map(e => ({ ...e, points: e.servePoints + e.sideoutPoints }))
|
|
82
|
+
.sort((a, b) => (a.setterZone ?? a.rotation) - (b.setterZone ?? b.rotation));
|
|
83
|
+
return {
|
|
84
|
+
serves: acc.serves,
|
|
85
|
+
breakpointWins: acc.breakpointWins,
|
|
86
|
+
breakpointPct: acc.serves > 0 ? (acc.breakpointWins / acc.serves) * 100 : 0,
|
|
87
|
+
receptions: acc.receptions,
|
|
88
|
+
sideoutWins: acc.sideoutWins,
|
|
89
|
+
sideoutPct: acc.receptions > 0 ? (acc.sideoutWins / acc.receptions) * 100 : 0,
|
|
90
|
+
serviceErrorsReceived: acc.serviceErrorsReceived,
|
|
91
|
+
modReceptions,
|
|
92
|
+
modSideoutWins,
|
|
93
|
+
modSideoutPct: modReceptions > 0 ? (modSideoutWins / modReceptions) * 100 : 0,
|
|
94
|
+
fbsoWins: acc.fbsoWins,
|
|
95
|
+
fbsoPct: acc.receptions > 0 ? (acc.fbsoWins / acc.receptions) * 100 : 0,
|
|
96
|
+
modFbsoPct: modReceptions > 0 ? (acc.fbsoWins / modReceptions) * 100 : 0,
|
|
97
|
+
rotations
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function computeRallyMetrics(sets, homeTeamId, awayTeamId) {
|
|
101
|
+
const totalHome = newAcc();
|
|
102
|
+
const totalAway = newAcc();
|
|
103
|
+
const setResults = [];
|
|
104
|
+
for (const set of sets) {
|
|
105
|
+
const setHome = newAcc();
|
|
106
|
+
const setAway = newAcc();
|
|
107
|
+
accumulateSet(set, homeTeamId, awayTeamId, setHome, setAway);
|
|
108
|
+
accumulateSet(set, homeTeamId, awayTeamId, totalHome, totalAway);
|
|
109
|
+
setResults.push({ home: finalizeTeam(setHome), away: finalizeTeam(setAway) });
|
|
110
|
+
}
|
|
111
|
+
return { total: { home: finalizeTeam(totalHome), away: finalizeTeam(totalAway) }, sets: setResults };
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { computeRallyMetrics } from './rally-metrics';
|
|
2
|
+
import { EventTypeEnum } from '../event';
|
|
3
|
+
const serve = (failure = 0) => ({
|
|
4
|
+
events: [{ eventType: EventTypeEnum.SERVE, failure }]
|
|
5
|
+
});
|
|
6
|
+
const HOME = 'H';
|
|
7
|
+
const AWAY = 'A';
|
|
8
|
+
// Serving sequence H, H, A, A, H. Rally 1 (H's serve) is a service error. Final H 3 - 2 A resolves the
|
|
9
|
+
// last rally -> H. Winners: H, A, A, H, H. Home setter starts in zone 2, away setter in zone 1.
|
|
10
|
+
const set = {
|
|
11
|
+
homePoints: 3,
|
|
12
|
+
awayPoints: 2,
|
|
13
|
+
homeSetterStartZone: 2,
|
|
14
|
+
awaySetterStartZone: 1,
|
|
15
|
+
rallies: [
|
|
16
|
+
{ servingTeamId: HOME, ...serve() }, // 0: H breakpoint, H setter z2
|
|
17
|
+
{ servingTeamId: HOME, ...serve(1) }, // 1: A sideout via H service error, A setter z1
|
|
18
|
+
{ servingTeamId: AWAY, ...serve() }, // 2: A breakpoint, A rotated to z6
|
|
19
|
+
{ servingTeamId: AWAY, ...serve() }, // 3: H sideout, H still z2
|
|
20
|
+
{ servingTeamId: HOME, ...serve() } // 4: H breakpoint, H rotated to z1
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
const m = computeRallyMetrics([set], HOME, AWAY);
|
|
24
|
+
describe('computeRallyMetrics()', () => {
|
|
25
|
+
it('serve / sideout / breakpoint counts and percentages', () => {
|
|
26
|
+
expect(m.total.home.serves).toBe(3);
|
|
27
|
+
expect(m.total.home.breakpointWins).toBe(2);
|
|
28
|
+
expect(m.total.home.breakpointPct).toBeCloseTo(66.6667, 3);
|
|
29
|
+
expect(m.total.home.receptions).toBe(2);
|
|
30
|
+
expect(m.total.home.sideoutWins).toBe(1);
|
|
31
|
+
expect(m.total.home.sideoutPct).toBeCloseTo(50, 5);
|
|
32
|
+
expect(m.total.away.serves).toBe(2);
|
|
33
|
+
expect(m.total.away.breakpointWins).toBe(1);
|
|
34
|
+
expect(m.total.away.receptions).toBe(3);
|
|
35
|
+
expect(m.total.away.sideoutWins).toBe(1);
|
|
36
|
+
expect(m.total.away.sideoutPct).toBeCloseTo(33.3333, 3);
|
|
37
|
+
});
|
|
38
|
+
it('modified sideout excludes the opponent service error', () => {
|
|
39
|
+
expect(m.total.away.serviceErrorsReceived).toBe(1);
|
|
40
|
+
expect(m.total.away.modReceptions).toBe(2);
|
|
41
|
+
expect(m.total.away.modSideoutWins).toBe(0);
|
|
42
|
+
expect(m.total.away.modSideoutPct).toBe(0);
|
|
43
|
+
expect(m.total.home.serviceErrorsReceived).toBe(0);
|
|
44
|
+
expect(m.total.home.modSideoutPct).toBeCloseTo(50, 5);
|
|
45
|
+
});
|
|
46
|
+
it('per-set rotations labeled by setter zone, split serve vs sideout', () => {
|
|
47
|
+
expect(m.sets).toHaveLength(1);
|
|
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 }
|
|
51
|
+
]);
|
|
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 }
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
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
|
+
});
|