@pickleball/expo-sdk 0.1.1
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CourtSide,
|
|
3
|
+
CourtSwitchConfirmedEvent,
|
|
4
|
+
GameSnapshot,
|
|
5
|
+
MatchCorrectedEvent,
|
|
6
|
+
MatchCorrection,
|
|
7
|
+
MatchCreateInput,
|
|
8
|
+
MatchCreatedEvent,
|
|
9
|
+
MatchCreation,
|
|
10
|
+
MatchEndedEvent,
|
|
11
|
+
MatchEvent,
|
|
12
|
+
MatchInputSource,
|
|
13
|
+
MatchSnapshot,
|
|
14
|
+
MatchTeam,
|
|
15
|
+
MatchTeamId,
|
|
16
|
+
PendingCourtSwitch,
|
|
17
|
+
RallyAwardedEvent,
|
|
18
|
+
ServeState,
|
|
19
|
+
TeamScore,
|
|
20
|
+
} from "./contracts";
|
|
21
|
+
import { cloneSnapshot, cloneTeam } from "./state";
|
|
22
|
+
import {
|
|
23
|
+
assertRuntimeMatchEvent,
|
|
24
|
+
assertRuntimeMatchSnapshot,
|
|
25
|
+
} from "./validation";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TARGET_SCORE = 11;
|
|
28
|
+
|
|
29
|
+
function oppositeTeam(teamId: MatchTeamId): MatchTeamId {
|
|
30
|
+
return teamId === "A" ? "B" : "A";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function oppositeSide(side: CourtSide): CourtSide {
|
|
34
|
+
return side === "left" ? "right" : "left";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serviceSide(score: number): CourtSide {
|
|
38
|
+
return score % 2 === 0 ? "right" : "left";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertNonEmpty(value: string, label: string) {
|
|
42
|
+
if (value.trim().length === 0) throw new TypeError(`${label} must not be empty`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertCreateInput(input: MatchCreateInput) {
|
|
46
|
+
assertNonEmpty(input.id, "Match id");
|
|
47
|
+
if (input.format !== "singles" && input.format !== "doubles") {
|
|
48
|
+
throw new TypeError("Invalid match format");
|
|
49
|
+
}
|
|
50
|
+
if (input.scoringMode !== "rally" && input.scoringMode !== "sideOut") {
|
|
51
|
+
throw new TypeError("Invalid scoring mode");
|
|
52
|
+
}
|
|
53
|
+
const target = input.targetScore ?? DEFAULT_TARGET_SCORE;
|
|
54
|
+
if (!Number.isInteger(target) || target < 1 || target > 99) {
|
|
55
|
+
throw new TypeError("Target score must be an integer from 1 to 99");
|
|
56
|
+
}
|
|
57
|
+
const expectedPlayers = input.format === "singles" ? 1 : 2;
|
|
58
|
+
const playerIds = new Set<string>();
|
|
59
|
+
for (const teamId of ["A", "B"] as const) {
|
|
60
|
+
const team = input.teams[teamId];
|
|
61
|
+
if (!team || team.id !== teamId || team.players.length !== expectedPlayers) {
|
|
62
|
+
throw new TypeError(`Team ${teamId} must have ${expectedPlayers} player(s)`);
|
|
63
|
+
}
|
|
64
|
+
if (team.name !== undefined) assertNonEmpty(team.name, `Team ${teamId} name`);
|
|
65
|
+
for (const player of team.players) {
|
|
66
|
+
assertNonEmpty(player.id, "Player id");
|
|
67
|
+
assertNonEmpty(player.name, "Player name");
|
|
68
|
+
if (playerIds.has(player.id)) throw new TypeError("Player ids must be unique");
|
|
69
|
+
playerIds.add(player.id);
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
team.initialServerId !== undefined &&
|
|
73
|
+
!team.players.some((player) => player.id === team.initialServerId)
|
|
74
|
+
) {
|
|
75
|
+
throw new TypeError(`Team ${teamId} initial server must belong to the team`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
input.initialServingTeam !== undefined &&
|
|
80
|
+
input.initialServingTeam !== "A" &&
|
|
81
|
+
input.initialServingTeam !== "B"
|
|
82
|
+
) {
|
|
83
|
+
throw new TypeError("Invalid initial serving team");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function initialPlayerCourts(
|
|
88
|
+
teams: Readonly<Record<MatchTeamId, MatchTeam>>,
|
|
89
|
+
designatedServerByTeam: Readonly<Record<MatchTeamId, string>>,
|
|
90
|
+
): Record<string, CourtSide> {
|
|
91
|
+
const courts: Record<string, CourtSide> = {};
|
|
92
|
+
for (const teamId of ["A", "B"] as const) {
|
|
93
|
+
teams[teamId].players.forEach((player) => {
|
|
94
|
+
courts[player.id] =
|
|
95
|
+
player.id === designatedServerByTeam[teamId] ? "right" : "left";
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return courts;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function gameNumber(value: number): 1 | 2 | 3 {
|
|
102
|
+
if (value !== 1 && value !== 2 && value !== 3) {
|
|
103
|
+
throw new TypeError("A best-of-three match cannot have another game");
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeGame(
|
|
109
|
+
number: 1 | 2 | 3,
|
|
110
|
+
startingServingTeam: MatchTeamId,
|
|
111
|
+
): GameSnapshot {
|
|
112
|
+
return {
|
|
113
|
+
number,
|
|
114
|
+
score: { A: 0, B: 0 },
|
|
115
|
+
startingServingTeam,
|
|
116
|
+
winner: null,
|
|
117
|
+
midGameSwitchCompleted: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function initialServe(
|
|
122
|
+
snapshot: Pick<
|
|
123
|
+
MatchSnapshot,
|
|
124
|
+
| "format"
|
|
125
|
+
| "scoringMode"
|
|
126
|
+
| "teams"
|
|
127
|
+
| "designatedServerByTeam"
|
|
128
|
+
| "playerCourts"
|
|
129
|
+
>,
|
|
130
|
+
teamId: MatchTeamId,
|
|
131
|
+
): ServeState {
|
|
132
|
+
const playerId = snapshot.designatedServerByTeam[teamId];
|
|
133
|
+
return {
|
|
134
|
+
teamId,
|
|
135
|
+
playerId,
|
|
136
|
+
serverNumber:
|
|
137
|
+
snapshot.format === "doubles" && snapshot.scoringMode === "sideOut" ? 2 : 1,
|
|
138
|
+
court: snapshot.playerCourts[playerId],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function eventIdentity(snapshot: MatchSnapshot) {
|
|
143
|
+
const sequence = snapshot.sequence + 1;
|
|
144
|
+
return { schemaVersion: 1 as const, id: `${snapshot.id}:${sequence}`, sequence };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createMatch(
|
|
148
|
+
input: MatchCreateInput,
|
|
149
|
+
source: MatchInputSource = "system",
|
|
150
|
+
): MatchCreation {
|
|
151
|
+
assertCreateInput(input);
|
|
152
|
+
const teams = { A: cloneTeam(input.teams.A), B: cloneTeam(input.teams.B) };
|
|
153
|
+
const designatedServerByTeam = {
|
|
154
|
+
A: teams.A.initialServerId ?? teams.A.players[0].id,
|
|
155
|
+
B: teams.B.initialServerId ?? teams.B.players[0].id,
|
|
156
|
+
};
|
|
157
|
+
const playerCourts = initialPlayerCourts(teams, designatedServerByTeam);
|
|
158
|
+
const initialServingTeam = input.initialServingTeam ?? "A";
|
|
159
|
+
const base = {
|
|
160
|
+
id: input.id,
|
|
161
|
+
format: input.format,
|
|
162
|
+
scoringMode: input.scoringMode,
|
|
163
|
+
targetScore: input.targetScore ?? DEFAULT_TARGET_SCORE,
|
|
164
|
+
bestOf: 3 as const,
|
|
165
|
+
teams,
|
|
166
|
+
designatedServerByTeam,
|
|
167
|
+
initialServingTeam,
|
|
168
|
+
courtSides: { left: "A", right: "B" } as const,
|
|
169
|
+
playerCourts,
|
|
170
|
+
games: [makeGame(1, initialServingTeam)],
|
|
171
|
+
currentGame: 1 as const,
|
|
172
|
+
gamesWon: { A: 0, B: 0 },
|
|
173
|
+
pendingCourtSwitch: null,
|
|
174
|
+
status: "inProgress" as const,
|
|
175
|
+
winner: null,
|
|
176
|
+
endReason: null,
|
|
177
|
+
sequence: 0,
|
|
178
|
+
};
|
|
179
|
+
const snapshot: MatchSnapshot = {
|
|
180
|
+
...base,
|
|
181
|
+
serve: initialServe(base, initialServingTeam),
|
|
182
|
+
};
|
|
183
|
+
const event: MatchCreatedEvent = {
|
|
184
|
+
type: "matchCreated",
|
|
185
|
+
schemaVersion: 1,
|
|
186
|
+
id: `${snapshot.id}:0`,
|
|
187
|
+
sequence: 0,
|
|
188
|
+
source,
|
|
189
|
+
input,
|
|
190
|
+
snapshot: cloneSnapshot(snapshot),
|
|
191
|
+
};
|
|
192
|
+
return { snapshot, events: [event] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertNextEvent(snapshot: MatchSnapshot, event: MatchEvent) {
|
|
196
|
+
if (event.sequence !== snapshot.sequence + 1) {
|
|
197
|
+
throw new TypeError("Match event sequence is not contiguous");
|
|
198
|
+
}
|
|
199
|
+
if (event.id !== `${snapshot.id}:${event.sequence}`) {
|
|
200
|
+
throw new TypeError("Match event id does not match its sequence");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function currentGame(snapshot: MatchSnapshot): GameSnapshot {
|
|
205
|
+
const game = snapshot.games[snapshot.currentGame - 1];
|
|
206
|
+
if (!game) throw new TypeError("Current game is missing");
|
|
207
|
+
return game;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function updateCurrentGame(
|
|
211
|
+
snapshot: MatchSnapshot,
|
|
212
|
+
update: (game: GameSnapshot) => GameSnapshot,
|
|
213
|
+
): MatchSnapshot {
|
|
214
|
+
const games = snapshot.games.map((game, index) =>
|
|
215
|
+
index === snapshot.currentGame - 1 ? update(game) : game,
|
|
216
|
+
);
|
|
217
|
+
return { ...snapshot, games };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function playerOnCourt(
|
|
221
|
+
snapshot: MatchSnapshot,
|
|
222
|
+
teamId: MatchTeamId,
|
|
223
|
+
court: CourtSide,
|
|
224
|
+
): string {
|
|
225
|
+
const player = snapshot.teams[teamId].players.find(
|
|
226
|
+
(candidate) => snapshot.playerCourts[candidate.id] === court,
|
|
227
|
+
);
|
|
228
|
+
if (!player) throw new TypeError(`Team ${teamId} has no player on ${court}`);
|
|
229
|
+
return player.id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function swapTeamPlayers(
|
|
233
|
+
snapshot: MatchSnapshot,
|
|
234
|
+
teamId: MatchTeamId,
|
|
235
|
+
): MatchSnapshot {
|
|
236
|
+
if (snapshot.format === "singles") return snapshot;
|
|
237
|
+
const playerCourts = { ...snapshot.playerCourts };
|
|
238
|
+
for (const player of snapshot.teams[teamId].players) {
|
|
239
|
+
playerCourts[player.id] = oppositeSide(playerCourts[player.id]);
|
|
240
|
+
}
|
|
241
|
+
return { ...snapshot, playerCourts };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function serveForPossession(
|
|
245
|
+
snapshot: MatchSnapshot,
|
|
246
|
+
teamId: MatchTeamId,
|
|
247
|
+
serverNumber: 1 | 2 = 1,
|
|
248
|
+
): ServeState {
|
|
249
|
+
const score = currentGame(snapshot).score[teamId];
|
|
250
|
+
const court = serviceSide(score);
|
|
251
|
+
if (snapshot.format === "singles") {
|
|
252
|
+
return {
|
|
253
|
+
teamId,
|
|
254
|
+
playerId: snapshot.teams[teamId].players[0].id,
|
|
255
|
+
serverNumber: 1,
|
|
256
|
+
court,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
teamId,
|
|
261
|
+
playerId: playerOnCourt(snapshot, teamId, court),
|
|
262
|
+
serverNumber,
|
|
263
|
+
court,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function finishPoint(
|
|
268
|
+
snapshot: MatchSnapshot,
|
|
269
|
+
scoringTeam: MatchTeamId | null,
|
|
270
|
+
): MatchSnapshot {
|
|
271
|
+
if (!scoringTeam) return snapshot;
|
|
272
|
+
const game = currentGame(snapshot);
|
|
273
|
+
if (game.score[scoringTeam] < snapshot.targetScore) {
|
|
274
|
+
if (
|
|
275
|
+
snapshot.currentGame === 3 &&
|
|
276
|
+
!game.midGameSwitchCompleted &&
|
|
277
|
+
game.score[scoringTeam] >= Math.ceil(snapshot.targetScore / 2)
|
|
278
|
+
) {
|
|
279
|
+
return {
|
|
280
|
+
...snapshot,
|
|
281
|
+
pendingCourtSwitch: { reason: "gameThreeMidpoint", game: 3 },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return snapshot;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let next = updateCurrentGame(snapshot, (value) => ({
|
|
288
|
+
...value,
|
|
289
|
+
winner: scoringTeam,
|
|
290
|
+
}));
|
|
291
|
+
const gamesWon: TeamScore = {
|
|
292
|
+
...next.gamesWon,
|
|
293
|
+
[scoringTeam]: next.gamesWon[scoringTeam] + 1,
|
|
294
|
+
};
|
|
295
|
+
next = { ...next, gamesWon };
|
|
296
|
+
if (gamesWon[scoringTeam] === 2) {
|
|
297
|
+
return {
|
|
298
|
+
...next,
|
|
299
|
+
status: "completed",
|
|
300
|
+
winner: scoringTeam,
|
|
301
|
+
pendingCourtSwitch: null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
...next,
|
|
306
|
+
pendingCourtSwitch: {
|
|
307
|
+
reason: "gameEnd",
|
|
308
|
+
game: next.currentGame as 1 | 2,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function scorePoint(snapshot: MatchSnapshot, teamId: MatchTeamId): MatchSnapshot {
|
|
314
|
+
return updateCurrentGame(snapshot, (game) => ({
|
|
315
|
+
...game,
|
|
316
|
+
score: { ...game.score, [teamId]: game.score[teamId] + 1 },
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function applyRally(
|
|
321
|
+
snapshot: MatchSnapshot,
|
|
322
|
+
winnerTeamId: MatchTeamId,
|
|
323
|
+
): MatchSnapshot {
|
|
324
|
+
const servingTeam = snapshot.serve.teamId;
|
|
325
|
+
let next = snapshot;
|
|
326
|
+
let scoringTeam: MatchTeamId | null = null;
|
|
327
|
+
|
|
328
|
+
if (snapshot.scoringMode === "rally") {
|
|
329
|
+
scoringTeam = winnerTeamId;
|
|
330
|
+
next = scorePoint(next, winnerTeamId);
|
|
331
|
+
if (winnerTeamId === servingTeam) {
|
|
332
|
+
next = swapTeamPlayers(next, winnerTeamId);
|
|
333
|
+
next = {
|
|
334
|
+
...next,
|
|
335
|
+
serve:
|
|
336
|
+
next.format === "singles"
|
|
337
|
+
? serveForPossession(next, winnerTeamId)
|
|
338
|
+
: {
|
|
339
|
+
...next.serve,
|
|
340
|
+
court: next.playerCourts[next.serve.playerId],
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
} else {
|
|
344
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId) };
|
|
345
|
+
}
|
|
346
|
+
} else if (winnerTeamId === servingTeam) {
|
|
347
|
+
scoringTeam = winnerTeamId;
|
|
348
|
+
next = scorePoint(next, winnerTeamId);
|
|
349
|
+
next = swapTeamPlayers(next, winnerTeamId);
|
|
350
|
+
next = {
|
|
351
|
+
...next,
|
|
352
|
+
serve:
|
|
353
|
+
next.format === "singles"
|
|
354
|
+
? serveForPossession(next, winnerTeamId)
|
|
355
|
+
: {
|
|
356
|
+
...next.serve,
|
|
357
|
+
court: next.playerCourts[next.serve.playerId],
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
} else if (snapshot.format === "singles") {
|
|
361
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId) };
|
|
362
|
+
} else if (snapshot.serve.serverNumber === 1) {
|
|
363
|
+
const partner = snapshot.teams[servingTeam].players.find(
|
|
364
|
+
(player) => player.id !== snapshot.serve.playerId,
|
|
365
|
+
);
|
|
366
|
+
if (!partner) throw new TypeError("Doubles serving partner is missing");
|
|
367
|
+
next = {
|
|
368
|
+
...next,
|
|
369
|
+
serve: {
|
|
370
|
+
teamId: servingTeam,
|
|
371
|
+
playerId: partner.id,
|
|
372
|
+
serverNumber: 2,
|
|
373
|
+
court: snapshot.playerCourts[partner.id],
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
} else {
|
|
377
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId, 1) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return finishPoint(next, scoringTeam);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function assertPlayerCourts(snapshot: MatchSnapshot) {
|
|
384
|
+
const knownIds = new Set(
|
|
385
|
+
(["A", "B"] as const).flatMap((teamId) =>
|
|
386
|
+
snapshot.teams[teamId].players.map((player) => player.id),
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
for (const [playerId, court] of Object.entries(snapshot.playerCourts)) {
|
|
390
|
+
if (!knownIds.has(playerId) || (court !== "left" && court !== "right")) {
|
|
391
|
+
throw new TypeError("Correction contains an invalid player court");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (Object.keys(snapshot.playerCourts).length !== knownIds.size) {
|
|
395
|
+
throw new TypeError("Every player must have a court mapping");
|
|
396
|
+
}
|
|
397
|
+
if (snapshot.format === "doubles") {
|
|
398
|
+
for (const teamId of ["A", "B"] as const) {
|
|
399
|
+
const [first, second] = snapshot.teams[teamId].players;
|
|
400
|
+
if (snapshot.playerCourts[first.id] === snapshot.playerCourts[second.id]) {
|
|
401
|
+
throw new TypeError(`Team ${teamId} players must occupy different courts`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function assertServe(snapshot: MatchSnapshot, serve: ServeState) {
|
|
408
|
+
const teamPlayerIds = snapshot.teams[serve.teamId].players.map((player) => player.id);
|
|
409
|
+
if (!teamPlayerIds.includes(serve.playerId)) {
|
|
410
|
+
throw new TypeError("Server must belong to the serving team");
|
|
411
|
+
}
|
|
412
|
+
if (serve.serverNumber !== 1 && serve.serverNumber !== 2) {
|
|
413
|
+
throw new TypeError("Server number must be one or two");
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
snapshot.format === "singles" ||
|
|
417
|
+
(snapshot.format === "doubles" && snapshot.scoringMode === "rally")
|
|
418
|
+
) {
|
|
419
|
+
if (serve.serverNumber !== 1) throw new TypeError("This mode has one server");
|
|
420
|
+
}
|
|
421
|
+
const expectedCourt =
|
|
422
|
+
snapshot.format === "singles"
|
|
423
|
+
? serviceSide(currentGame(snapshot).score[serve.teamId])
|
|
424
|
+
: snapshot.playerCourts[serve.playerId];
|
|
425
|
+
if (serve.court !== expectedCourt) {
|
|
426
|
+
throw new TypeError("Service court does not match score/player position");
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
snapshot.format === "doubles" &&
|
|
430
|
+
(snapshot.scoringMode === "rally" || serve.serverNumber === 1) &&
|
|
431
|
+
serve.court !== serviceSide(currentGame(snapshot).score[serve.teamId])
|
|
432
|
+
) {
|
|
433
|
+
throw new TypeError("First server court does not match the serving team score");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function applyCorrection(
|
|
438
|
+
snapshot: MatchSnapshot,
|
|
439
|
+
correction: MatchCorrection,
|
|
440
|
+
): MatchSnapshot {
|
|
441
|
+
const correctionKeys = Object.keys(correction);
|
|
442
|
+
if (
|
|
443
|
+
correctionKeys.length === 0 ||
|
|
444
|
+
correctionKeys.some(
|
|
445
|
+
(key) => key !== "score" && key !== "serve" && key !== "playerCourts",
|
|
446
|
+
) ||
|
|
447
|
+
(correction.score === undefined &&
|
|
448
|
+
correction.serve === undefined &&
|
|
449
|
+
correction.playerCourts === undefined)
|
|
450
|
+
) {
|
|
451
|
+
throw new TypeError("Correction must contain a supported patch");
|
|
452
|
+
}
|
|
453
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) {
|
|
454
|
+
throw new TypeError("Only an active, unpaused match can be corrected");
|
|
455
|
+
}
|
|
456
|
+
let next = cloneSnapshot(snapshot);
|
|
457
|
+
const previousScore = currentGame(snapshot).score;
|
|
458
|
+
if (correction.playerCourts) {
|
|
459
|
+
next = {
|
|
460
|
+
...next,
|
|
461
|
+
playerCourts: { ...next.playerCourts, ...correction.playerCourts },
|
|
462
|
+
};
|
|
463
|
+
assertPlayerCourts(next);
|
|
464
|
+
}
|
|
465
|
+
if (correction.score) {
|
|
466
|
+
if (Object.keys(correction.score).some((teamId) => teamId !== "A" && teamId !== "B")) {
|
|
467
|
+
throw new TypeError("Correction contains an invalid team score");
|
|
468
|
+
}
|
|
469
|
+
for (const value of Object.values(correction.score)) {
|
|
470
|
+
if (!Number.isInteger(value) || value! < 0 || value! > next.targetScore) {
|
|
471
|
+
throw new TypeError("Corrected scores must be non-negative integers up to target");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
next = updateCurrentGame(next, (game) => ({
|
|
475
|
+
...game,
|
|
476
|
+
score: { ...game.score, ...correction.score },
|
|
477
|
+
}));
|
|
478
|
+
const score = currentGame(next).score;
|
|
479
|
+
if (score.A === next.targetScore && score.B === next.targetScore) {
|
|
480
|
+
throw new TypeError("Both teams cannot have a winning score");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const correctedServingScore = correction.score?.[snapshot.serve.teamId];
|
|
484
|
+
const servingTeamCourtsPatched = snapshot.teams[snapshot.serve.teamId].players.some(
|
|
485
|
+
(player) => correction.playerCourts?.[player.id] !== undefined,
|
|
486
|
+
);
|
|
487
|
+
if (
|
|
488
|
+
next.format === "doubles" &&
|
|
489
|
+
correctedServingScore !== undefined &&
|
|
490
|
+
correctedServingScore % 2 !== previousScore[snapshot.serve.teamId] % 2
|
|
491
|
+
) {
|
|
492
|
+
if (servingTeamCourtsPatched) {
|
|
493
|
+
const hasExpectedRotation = snapshot.teams[snapshot.serve.teamId].players.every(
|
|
494
|
+
(player) =>
|
|
495
|
+
next.playerCourts[player.id] === oppositeSide(snapshot.playerCourts[player.id]),
|
|
496
|
+
);
|
|
497
|
+
if (!hasExpectedRotation) {
|
|
498
|
+
throw new TypeError("Corrected serving positions do not match the score change");
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
next = swapTeamPlayers(next, snapshot.serve.teamId);
|
|
502
|
+
next = {
|
|
503
|
+
...next,
|
|
504
|
+
serve: { ...next.serve, court: next.playerCourts[next.serve.playerId] },
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (correction.serve) {
|
|
509
|
+
assertServe(next, correction.serve);
|
|
510
|
+
next = { ...next, serve: { ...correction.serve } };
|
|
511
|
+
} else if (correction.playerCourts?.[next.serve.playerId]) {
|
|
512
|
+
next = {
|
|
513
|
+
...next,
|
|
514
|
+
serve: { ...next.serve, court: next.playerCourts[next.serve.playerId] },
|
|
515
|
+
};
|
|
516
|
+
} else if (correction.score && next.format === "singles") {
|
|
517
|
+
next = { ...next, serve: serveForPossession(next, next.serve.teamId) };
|
|
518
|
+
}
|
|
519
|
+
assertServe(next, next.serve);
|
|
520
|
+
|
|
521
|
+
const score = currentGame(next).score;
|
|
522
|
+
const winningTeam =
|
|
523
|
+
score.A === next.targetScore ? "A" : score.B === next.targetScore ? "B" : null;
|
|
524
|
+
const midpoint = Math.ceil(next.targetScore / 2);
|
|
525
|
+
const midpointTeam = score.A >= midpoint ? "A" : score.B >= midpoint ? "B" : null;
|
|
526
|
+
return finishPoint(next, winningTeam ?? midpointTeam);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function applyMatchEvent(
|
|
530
|
+
snapshot: MatchSnapshot,
|
|
531
|
+
event: MatchEvent,
|
|
532
|
+
): MatchSnapshot {
|
|
533
|
+
assertRuntimeMatchSnapshot(snapshot);
|
|
534
|
+
assertRuntimeMatchEvent(event);
|
|
535
|
+
if (event.type === "matchCreated") {
|
|
536
|
+
throw new TypeError("A creation event can only begin an event log");
|
|
537
|
+
}
|
|
538
|
+
assertNextEvent(snapshot, event);
|
|
539
|
+
let next: MatchSnapshot;
|
|
540
|
+
switch (event.type) {
|
|
541
|
+
case "rallyAwarded":
|
|
542
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) {
|
|
543
|
+
throw new TypeError("A paused or terminal match cannot accept a rally");
|
|
544
|
+
}
|
|
545
|
+
if (snapshot.courtSides[event.displaySide] !== event.winnerTeamId) {
|
|
546
|
+
throw new TypeError("Rally display side does not map to its winning team");
|
|
547
|
+
}
|
|
548
|
+
next = applyRally(cloneSnapshot(snapshot), event.winnerTeamId);
|
|
549
|
+
break;
|
|
550
|
+
case "courtSwitchConfirmed": {
|
|
551
|
+
if (
|
|
552
|
+
snapshot.status !== "inProgress" ||
|
|
553
|
+
!snapshot.pendingCourtSwitch ||
|
|
554
|
+
JSON.stringify(snapshot.pendingCourtSwitch) !== JSON.stringify(event.pending)
|
|
555
|
+
) {
|
|
556
|
+
throw new TypeError("No matching court switch is pending");
|
|
557
|
+
}
|
|
558
|
+
next = {
|
|
559
|
+
...cloneSnapshot(snapshot),
|
|
560
|
+
courtSides: {
|
|
561
|
+
left: snapshot.courtSides.right,
|
|
562
|
+
right: snapshot.courtSides.left,
|
|
563
|
+
},
|
|
564
|
+
pendingCourtSwitch: null,
|
|
565
|
+
};
|
|
566
|
+
if (event.pending.reason === "gameThreeMidpoint") {
|
|
567
|
+
next = updateCurrentGame(next, (game) => ({
|
|
568
|
+
...game,
|
|
569
|
+
midGameSwitchCompleted: true,
|
|
570
|
+
}));
|
|
571
|
+
} else {
|
|
572
|
+
const nextNumber = gameNumber(snapshot.currentGame + 1);
|
|
573
|
+
const startingServingTeam =
|
|
574
|
+
nextNumber % 2 === 1
|
|
575
|
+
? snapshot.initialServingTeam
|
|
576
|
+
: oppositeTeam(snapshot.initialServingTeam);
|
|
577
|
+
const playerCourts = initialPlayerCourts(
|
|
578
|
+
snapshot.teams,
|
|
579
|
+
snapshot.designatedServerByTeam,
|
|
580
|
+
);
|
|
581
|
+
const withoutServe = {
|
|
582
|
+
...next,
|
|
583
|
+
playerCourts,
|
|
584
|
+
games: [...next.games, makeGame(nextNumber, startingServingTeam)],
|
|
585
|
+
currentGame: nextNumber,
|
|
586
|
+
};
|
|
587
|
+
next = {
|
|
588
|
+
...withoutServe,
|
|
589
|
+
serve: initialServe(withoutServe, startingServingTeam),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
case "matchCorrected":
|
|
595
|
+
next = applyCorrection(snapshot, event.correction);
|
|
596
|
+
break;
|
|
597
|
+
case "matchEnded":
|
|
598
|
+
if (snapshot.status !== "inProgress") {
|
|
599
|
+
throw new TypeError("Match is already terminal");
|
|
600
|
+
}
|
|
601
|
+
next = {
|
|
602
|
+
...cloneSnapshot(snapshot),
|
|
603
|
+
status: "ended",
|
|
604
|
+
winner: null,
|
|
605
|
+
endReason: event.reason,
|
|
606
|
+
pendingCourtSwitch: null,
|
|
607
|
+
};
|
|
608
|
+
break;
|
|
609
|
+
case "matchEventUndone":
|
|
610
|
+
if (
|
|
611
|
+
event.restoredSnapshot.id !== snapshot.id ||
|
|
612
|
+
!event.targetEventId.startsWith(`${snapshot.id}:`)
|
|
613
|
+
) {
|
|
614
|
+
throw new TypeError("Undo payload belongs to a different match");
|
|
615
|
+
}
|
|
616
|
+
const targetSequence = Number(event.targetEventId.slice(snapshot.id.length + 1));
|
|
617
|
+
if (
|
|
618
|
+
!Number.isInteger(targetSequence) ||
|
|
619
|
+
targetSequence < 1 ||
|
|
620
|
+
targetSequence >= event.sequence ||
|
|
621
|
+
event.restoredSnapshot.sequence !== targetSequence - 1
|
|
622
|
+
) {
|
|
623
|
+
throw new TypeError("Undo payload has an invalid target lineage");
|
|
624
|
+
}
|
|
625
|
+
next = cloneSnapshot(event.restoredSnapshot);
|
|
626
|
+
break;
|
|
627
|
+
default:
|
|
628
|
+
throw new TypeError("Unknown match event type");
|
|
629
|
+
}
|
|
630
|
+
const result = { ...next, sequence: event.sequence };
|
|
631
|
+
assertRuntimeMatchSnapshot(result);
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function awardRally(
|
|
636
|
+
snapshot: MatchSnapshot,
|
|
637
|
+
displaySide: CourtSide,
|
|
638
|
+
source: MatchInputSource = "local",
|
|
639
|
+
): RallyAwardedEvent | null {
|
|
640
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) return null;
|
|
641
|
+
if (displaySide !== "left" && displaySide !== "right") {
|
|
642
|
+
throw new TypeError("Invalid display side");
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
type: "rallyAwarded",
|
|
646
|
+
...eventIdentity(snapshot),
|
|
647
|
+
source,
|
|
648
|
+
displaySide,
|
|
649
|
+
winnerTeamId: snapshot.courtSides[displaySide],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function confirmCourtSwitch(
|
|
654
|
+
snapshot: MatchSnapshot,
|
|
655
|
+
source: MatchInputSource = "local",
|
|
656
|
+
): CourtSwitchConfirmedEvent {
|
|
657
|
+
if (snapshot.status !== "inProgress" || !snapshot.pendingCourtSwitch) {
|
|
658
|
+
throw new TypeError("No court switch is pending");
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
type: "courtSwitchConfirmed",
|
|
662
|
+
...eventIdentity(snapshot),
|
|
663
|
+
source,
|
|
664
|
+
pending: { ...snapshot.pendingCourtSwitch } as PendingCourtSwitch,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function correctMatch(
|
|
669
|
+
snapshot: MatchSnapshot,
|
|
670
|
+
correction: MatchCorrection,
|
|
671
|
+
source: MatchInputSource = "local",
|
|
672
|
+
): MatchCorrectedEvent {
|
|
673
|
+
const event: MatchCorrectedEvent = {
|
|
674
|
+
type: "matchCorrected",
|
|
675
|
+
...eventIdentity(snapshot),
|
|
676
|
+
source,
|
|
677
|
+
correction: {
|
|
678
|
+
...(correction.score ? { score: { ...correction.score } } : {}),
|
|
679
|
+
...(correction.serve ? { serve: { ...correction.serve } } : {}),
|
|
680
|
+
...(correction.playerCourts
|
|
681
|
+
? { playerCourts: { ...correction.playerCourts } }
|
|
682
|
+
: {}),
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
applyMatchEvent(snapshot, event);
|
|
686
|
+
return event;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function endMatch(
|
|
690
|
+
snapshot: MatchSnapshot,
|
|
691
|
+
reason: string,
|
|
692
|
+
source: MatchInputSource = "local",
|
|
693
|
+
): MatchEndedEvent {
|
|
694
|
+
assertNonEmpty(reason, "End reason");
|
|
695
|
+
if (snapshot.status !== "inProgress") throw new TypeError("Match is already terminal");
|
|
696
|
+
return {
|
|
697
|
+
type: "matchEnded",
|
|
698
|
+
...eventIdentity(snapshot),
|
|
699
|
+
source,
|
|
700
|
+
reason,
|
|
701
|
+
};
|
|
702
|
+
}
|