@pickleball/expo-sdk 0.1.1 → 0.2.0
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 +24 -0
- package/README.md +69 -0
- package/dist/advanced-BXDrDl1h.d.ts +316 -0
- package/dist/advanced-BdAa2Erv.d.cts +316 -0
- package/dist/advanced.cjs +35 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +35 -0
- package/dist/chunk-2JHULJZ7.js +265 -0
- package/dist/chunk-6JOMESNT.js +9 -0
- package/dist/chunk-7OX2DSKI.cjs +33 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-FODE2W2H.js +1953 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBPR2QND.cjs +1953 -0
- package/dist/chunk-TRLASNPQ.cjs +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- package/dist/device-agent/index.cjs +498 -0
- package/dist/device-agent/index.d.cts +157 -0
- package/dist/device-agent/index.d.ts +157 -0
- package/dist/device-agent/index.js +498 -0
- package/dist/engine-CCSSqPbZ.d.cts +516 -0
- package/dist/engine-CCSSqPbZ.d.ts +516 -0
- package/dist/index.cjs +339 -5569
- package/dist/index.d.cts +22 -1406
- package/dist/index.d.ts +22 -1406
- package/dist/index.js +269 -5401
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +53 -70
- package/dist/plugin.d.cts +13 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +20 -15
- package/dist/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -0
- package/dist/version.cjs +4 -32
- package/dist/version.js +2 -1
- package/package.json +68 -4
- package/src/advanced.ts +47 -0
- package/src/device-agent/agent.ts +443 -0
- package/src/device-agent/component.tsx +49 -0
- package/src/device-agent/convex-control-channel.ts +82 -0
- package/src/device-agent/http-transport.ts +143 -0
- package/src/device-agent/index.ts +4 -0
- package/src/engine.ts +21 -5
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +19 -13
- package/src/index.ts +38 -14
- package/src/livekit-room-adapter.ts +39 -2
- package/src/livestream-screen.tsx +26 -4
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +54 -4
- package/src/plugin.ts +35 -14
- package/src/provider.tsx +35 -4
- package/src/rtmp-room-adapter.ts +196 -0
- package/dist/chunk-VHLUJQG5.js +0 -17
|
@@ -0,0 +1,2456 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDefaultMatchVoiceService
|
|
3
|
+
} from "../chunk-DHDX3ZOO.js";
|
|
4
|
+
import {
|
|
5
|
+
BTHomeRemoteDeduplicator,
|
|
6
|
+
DEFAULT_REMOTE_ACTION_MAPPING,
|
|
7
|
+
HidVolumeGestureRecognizer,
|
|
8
|
+
RemoteError,
|
|
9
|
+
decodeBase64Bytes,
|
|
10
|
+
parseBTHomeV2Advertisement
|
|
11
|
+
} from "../chunk-2JHULJZ7.js";
|
|
12
|
+
import {
|
|
13
|
+
__require
|
|
14
|
+
} from "../chunk-KFQGP6VL.js";
|
|
15
|
+
|
|
16
|
+
// src/match/state.ts
|
|
17
|
+
function clonePlayer(player) {
|
|
18
|
+
return { id: player.id, name: player.name };
|
|
19
|
+
}
|
|
20
|
+
function cloneTeam(team) {
|
|
21
|
+
return {
|
|
22
|
+
id: team.id,
|
|
23
|
+
...team.name === void 0 ? {} : { name: team.name },
|
|
24
|
+
...team.initialServerId === void 0 ? {} : { initialServerId: team.initialServerId },
|
|
25
|
+
players: team.players.map(clonePlayer)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function cloneGame(game) {
|
|
29
|
+
return { ...game, score: { ...game.score } };
|
|
30
|
+
}
|
|
31
|
+
function cloneSnapshot(snapshot) {
|
|
32
|
+
return {
|
|
33
|
+
...snapshot,
|
|
34
|
+
teams: { A: cloneTeam(snapshot.teams.A), B: cloneTeam(snapshot.teams.B) },
|
|
35
|
+
designatedServerByTeam: { ...snapshot.designatedServerByTeam },
|
|
36
|
+
courtSides: { ...snapshot.courtSides },
|
|
37
|
+
playerCourts: { ...snapshot.playerCourts },
|
|
38
|
+
games: snapshot.games.map(cloneGame),
|
|
39
|
+
gamesWon: { ...snapshot.gamesWon },
|
|
40
|
+
serve: { ...snapshot.serve },
|
|
41
|
+
pendingCourtSwitch: snapshot.pendingCourtSwitch ? { ...snapshot.pendingCourtSwitch } : null
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function valuesEqual(left, right) {
|
|
45
|
+
if (left === right) return true;
|
|
46
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
47
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => valuesEqual(value, right[index]));
|
|
48
|
+
}
|
|
49
|
+
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const leftRecord = left;
|
|
53
|
+
const rightRecord = right;
|
|
54
|
+
const leftKeys = Object.keys(leftRecord).sort();
|
|
55
|
+
const rightKeys = Object.keys(rightRecord).sort();
|
|
56
|
+
return leftKeys.length === rightKeys.length && leftKeys.every(
|
|
57
|
+
(key, index) => key === rightKeys[index] && valuesEqual(leftRecord[key], rightRecord[key])
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/match/validation.ts
|
|
62
|
+
var TEAM_IDS = ["A", "B"];
|
|
63
|
+
var INPUT_SOURCES = ["local", "remote", "voice", "ble", "system"];
|
|
64
|
+
function isRecord(value) {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
function assertRecord(value, label) {
|
|
68
|
+
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
|
69
|
+
}
|
|
70
|
+
function assertNonEmptyString(value, label) {
|
|
71
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
72
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function assertInteger(value, minimum, maximum, label) {
|
|
76
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
77
|
+
throw new TypeError(`${label} must be an integer from ${minimum} to ${maximum}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isTeamId(value) {
|
|
81
|
+
return value === "A" || value === "B";
|
|
82
|
+
}
|
|
83
|
+
function isCourtSide(value) {
|
|
84
|
+
return value === "left" || value === "right";
|
|
85
|
+
}
|
|
86
|
+
function isFormat(value) {
|
|
87
|
+
return value === "singles" || value === "doubles";
|
|
88
|
+
}
|
|
89
|
+
function isScoringMode(value) {
|
|
90
|
+
return value === "rally" || value === "sideOut";
|
|
91
|
+
}
|
|
92
|
+
function isSource(value) {
|
|
93
|
+
return INPUT_SOURCES.some((source) => source === value);
|
|
94
|
+
}
|
|
95
|
+
function assertRecordKeys(value, allowed, label) {
|
|
96
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
97
|
+
throw new TypeError(`${label} contains an unknown field`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function assertPendingSwitch(value) {
|
|
101
|
+
assertRecord(value, "Pending court switch");
|
|
102
|
+
if (value.reason === "gameEnd") {
|
|
103
|
+
if (value.game !== 1 && value.game !== 2) {
|
|
104
|
+
throw new TypeError("Game-end switch must follow game one or two");
|
|
105
|
+
}
|
|
106
|
+
} else if (value.reason === "gameThreeMidpoint") {
|
|
107
|
+
if (value.game !== 3) throw new TypeError("Midpoint switch must be in game three");
|
|
108
|
+
} else {
|
|
109
|
+
throw new TypeError("Invalid pending court switch reason");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function assertRuntimeMatchSnapshot(value) {
|
|
113
|
+
assertRecord(value, "Match snapshot");
|
|
114
|
+
assertNonEmptyString(value.id, "Match id");
|
|
115
|
+
if (!isFormat(value.format)) throw new TypeError("Invalid match format");
|
|
116
|
+
if (!isScoringMode(value.scoringMode)) throw new TypeError("Invalid scoring mode");
|
|
117
|
+
assertInteger(value.targetScore, 1, 99, "Target score");
|
|
118
|
+
if (value.bestOf !== 3) throw new TypeError("Match must be best-of-three");
|
|
119
|
+
if (!isTeamId(value.initialServingTeam)) {
|
|
120
|
+
throw new TypeError("Invalid initial serving team");
|
|
121
|
+
}
|
|
122
|
+
assertInteger(value.sequence, 0, Number.MAX_SAFE_INTEGER, "Match sequence");
|
|
123
|
+
assertRecord(value.teams, "Teams");
|
|
124
|
+
assertRecordKeys(value.teams, TEAM_IDS, "Teams");
|
|
125
|
+
const expectedPlayers = value.format === "singles" ? 1 : 2;
|
|
126
|
+
const playerIds = /* @__PURE__ */ new Set();
|
|
127
|
+
const teamPlayerIds = { A: [], B: [] };
|
|
128
|
+
for (const teamId of TEAM_IDS) {
|
|
129
|
+
const team = value.teams[teamId];
|
|
130
|
+
assertRecord(team, `Team ${teamId}`);
|
|
131
|
+
if (team.id !== teamId || !Array.isArray(team.players) || team.players.length !== expectedPlayers) {
|
|
132
|
+
throw new TypeError(`Team ${teamId} roster is invalid`);
|
|
133
|
+
}
|
|
134
|
+
if (team.name !== void 0) assertNonEmptyString(team.name, `Team ${teamId} name`);
|
|
135
|
+
for (const rawPlayer of team.players) {
|
|
136
|
+
assertRecord(rawPlayer, "Player");
|
|
137
|
+
assertNonEmptyString(rawPlayer.id, "Player id");
|
|
138
|
+
assertNonEmptyString(rawPlayer.name, "Player name");
|
|
139
|
+
if (playerIds.has(rawPlayer.id)) throw new TypeError("Player ids must be unique");
|
|
140
|
+
playerIds.add(rawPlayer.id);
|
|
141
|
+
teamPlayerIds[teamId].push(rawPlayer.id);
|
|
142
|
+
}
|
|
143
|
+
if (team.initialServerId !== void 0 && !teamPlayerIds[teamId].includes(team.initialServerId)) {
|
|
144
|
+
throw new TypeError(`Team ${teamId} initial server is invalid`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
assertRecord(value.designatedServerByTeam, "Designated servers");
|
|
148
|
+
for (const teamId of TEAM_IDS) {
|
|
149
|
+
if (!teamPlayerIds[teamId].includes(value.designatedServerByTeam[teamId])) {
|
|
150
|
+
throw new TypeError(`Team ${teamId} designated server is invalid`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
assertRecord(value.courtSides, "Display court mapping");
|
|
154
|
+
if (!isTeamId(value.courtSides.left) || !isTeamId(value.courtSides.right) || value.courtSides.left === value.courtSides.right) {
|
|
155
|
+
throw new TypeError("Display court mapping must contain both teams");
|
|
156
|
+
}
|
|
157
|
+
assertRecord(value.playerCourts, "Player court mapping");
|
|
158
|
+
assertRecordKeys(value.playerCourts, [...playerIds], "Player court mapping");
|
|
159
|
+
if (Object.keys(value.playerCourts).length !== playerIds.size) {
|
|
160
|
+
throw new TypeError("Every player must have a court mapping");
|
|
161
|
+
}
|
|
162
|
+
for (const playerId of playerIds) {
|
|
163
|
+
if (!isCourtSide(value.playerCourts[playerId])) {
|
|
164
|
+
throw new TypeError("Player court mapping contains an invalid side");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (value.format === "doubles") {
|
|
168
|
+
for (const teamId of TEAM_IDS) {
|
|
169
|
+
const [first, second] = teamPlayerIds[teamId];
|
|
170
|
+
if (value.playerCourts[first] === value.playerCourts[second]) {
|
|
171
|
+
throw new TypeError(`Team ${teamId} players must occupy different courts`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(value.games) || value.games.length < 1 || value.games.length > 3) {
|
|
176
|
+
throw new TypeError("Match games are invalid");
|
|
177
|
+
}
|
|
178
|
+
const games = value.games;
|
|
179
|
+
if (value.currentGame !== games.length) {
|
|
180
|
+
throw new TypeError("Current game must be the final game snapshot");
|
|
181
|
+
}
|
|
182
|
+
const winnerCounts = { A: 0, B: 0 };
|
|
183
|
+
games.forEach((rawGame, index) => {
|
|
184
|
+
assertRecord(rawGame, "Game snapshot");
|
|
185
|
+
if (rawGame.number !== index + 1 || !isTeamId(rawGame.startingServingTeam)) {
|
|
186
|
+
throw new TypeError("Game identity is invalid");
|
|
187
|
+
}
|
|
188
|
+
assertRecord(rawGame.score, "Game score");
|
|
189
|
+
for (const teamId of TEAM_IDS) {
|
|
190
|
+
assertInteger(rawGame.score[teamId], 0, value.targetScore, "Game score");
|
|
191
|
+
}
|
|
192
|
+
if (rawGame.winner !== null && !isTeamId(rawGame.winner)) {
|
|
193
|
+
throw new TypeError("Game winner is invalid");
|
|
194
|
+
}
|
|
195
|
+
if (isTeamId(rawGame.winner) && rawGame.score[rawGame.winner] !== value.targetScore) {
|
|
196
|
+
throw new TypeError("Winning game must reach target score");
|
|
197
|
+
}
|
|
198
|
+
if (rawGame.winner === null) {
|
|
199
|
+
if (rawGame.score.A === value.targetScore || rawGame.score.B === value.targetScore) {
|
|
200
|
+
throw new TypeError("A game at target score must have a winner");
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const losingTeam = rawGame.winner === "A" ? "B" : "A";
|
|
204
|
+
if (rawGame.score[losingTeam] >= value.targetScore) {
|
|
205
|
+
throw new TypeError("The losing team must remain below target score");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (isTeamId(rawGame.winner)) winnerCounts[rawGame.winner] += 1;
|
|
209
|
+
if (typeof rawGame.midGameSwitchCompleted !== "boolean") {
|
|
210
|
+
throw new TypeError("Game midpoint switch flag is invalid");
|
|
211
|
+
}
|
|
212
|
+
if (rawGame.midGameSwitchCompleted && rawGame.number !== 3) {
|
|
213
|
+
throw new TypeError("Only game three has a midpoint switch");
|
|
214
|
+
}
|
|
215
|
+
if (index < games.length - 1 && rawGame.winner === null) {
|
|
216
|
+
throw new TypeError("Previous games must have a winner");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
assertRecord(value.gamesWon, "Games won");
|
|
220
|
+
for (const teamId of TEAM_IDS) {
|
|
221
|
+
if (value.gamesWon[teamId] !== winnerCounts[teamId]) {
|
|
222
|
+
throw new TypeError("Games-won total does not match game history");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
assertRecord(value.serve, "Serve state");
|
|
226
|
+
if (!isTeamId(value.serve.teamId) || !teamPlayerIds[value.serve.teamId].includes(value.serve.playerId) || value.serve.serverNumber !== 1 && value.serve.serverNumber !== 2 || !isCourtSide(value.serve.court)) {
|
|
227
|
+
throw new TypeError("Serve state is invalid");
|
|
228
|
+
}
|
|
229
|
+
if ((value.format === "singles" || value.scoringMode === "rally") && value.serve.serverNumber !== 1) {
|
|
230
|
+
throw new TypeError("This match mode has one server");
|
|
231
|
+
}
|
|
232
|
+
const currentScore = games[value.currentGame - 1].score;
|
|
233
|
+
const parityCourt = currentScore[value.serve.teamId] % 2 === 0 ? "right" : "left";
|
|
234
|
+
if ((value.format === "singles" || value.scoringMode === "rally" || value.serve.serverNumber === 1) && value.serve.court !== parityCourt) {
|
|
235
|
+
throw new TypeError("Server court does not match score parity");
|
|
236
|
+
}
|
|
237
|
+
if (value.format === "doubles" && value.playerCourts[value.serve.playerId] !== value.serve.court) {
|
|
238
|
+
throw new TypeError("Server court does not match player position");
|
|
239
|
+
}
|
|
240
|
+
const currentGame2 = games[games.length - 1];
|
|
241
|
+
if (value.pendingCourtSwitch !== null) {
|
|
242
|
+
assertPendingSwitch(value.pendingCourtSwitch);
|
|
243
|
+
const pending = value.pendingCourtSwitch;
|
|
244
|
+
if (pending.game !== value.currentGame) {
|
|
245
|
+
throw new TypeError("Pending switch must belong to the current game");
|
|
246
|
+
}
|
|
247
|
+
if (pending.reason === "gameEnd" && currentGame2.winner === null) {
|
|
248
|
+
throw new TypeError("Game-end switch requires a game winner");
|
|
249
|
+
}
|
|
250
|
+
if (pending.reason === "gameThreeMidpoint") {
|
|
251
|
+
const score = currentGame2.score;
|
|
252
|
+
if (currentGame2.winner !== null || currentGame2.midGameSwitchCompleted !== false || Math.max(score.A, score.B) < Math.ceil(value.targetScore / 2)) {
|
|
253
|
+
throw new TypeError("Game-three midpoint switch state is invalid");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (value.status === "inProgress") {
|
|
258
|
+
if (winnerCounts.A >= 2 || winnerCounts.B >= 2) {
|
|
259
|
+
throw new TypeError("In-progress match cannot already have a match winner");
|
|
260
|
+
}
|
|
261
|
+
if (value.winner !== null || value.endReason !== null) {
|
|
262
|
+
throw new TypeError("In-progress match cannot have terminal metadata");
|
|
263
|
+
}
|
|
264
|
+
if (currentGame2.winner !== null && value.pendingCourtSwitch === null) {
|
|
265
|
+
throw new TypeError("Completed game must await a court switch");
|
|
266
|
+
}
|
|
267
|
+
} else if (value.status === "completed") {
|
|
268
|
+
const matchWinners = TEAM_IDS.filter((teamId) => winnerCounts[teamId] === 2);
|
|
269
|
+
if (matchWinners.length !== 1 || !isTeamId(value.winner) || matchWinners[0] !== value.winner || value.gamesWon[value.winner] !== 2 || currentGame2.winner !== value.winner || value.endReason !== null || value.pendingCourtSwitch !== null) {
|
|
270
|
+
throw new TypeError("Completed match state is invalid");
|
|
271
|
+
}
|
|
272
|
+
} else if (value.status === "ended") {
|
|
273
|
+
if (winnerCounts.A >= 2 || winnerCounts.B >= 2 || value.winner !== null || typeof value.endReason !== "string" || value.endReason.trim().length === 0 || value.pendingCourtSwitch !== null) {
|
|
274
|
+
throw new TypeError("Explicitly ended match state is invalid");
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
throw new TypeError("Match status is invalid");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function assertCorrection(value) {
|
|
281
|
+
assertRecord(value, "Match correction");
|
|
282
|
+
assertRecordKeys(value, ["score", "serve", "playerCourts"], "Match correction");
|
|
283
|
+
if (value.score === void 0 && value.serve === void 0 && value.playerCourts === void 0) {
|
|
284
|
+
throw new TypeError("Match correction is empty");
|
|
285
|
+
}
|
|
286
|
+
if (value.score !== void 0) {
|
|
287
|
+
assertRecord(value.score, "Corrected score");
|
|
288
|
+
assertRecordKeys(value.score, TEAM_IDS, "Corrected score");
|
|
289
|
+
for (const score of Object.values(value.score)) {
|
|
290
|
+
if (!Number.isInteger(score)) throw new TypeError("Corrected score must be an integer");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (value.serve !== void 0) {
|
|
294
|
+
assertRecord(value.serve, "Corrected serve");
|
|
295
|
+
if (!isTeamId(value.serve.teamId) || typeof value.serve.playerId !== "string" || value.serve.serverNumber !== 1 && value.serve.serverNumber !== 2 || !isCourtSide(value.serve.court)) {
|
|
296
|
+
throw new TypeError("Corrected serve is malformed");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (value.playerCourts !== void 0) {
|
|
300
|
+
assertRecord(value.playerCourts, "Corrected player courts");
|
|
301
|
+
for (const court of Object.values(value.playerCourts)) {
|
|
302
|
+
if (!isCourtSide(court)) throw new TypeError("Corrected player court is malformed");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function assertRuntimeMatchEvent(value) {
|
|
307
|
+
assertRecord(value, "Match event");
|
|
308
|
+
if (value.schemaVersion !== 1) throw new TypeError("Unsupported match event schema");
|
|
309
|
+
assertNonEmptyString(value.id, "Match event id");
|
|
310
|
+
assertInteger(value.sequence, 0, Number.MAX_SAFE_INTEGER, "Match event sequence");
|
|
311
|
+
if (!isSource(value.source)) throw new TypeError("Invalid match event source");
|
|
312
|
+
switch (value.type) {
|
|
313
|
+
case "matchCreated":
|
|
314
|
+
assertRuntimeMatchSnapshot(value.snapshot);
|
|
315
|
+
break;
|
|
316
|
+
case "rallyAwarded":
|
|
317
|
+
if (!isCourtSide(value.displaySide) || !isTeamId(value.winnerTeamId)) {
|
|
318
|
+
throw new TypeError("Rally event payload is invalid");
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
case "courtSwitchConfirmed":
|
|
322
|
+
assertPendingSwitch(value.pending);
|
|
323
|
+
break;
|
|
324
|
+
case "matchCorrected":
|
|
325
|
+
assertCorrection(value.correction);
|
|
326
|
+
break;
|
|
327
|
+
case "matchEnded":
|
|
328
|
+
assertNonEmptyString(value.reason, "Match end reason");
|
|
329
|
+
break;
|
|
330
|
+
case "matchEventUndone":
|
|
331
|
+
assertNonEmptyString(value.targetEventId, "Undo target event id");
|
|
332
|
+
assertRuntimeMatchSnapshot(value.restoredSnapshot);
|
|
333
|
+
break;
|
|
334
|
+
default:
|
|
335
|
+
throw new TypeError("Unknown match event type");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/match/engine.ts
|
|
340
|
+
var DEFAULT_TARGET_SCORE = 11;
|
|
341
|
+
function oppositeTeam(teamId) {
|
|
342
|
+
return teamId === "A" ? "B" : "A";
|
|
343
|
+
}
|
|
344
|
+
function oppositeSide(side) {
|
|
345
|
+
return side === "left" ? "right" : "left";
|
|
346
|
+
}
|
|
347
|
+
function serviceSide(score) {
|
|
348
|
+
return score % 2 === 0 ? "right" : "left";
|
|
349
|
+
}
|
|
350
|
+
function assertNonEmpty(value, label) {
|
|
351
|
+
if (value.trim().length === 0) throw new TypeError(`${label} must not be empty`);
|
|
352
|
+
}
|
|
353
|
+
function assertCreateInput(input) {
|
|
354
|
+
assertNonEmpty(input.id, "Match id");
|
|
355
|
+
if (input.format !== "singles" && input.format !== "doubles") {
|
|
356
|
+
throw new TypeError("Invalid match format");
|
|
357
|
+
}
|
|
358
|
+
if (input.scoringMode !== "rally" && input.scoringMode !== "sideOut") {
|
|
359
|
+
throw new TypeError("Invalid scoring mode");
|
|
360
|
+
}
|
|
361
|
+
const target = input.targetScore ?? DEFAULT_TARGET_SCORE;
|
|
362
|
+
if (!Number.isInteger(target) || target < 1 || target > 99) {
|
|
363
|
+
throw new TypeError("Target score must be an integer from 1 to 99");
|
|
364
|
+
}
|
|
365
|
+
const expectedPlayers = input.format === "singles" ? 1 : 2;
|
|
366
|
+
const playerIds = /* @__PURE__ */ new Set();
|
|
367
|
+
for (const teamId of ["A", "B"]) {
|
|
368
|
+
const team = input.teams[teamId];
|
|
369
|
+
if (!team || team.id !== teamId || team.players.length !== expectedPlayers) {
|
|
370
|
+
throw new TypeError(`Team ${teamId} must have ${expectedPlayers} player(s)`);
|
|
371
|
+
}
|
|
372
|
+
if (team.name !== void 0) assertNonEmpty(team.name, `Team ${teamId} name`);
|
|
373
|
+
for (const player of team.players) {
|
|
374
|
+
assertNonEmpty(player.id, "Player id");
|
|
375
|
+
assertNonEmpty(player.name, "Player name");
|
|
376
|
+
if (playerIds.has(player.id)) throw new TypeError("Player ids must be unique");
|
|
377
|
+
playerIds.add(player.id);
|
|
378
|
+
}
|
|
379
|
+
if (team.initialServerId !== void 0 && !team.players.some((player) => player.id === team.initialServerId)) {
|
|
380
|
+
throw new TypeError(`Team ${teamId} initial server must belong to the team`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (input.initialServingTeam !== void 0 && input.initialServingTeam !== "A" && input.initialServingTeam !== "B") {
|
|
384
|
+
throw new TypeError("Invalid initial serving team");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function initialPlayerCourts(teams, designatedServerByTeam) {
|
|
388
|
+
const courts = {};
|
|
389
|
+
for (const teamId of ["A", "B"]) {
|
|
390
|
+
teams[teamId].players.forEach((player) => {
|
|
391
|
+
courts[player.id] = player.id === designatedServerByTeam[teamId] ? "right" : "left";
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return courts;
|
|
395
|
+
}
|
|
396
|
+
function gameNumber(value) {
|
|
397
|
+
if (value !== 1 && value !== 2 && value !== 3) {
|
|
398
|
+
throw new TypeError("A best-of-three match cannot have another game");
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
function makeGame(number, startingServingTeam) {
|
|
403
|
+
return {
|
|
404
|
+
number,
|
|
405
|
+
score: { A: 0, B: 0 },
|
|
406
|
+
startingServingTeam,
|
|
407
|
+
winner: null,
|
|
408
|
+
midGameSwitchCompleted: false
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function initialServe(snapshot, teamId) {
|
|
412
|
+
const playerId = snapshot.designatedServerByTeam[teamId];
|
|
413
|
+
return {
|
|
414
|
+
teamId,
|
|
415
|
+
playerId,
|
|
416
|
+
serverNumber: snapshot.format === "doubles" && snapshot.scoringMode === "sideOut" ? 2 : 1,
|
|
417
|
+
court: snapshot.playerCourts[playerId]
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function eventIdentity(snapshot) {
|
|
421
|
+
const sequence = snapshot.sequence + 1;
|
|
422
|
+
return { schemaVersion: 1, id: `${snapshot.id}:${sequence}`, sequence };
|
|
423
|
+
}
|
|
424
|
+
function createMatch(input, source = "system") {
|
|
425
|
+
assertCreateInput(input);
|
|
426
|
+
const teams = { A: cloneTeam(input.teams.A), B: cloneTeam(input.teams.B) };
|
|
427
|
+
const designatedServerByTeam = {
|
|
428
|
+
A: teams.A.initialServerId ?? teams.A.players[0].id,
|
|
429
|
+
B: teams.B.initialServerId ?? teams.B.players[0].id
|
|
430
|
+
};
|
|
431
|
+
const playerCourts = initialPlayerCourts(teams, designatedServerByTeam);
|
|
432
|
+
const initialServingTeam = input.initialServingTeam ?? "A";
|
|
433
|
+
const base = {
|
|
434
|
+
id: input.id,
|
|
435
|
+
format: input.format,
|
|
436
|
+
scoringMode: input.scoringMode,
|
|
437
|
+
targetScore: input.targetScore ?? DEFAULT_TARGET_SCORE,
|
|
438
|
+
bestOf: 3,
|
|
439
|
+
teams,
|
|
440
|
+
designatedServerByTeam,
|
|
441
|
+
initialServingTeam,
|
|
442
|
+
courtSides: { left: "A", right: "B" },
|
|
443
|
+
playerCourts,
|
|
444
|
+
games: [makeGame(1, initialServingTeam)],
|
|
445
|
+
currentGame: 1,
|
|
446
|
+
gamesWon: { A: 0, B: 0 },
|
|
447
|
+
pendingCourtSwitch: null,
|
|
448
|
+
status: "inProgress",
|
|
449
|
+
winner: null,
|
|
450
|
+
endReason: null,
|
|
451
|
+
sequence: 0
|
|
452
|
+
};
|
|
453
|
+
const snapshot = {
|
|
454
|
+
...base,
|
|
455
|
+
serve: initialServe(base, initialServingTeam)
|
|
456
|
+
};
|
|
457
|
+
const event = {
|
|
458
|
+
type: "matchCreated",
|
|
459
|
+
schemaVersion: 1,
|
|
460
|
+
id: `${snapshot.id}:0`,
|
|
461
|
+
sequence: 0,
|
|
462
|
+
source,
|
|
463
|
+
input,
|
|
464
|
+
snapshot: cloneSnapshot(snapshot)
|
|
465
|
+
};
|
|
466
|
+
return { snapshot, events: [event] };
|
|
467
|
+
}
|
|
468
|
+
function assertNextEvent(snapshot, event) {
|
|
469
|
+
if (event.sequence !== snapshot.sequence + 1) {
|
|
470
|
+
throw new TypeError("Match event sequence is not contiguous");
|
|
471
|
+
}
|
|
472
|
+
if (event.id !== `${snapshot.id}:${event.sequence}`) {
|
|
473
|
+
throw new TypeError("Match event id does not match its sequence");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function currentGame(snapshot) {
|
|
477
|
+
const game = snapshot.games[snapshot.currentGame - 1];
|
|
478
|
+
if (!game) throw new TypeError("Current game is missing");
|
|
479
|
+
return game;
|
|
480
|
+
}
|
|
481
|
+
function updateCurrentGame(snapshot, update) {
|
|
482
|
+
const games = snapshot.games.map(
|
|
483
|
+
(game, index) => index === snapshot.currentGame - 1 ? update(game) : game
|
|
484
|
+
);
|
|
485
|
+
return { ...snapshot, games };
|
|
486
|
+
}
|
|
487
|
+
function playerOnCourt(snapshot, teamId, court) {
|
|
488
|
+
const player = snapshot.teams[teamId].players.find(
|
|
489
|
+
(candidate) => snapshot.playerCourts[candidate.id] === court
|
|
490
|
+
);
|
|
491
|
+
if (!player) throw new TypeError(`Team ${teamId} has no player on ${court}`);
|
|
492
|
+
return player.id;
|
|
493
|
+
}
|
|
494
|
+
function swapTeamPlayers(snapshot, teamId) {
|
|
495
|
+
if (snapshot.format === "singles") return snapshot;
|
|
496
|
+
const playerCourts = { ...snapshot.playerCourts };
|
|
497
|
+
for (const player of snapshot.teams[teamId].players) {
|
|
498
|
+
playerCourts[player.id] = oppositeSide(playerCourts[player.id]);
|
|
499
|
+
}
|
|
500
|
+
return { ...snapshot, playerCourts };
|
|
501
|
+
}
|
|
502
|
+
function serveForPossession(snapshot, teamId, serverNumber = 1) {
|
|
503
|
+
const score = currentGame(snapshot).score[teamId];
|
|
504
|
+
const court = serviceSide(score);
|
|
505
|
+
if (snapshot.format === "singles") {
|
|
506
|
+
return {
|
|
507
|
+
teamId,
|
|
508
|
+
playerId: snapshot.teams[teamId].players[0].id,
|
|
509
|
+
serverNumber: 1,
|
|
510
|
+
court
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
teamId,
|
|
515
|
+
playerId: playerOnCourt(snapshot, teamId, court),
|
|
516
|
+
serverNumber,
|
|
517
|
+
court
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function finishPoint(snapshot, scoringTeam) {
|
|
521
|
+
if (!scoringTeam) return snapshot;
|
|
522
|
+
const game = currentGame(snapshot);
|
|
523
|
+
if (game.score[scoringTeam] < snapshot.targetScore) {
|
|
524
|
+
if (snapshot.currentGame === 3 && !game.midGameSwitchCompleted && game.score[scoringTeam] >= Math.ceil(snapshot.targetScore / 2)) {
|
|
525
|
+
return {
|
|
526
|
+
...snapshot,
|
|
527
|
+
pendingCourtSwitch: { reason: "gameThreeMidpoint", game: 3 }
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
return snapshot;
|
|
531
|
+
}
|
|
532
|
+
let next = updateCurrentGame(snapshot, (value) => ({
|
|
533
|
+
...value,
|
|
534
|
+
winner: scoringTeam
|
|
535
|
+
}));
|
|
536
|
+
const gamesWon = {
|
|
537
|
+
...next.gamesWon,
|
|
538
|
+
[scoringTeam]: next.gamesWon[scoringTeam] + 1
|
|
539
|
+
};
|
|
540
|
+
next = { ...next, gamesWon };
|
|
541
|
+
if (gamesWon[scoringTeam] === 2) {
|
|
542
|
+
return {
|
|
543
|
+
...next,
|
|
544
|
+
status: "completed",
|
|
545
|
+
winner: scoringTeam,
|
|
546
|
+
pendingCourtSwitch: null
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
...next,
|
|
551
|
+
pendingCourtSwitch: {
|
|
552
|
+
reason: "gameEnd",
|
|
553
|
+
game: next.currentGame
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function scorePoint(snapshot, teamId) {
|
|
558
|
+
return updateCurrentGame(snapshot, (game) => ({
|
|
559
|
+
...game,
|
|
560
|
+
score: { ...game.score, [teamId]: game.score[teamId] + 1 }
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
function applyRally(snapshot, winnerTeamId) {
|
|
564
|
+
const servingTeam = snapshot.serve.teamId;
|
|
565
|
+
let next = snapshot;
|
|
566
|
+
let scoringTeam = null;
|
|
567
|
+
if (snapshot.scoringMode === "rally") {
|
|
568
|
+
scoringTeam = winnerTeamId;
|
|
569
|
+
next = scorePoint(next, winnerTeamId);
|
|
570
|
+
if (winnerTeamId === servingTeam) {
|
|
571
|
+
next = swapTeamPlayers(next, winnerTeamId);
|
|
572
|
+
next = {
|
|
573
|
+
...next,
|
|
574
|
+
serve: next.format === "singles" ? serveForPossession(next, winnerTeamId) : {
|
|
575
|
+
...next.serve,
|
|
576
|
+
court: next.playerCourts[next.serve.playerId]
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
} else {
|
|
580
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId) };
|
|
581
|
+
}
|
|
582
|
+
} else if (winnerTeamId === servingTeam) {
|
|
583
|
+
scoringTeam = winnerTeamId;
|
|
584
|
+
next = scorePoint(next, winnerTeamId);
|
|
585
|
+
next = swapTeamPlayers(next, winnerTeamId);
|
|
586
|
+
next = {
|
|
587
|
+
...next,
|
|
588
|
+
serve: next.format === "singles" ? serveForPossession(next, winnerTeamId) : {
|
|
589
|
+
...next.serve,
|
|
590
|
+
court: next.playerCourts[next.serve.playerId]
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
} else if (snapshot.format === "singles") {
|
|
594
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId) };
|
|
595
|
+
} else if (snapshot.serve.serverNumber === 1) {
|
|
596
|
+
const partner = snapshot.teams[servingTeam].players.find(
|
|
597
|
+
(player) => player.id !== snapshot.serve.playerId
|
|
598
|
+
);
|
|
599
|
+
if (!partner) throw new TypeError("Doubles serving partner is missing");
|
|
600
|
+
next = {
|
|
601
|
+
...next,
|
|
602
|
+
serve: {
|
|
603
|
+
teamId: servingTeam,
|
|
604
|
+
playerId: partner.id,
|
|
605
|
+
serverNumber: 2,
|
|
606
|
+
court: snapshot.playerCourts[partner.id]
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
} else {
|
|
610
|
+
next = { ...next, serve: serveForPossession(next, winnerTeamId, 1) };
|
|
611
|
+
}
|
|
612
|
+
return finishPoint(next, scoringTeam);
|
|
613
|
+
}
|
|
614
|
+
function assertPlayerCourts(snapshot) {
|
|
615
|
+
const knownIds = new Set(
|
|
616
|
+
["A", "B"].flatMap(
|
|
617
|
+
(teamId) => snapshot.teams[teamId].players.map((player) => player.id)
|
|
618
|
+
)
|
|
619
|
+
);
|
|
620
|
+
for (const [playerId, court] of Object.entries(snapshot.playerCourts)) {
|
|
621
|
+
if (!knownIds.has(playerId) || court !== "left" && court !== "right") {
|
|
622
|
+
throw new TypeError("Correction contains an invalid player court");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (Object.keys(snapshot.playerCourts).length !== knownIds.size) {
|
|
626
|
+
throw new TypeError("Every player must have a court mapping");
|
|
627
|
+
}
|
|
628
|
+
if (snapshot.format === "doubles") {
|
|
629
|
+
for (const teamId of ["A", "B"]) {
|
|
630
|
+
const [first, second] = snapshot.teams[teamId].players;
|
|
631
|
+
if (snapshot.playerCourts[first.id] === snapshot.playerCourts[second.id]) {
|
|
632
|
+
throw new TypeError(`Team ${teamId} players must occupy different courts`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function assertServe(snapshot, serve) {
|
|
638
|
+
const teamPlayerIds = snapshot.teams[serve.teamId].players.map((player) => player.id);
|
|
639
|
+
if (!teamPlayerIds.includes(serve.playerId)) {
|
|
640
|
+
throw new TypeError("Server must belong to the serving team");
|
|
641
|
+
}
|
|
642
|
+
if (serve.serverNumber !== 1 && serve.serverNumber !== 2) {
|
|
643
|
+
throw new TypeError("Server number must be one or two");
|
|
644
|
+
}
|
|
645
|
+
if (snapshot.format === "singles" || snapshot.format === "doubles" && snapshot.scoringMode === "rally") {
|
|
646
|
+
if (serve.serverNumber !== 1) throw new TypeError("This mode has one server");
|
|
647
|
+
}
|
|
648
|
+
const expectedCourt = snapshot.format === "singles" ? serviceSide(currentGame(snapshot).score[serve.teamId]) : snapshot.playerCourts[serve.playerId];
|
|
649
|
+
if (serve.court !== expectedCourt) {
|
|
650
|
+
throw new TypeError("Service court does not match score/player position");
|
|
651
|
+
}
|
|
652
|
+
if (snapshot.format === "doubles" && (snapshot.scoringMode === "rally" || serve.serverNumber === 1) && serve.court !== serviceSide(currentGame(snapshot).score[serve.teamId])) {
|
|
653
|
+
throw new TypeError("First server court does not match the serving team score");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function applyCorrection(snapshot, correction) {
|
|
657
|
+
const correctionKeys = Object.keys(correction);
|
|
658
|
+
if (correctionKeys.length === 0 || correctionKeys.some(
|
|
659
|
+
(key) => key !== "score" && key !== "serve" && key !== "playerCourts"
|
|
660
|
+
) || correction.score === void 0 && correction.serve === void 0 && correction.playerCourts === void 0) {
|
|
661
|
+
throw new TypeError("Correction must contain a supported patch");
|
|
662
|
+
}
|
|
663
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) {
|
|
664
|
+
throw new TypeError("Only an active, unpaused match can be corrected");
|
|
665
|
+
}
|
|
666
|
+
let next = cloneSnapshot(snapshot);
|
|
667
|
+
const previousScore = currentGame(snapshot).score;
|
|
668
|
+
if (correction.playerCourts) {
|
|
669
|
+
next = {
|
|
670
|
+
...next,
|
|
671
|
+
playerCourts: { ...next.playerCourts, ...correction.playerCourts }
|
|
672
|
+
};
|
|
673
|
+
assertPlayerCourts(next);
|
|
674
|
+
}
|
|
675
|
+
if (correction.score) {
|
|
676
|
+
if (Object.keys(correction.score).some((teamId) => teamId !== "A" && teamId !== "B")) {
|
|
677
|
+
throw new TypeError("Correction contains an invalid team score");
|
|
678
|
+
}
|
|
679
|
+
for (const value of Object.values(correction.score)) {
|
|
680
|
+
if (!Number.isInteger(value) || value < 0 || value > next.targetScore) {
|
|
681
|
+
throw new TypeError("Corrected scores must be non-negative integers up to target");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
next = updateCurrentGame(next, (game) => ({
|
|
685
|
+
...game,
|
|
686
|
+
score: { ...game.score, ...correction.score }
|
|
687
|
+
}));
|
|
688
|
+
const score2 = currentGame(next).score;
|
|
689
|
+
if (score2.A === next.targetScore && score2.B === next.targetScore) {
|
|
690
|
+
throw new TypeError("Both teams cannot have a winning score");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const correctedServingScore = correction.score?.[snapshot.serve.teamId];
|
|
694
|
+
const servingTeamCourtsPatched = snapshot.teams[snapshot.serve.teamId].players.some(
|
|
695
|
+
(player) => correction.playerCourts?.[player.id] !== void 0
|
|
696
|
+
);
|
|
697
|
+
if (next.format === "doubles" && correctedServingScore !== void 0 && correctedServingScore % 2 !== previousScore[snapshot.serve.teamId] % 2) {
|
|
698
|
+
if (servingTeamCourtsPatched) {
|
|
699
|
+
const hasExpectedRotation = snapshot.teams[snapshot.serve.teamId].players.every(
|
|
700
|
+
(player) => next.playerCourts[player.id] === oppositeSide(snapshot.playerCourts[player.id])
|
|
701
|
+
);
|
|
702
|
+
if (!hasExpectedRotation) {
|
|
703
|
+
throw new TypeError("Corrected serving positions do not match the score change");
|
|
704
|
+
}
|
|
705
|
+
} else {
|
|
706
|
+
next = swapTeamPlayers(next, snapshot.serve.teamId);
|
|
707
|
+
next = {
|
|
708
|
+
...next,
|
|
709
|
+
serve: { ...next.serve, court: next.playerCourts[next.serve.playerId] }
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (correction.serve) {
|
|
714
|
+
assertServe(next, correction.serve);
|
|
715
|
+
next = { ...next, serve: { ...correction.serve } };
|
|
716
|
+
} else if (correction.playerCourts?.[next.serve.playerId]) {
|
|
717
|
+
next = {
|
|
718
|
+
...next,
|
|
719
|
+
serve: { ...next.serve, court: next.playerCourts[next.serve.playerId] }
|
|
720
|
+
};
|
|
721
|
+
} else if (correction.score && next.format === "singles") {
|
|
722
|
+
next = { ...next, serve: serveForPossession(next, next.serve.teamId) };
|
|
723
|
+
}
|
|
724
|
+
assertServe(next, next.serve);
|
|
725
|
+
const score = currentGame(next).score;
|
|
726
|
+
const winningTeam = score.A === next.targetScore ? "A" : score.B === next.targetScore ? "B" : null;
|
|
727
|
+
const midpoint = Math.ceil(next.targetScore / 2);
|
|
728
|
+
const midpointTeam = score.A >= midpoint ? "A" : score.B >= midpoint ? "B" : null;
|
|
729
|
+
return finishPoint(next, winningTeam ?? midpointTeam);
|
|
730
|
+
}
|
|
731
|
+
function applyMatchEvent(snapshot, event) {
|
|
732
|
+
assertRuntimeMatchSnapshot(snapshot);
|
|
733
|
+
assertRuntimeMatchEvent(event);
|
|
734
|
+
if (event.type === "matchCreated") {
|
|
735
|
+
throw new TypeError("A creation event can only begin an event log");
|
|
736
|
+
}
|
|
737
|
+
assertNextEvent(snapshot, event);
|
|
738
|
+
let next;
|
|
739
|
+
switch (event.type) {
|
|
740
|
+
case "rallyAwarded":
|
|
741
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) {
|
|
742
|
+
throw new TypeError("A paused or terminal match cannot accept a rally");
|
|
743
|
+
}
|
|
744
|
+
if (snapshot.courtSides[event.displaySide] !== event.winnerTeamId) {
|
|
745
|
+
throw new TypeError("Rally display side does not map to its winning team");
|
|
746
|
+
}
|
|
747
|
+
next = applyRally(cloneSnapshot(snapshot), event.winnerTeamId);
|
|
748
|
+
break;
|
|
749
|
+
case "courtSwitchConfirmed": {
|
|
750
|
+
if (snapshot.status !== "inProgress" || !snapshot.pendingCourtSwitch || JSON.stringify(snapshot.pendingCourtSwitch) !== JSON.stringify(event.pending)) {
|
|
751
|
+
throw new TypeError("No matching court switch is pending");
|
|
752
|
+
}
|
|
753
|
+
next = {
|
|
754
|
+
...cloneSnapshot(snapshot),
|
|
755
|
+
courtSides: {
|
|
756
|
+
left: snapshot.courtSides.right,
|
|
757
|
+
right: snapshot.courtSides.left
|
|
758
|
+
},
|
|
759
|
+
pendingCourtSwitch: null
|
|
760
|
+
};
|
|
761
|
+
if (event.pending.reason === "gameThreeMidpoint") {
|
|
762
|
+
next = updateCurrentGame(next, (game) => ({
|
|
763
|
+
...game,
|
|
764
|
+
midGameSwitchCompleted: true
|
|
765
|
+
}));
|
|
766
|
+
} else {
|
|
767
|
+
const nextNumber = gameNumber(snapshot.currentGame + 1);
|
|
768
|
+
const startingServingTeam = nextNumber % 2 === 1 ? snapshot.initialServingTeam : oppositeTeam(snapshot.initialServingTeam);
|
|
769
|
+
const playerCourts = initialPlayerCourts(
|
|
770
|
+
snapshot.teams,
|
|
771
|
+
snapshot.designatedServerByTeam
|
|
772
|
+
);
|
|
773
|
+
const withoutServe = {
|
|
774
|
+
...next,
|
|
775
|
+
playerCourts,
|
|
776
|
+
games: [...next.games, makeGame(nextNumber, startingServingTeam)],
|
|
777
|
+
currentGame: nextNumber
|
|
778
|
+
};
|
|
779
|
+
next = {
|
|
780
|
+
...withoutServe,
|
|
781
|
+
serve: initialServe(withoutServe, startingServingTeam)
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
case "matchCorrected":
|
|
787
|
+
next = applyCorrection(snapshot, event.correction);
|
|
788
|
+
break;
|
|
789
|
+
case "matchEnded":
|
|
790
|
+
if (snapshot.status !== "inProgress") {
|
|
791
|
+
throw new TypeError("Match is already terminal");
|
|
792
|
+
}
|
|
793
|
+
next = {
|
|
794
|
+
...cloneSnapshot(snapshot),
|
|
795
|
+
status: "ended",
|
|
796
|
+
winner: null,
|
|
797
|
+
endReason: event.reason,
|
|
798
|
+
pendingCourtSwitch: null
|
|
799
|
+
};
|
|
800
|
+
break;
|
|
801
|
+
case "matchEventUndone":
|
|
802
|
+
if (event.restoredSnapshot.id !== snapshot.id || !event.targetEventId.startsWith(`${snapshot.id}:`)) {
|
|
803
|
+
throw new TypeError("Undo payload belongs to a different match");
|
|
804
|
+
}
|
|
805
|
+
const targetSequence = Number(event.targetEventId.slice(snapshot.id.length + 1));
|
|
806
|
+
if (!Number.isInteger(targetSequence) || targetSequence < 1 || targetSequence >= event.sequence || event.restoredSnapshot.sequence !== targetSequence - 1) {
|
|
807
|
+
throw new TypeError("Undo payload has an invalid target lineage");
|
|
808
|
+
}
|
|
809
|
+
next = cloneSnapshot(event.restoredSnapshot);
|
|
810
|
+
break;
|
|
811
|
+
default:
|
|
812
|
+
throw new TypeError("Unknown match event type");
|
|
813
|
+
}
|
|
814
|
+
const result = { ...next, sequence: event.sequence };
|
|
815
|
+
assertRuntimeMatchSnapshot(result);
|
|
816
|
+
return result;
|
|
817
|
+
}
|
|
818
|
+
function awardRally(snapshot, displaySide, source = "local") {
|
|
819
|
+
if (snapshot.status !== "inProgress" || snapshot.pendingCourtSwitch) return null;
|
|
820
|
+
if (displaySide !== "left" && displaySide !== "right") {
|
|
821
|
+
throw new TypeError("Invalid display side");
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
type: "rallyAwarded",
|
|
825
|
+
...eventIdentity(snapshot),
|
|
826
|
+
source,
|
|
827
|
+
displaySide,
|
|
828
|
+
winnerTeamId: snapshot.courtSides[displaySide]
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
function confirmCourtSwitch(snapshot, source = "local") {
|
|
832
|
+
if (snapshot.status !== "inProgress" || !snapshot.pendingCourtSwitch) {
|
|
833
|
+
throw new TypeError("No court switch is pending");
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
type: "courtSwitchConfirmed",
|
|
837
|
+
...eventIdentity(snapshot),
|
|
838
|
+
source,
|
|
839
|
+
pending: { ...snapshot.pendingCourtSwitch }
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function correctMatch(snapshot, correction, source = "local") {
|
|
843
|
+
const event = {
|
|
844
|
+
type: "matchCorrected",
|
|
845
|
+
...eventIdentity(snapshot),
|
|
846
|
+
source,
|
|
847
|
+
correction: {
|
|
848
|
+
...correction.score ? { score: { ...correction.score } } : {},
|
|
849
|
+
...correction.serve ? { serve: { ...correction.serve } } : {},
|
|
850
|
+
...correction.playerCourts ? { playerCourts: { ...correction.playerCourts } } : {}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
applyMatchEvent(snapshot, event);
|
|
854
|
+
return event;
|
|
855
|
+
}
|
|
856
|
+
function endMatch(snapshot, reason, source = "local") {
|
|
857
|
+
assertNonEmpty(reason, "End reason");
|
|
858
|
+
if (snapshot.status !== "inProgress") throw new TypeError("Match is already terminal");
|
|
859
|
+
return {
|
|
860
|
+
type: "matchEnded",
|
|
861
|
+
...eventIdentity(snapshot),
|
|
862
|
+
source,
|
|
863
|
+
reason
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/match/replay.ts
|
|
868
|
+
function replayTrace(events) {
|
|
869
|
+
const [rawCreated, ...remaining] = events;
|
|
870
|
+
assertRuntimeMatchEvent(rawCreated);
|
|
871
|
+
if (rawCreated.type !== "matchCreated" || rawCreated.sequence !== 0) {
|
|
872
|
+
throw new TypeError("A match event log must begin with its creation event");
|
|
873
|
+
}
|
|
874
|
+
const created = rawCreated;
|
|
875
|
+
if (created.id !== `${created.snapshot.id}:0`) {
|
|
876
|
+
throw new TypeError("Creation event id does not match its snapshot");
|
|
877
|
+
}
|
|
878
|
+
const expected = createMatch(
|
|
879
|
+
{
|
|
880
|
+
id: created.snapshot.id,
|
|
881
|
+
format: created.snapshot.format,
|
|
882
|
+
scoringMode: created.snapshot.scoringMode,
|
|
883
|
+
targetScore: created.snapshot.targetScore,
|
|
884
|
+
initialServingTeam: created.snapshot.initialServingTeam,
|
|
885
|
+
teams: created.snapshot.teams
|
|
886
|
+
},
|
|
887
|
+
created.source
|
|
888
|
+
).snapshot;
|
|
889
|
+
if (!valuesEqual(expected, created.snapshot)) {
|
|
890
|
+
throw new TypeError("Creation snapshot is not a valid initial match state");
|
|
891
|
+
}
|
|
892
|
+
let snapshot = cloneSnapshot(created.snapshot);
|
|
893
|
+
const effectiveEvents = [];
|
|
894
|
+
let processedEvents = 1;
|
|
895
|
+
let undoValidations = 0;
|
|
896
|
+
for (const rawEvent of remaining) {
|
|
897
|
+
assertRuntimeMatchEvent(rawEvent);
|
|
898
|
+
const event = rawEvent;
|
|
899
|
+
if (event.type === "matchEventUndone") {
|
|
900
|
+
const target = effectiveEvents.at(-1);
|
|
901
|
+
if (!target || target.event.id !== event.targetEventId) {
|
|
902
|
+
throw new TypeError("Undo does not target the latest effective match event");
|
|
903
|
+
}
|
|
904
|
+
if (!valuesEqual(target.before, event.restoredSnapshot)) {
|
|
905
|
+
throw new TypeError("Undo snapshot does not match its event lineage");
|
|
906
|
+
}
|
|
907
|
+
effectiveEvents.pop();
|
|
908
|
+
undoValidations += 1;
|
|
909
|
+
} else {
|
|
910
|
+
effectiveEvents.push({ event, before: cloneSnapshot(snapshot) });
|
|
911
|
+
}
|
|
912
|
+
snapshot = applyMatchEvent(snapshot, event);
|
|
913
|
+
processedEvents += 1;
|
|
914
|
+
}
|
|
915
|
+
return { snapshot, effectiveEvents, processedEvents, undoValidations };
|
|
916
|
+
}
|
|
917
|
+
function replayMatchEvents(events) {
|
|
918
|
+
return replayTrace(events).snapshot;
|
|
919
|
+
}
|
|
920
|
+
function undoLastMatchEvent(events, source = "local") {
|
|
921
|
+
const trace = replayTrace(events);
|
|
922
|
+
const target = trace.effectiveEvents.at(-1);
|
|
923
|
+
if (!target) return null;
|
|
924
|
+
const sequence = trace.snapshot.sequence + 1;
|
|
925
|
+
return {
|
|
926
|
+
type: "matchEventUndone",
|
|
927
|
+
schemaVersion: 1,
|
|
928
|
+
id: `${trace.snapshot.id}:${sequence}`,
|
|
929
|
+
sequence,
|
|
930
|
+
source,
|
|
931
|
+
targetEventId: target.event.id,
|
|
932
|
+
restoredSnapshot: cloneSnapshot(target.before)
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/match/persistence/types.ts
|
|
937
|
+
var MATCH_PERSISTENCE_SCHEMA_VERSION = 1;
|
|
938
|
+
var MatchPersistenceError = class extends Error {
|
|
939
|
+
constructor(code, message, options) {
|
|
940
|
+
super(message, options);
|
|
941
|
+
this.code = code;
|
|
942
|
+
this.retryable = code === "storage_failure";
|
|
943
|
+
}
|
|
944
|
+
name = "MatchPersistenceError";
|
|
945
|
+
recoverable = true;
|
|
946
|
+
retryable;
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// src/match/persistence/migrations.ts
|
|
950
|
+
var MATCH_SQL_SCHEMA_VERSION = 2;
|
|
951
|
+
var MATCH_SQL_MIGRATIONS = [
|
|
952
|
+
`CREATE TABLE IF NOT EXISTS match_aggregates (
|
|
953
|
+
match_id TEXT PRIMARY KEY NOT NULL,
|
|
954
|
+
schema_version INTEGER NOT NULL CHECK (schema_version = 1),
|
|
955
|
+
snapshot_json TEXT NOT NULL,
|
|
956
|
+
snapshot_sequence INTEGER NOT NULL CHECK (snapshot_sequence >= 0),
|
|
957
|
+
status TEXT NOT NULL CHECK (status IN ('inProgress', 'completed', 'ended')),
|
|
958
|
+
updated_at INTEGER NOT NULL
|
|
959
|
+
)`,
|
|
960
|
+
`CREATE TABLE IF NOT EXISTS match_events (
|
|
961
|
+
match_id TEXT NOT NULL,
|
|
962
|
+
sequence INTEGER NOT NULL CHECK (sequence >= 0),
|
|
963
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
964
|
+
schema_version INTEGER NOT NULL CHECK (schema_version = 1),
|
|
965
|
+
event_json TEXT NOT NULL,
|
|
966
|
+
created_at INTEGER NOT NULL,
|
|
967
|
+
PRIMARY KEY (match_id, sequence),
|
|
968
|
+
FOREIGN KEY (match_id) REFERENCES match_aggregates(match_id) ON DELETE RESTRICT
|
|
969
|
+
)`,
|
|
970
|
+
`CREATE TABLE IF NOT EXISTS match_active_pointer (
|
|
971
|
+
slot INTEGER PRIMARY KEY NOT NULL CHECK (slot = 1),
|
|
972
|
+
match_id TEXT NOT NULL UNIQUE,
|
|
973
|
+
FOREIGN KEY (match_id) REFERENCES match_aggregates(match_id) ON DELETE RESTRICT
|
|
974
|
+
)`,
|
|
975
|
+
`CREATE INDEX IF NOT EXISTS match_aggregates_recent
|
|
976
|
+
ON match_aggregates(updated_at DESC)`,
|
|
977
|
+
`CREATE TRIGGER IF NOT EXISTS match_events_no_update
|
|
978
|
+
BEFORE UPDATE ON match_events
|
|
979
|
+
BEGIN SELECT RAISE(ABORT, 'match events are immutable'); END`,
|
|
980
|
+
`CREATE TRIGGER IF NOT EXISTS match_events_no_delete
|
|
981
|
+
BEFORE DELETE ON match_events
|
|
982
|
+
BEGIN SELECT RAISE(ABORT, 'match events are immutable'); END`,
|
|
983
|
+
`CREATE TABLE IF NOT EXISTS remote_binding (
|
|
984
|
+
slot INTEGER PRIMARY KEY NOT NULL CHECK (slot = 1),
|
|
985
|
+
device_id TEXT NOT NULL CHECK (length(trim(device_id)) > 0),
|
|
986
|
+
label TEXT
|
|
987
|
+
)`
|
|
988
|
+
];
|
|
989
|
+
|
|
990
|
+
// src/match/persistence/memory-driver.ts
|
|
991
|
+
function cloneState(state) {
|
|
992
|
+
return {
|
|
993
|
+
migrationVersion: state.migrationVersion,
|
|
994
|
+
remoteBinding: state.remoteBinding ? { ...state.remoteBinding } : null,
|
|
995
|
+
activeMatchId: state.activeMatchId,
|
|
996
|
+
matches: state.matches.map((row) => ({ ...row })),
|
|
997
|
+
events: state.events.map((row) => ({ ...row })),
|
|
998
|
+
nextOrdinal: state.nextOrdinal,
|
|
999
|
+
ordinals: { ...state.ordinals }
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
var MemoryMatchPersistenceDriver = class {
|
|
1003
|
+
state = {
|
|
1004
|
+
migrationVersion: 0,
|
|
1005
|
+
remoteBinding: null,
|
|
1006
|
+
activeMatchId: null,
|
|
1007
|
+
matches: [],
|
|
1008
|
+
events: [],
|
|
1009
|
+
nextOrdinal: 0,
|
|
1010
|
+
ordinals: {}
|
|
1011
|
+
};
|
|
1012
|
+
eventInsertFailure = null;
|
|
1013
|
+
snapshotUpdateFailure = null;
|
|
1014
|
+
transactionTail = Promise.resolve();
|
|
1015
|
+
async migrate() {
|
|
1016
|
+
this.state.migrationVersion = 2;
|
|
1017
|
+
}
|
|
1018
|
+
async getRemoteBinding() {
|
|
1019
|
+
return this.state.remoteBinding ? { ...this.state.remoteBinding } : null;
|
|
1020
|
+
}
|
|
1021
|
+
async setRemoteBinding(binding) {
|
|
1022
|
+
this.state.remoteBinding = binding ? { ...binding } : null;
|
|
1023
|
+
}
|
|
1024
|
+
getMigrationVersion() {
|
|
1025
|
+
return this.state.migrationVersion;
|
|
1026
|
+
}
|
|
1027
|
+
async transaction(work) {
|
|
1028
|
+
const previous = this.transactionTail;
|
|
1029
|
+
let release;
|
|
1030
|
+
this.transactionTail = new Promise((resolve) => {
|
|
1031
|
+
release = resolve;
|
|
1032
|
+
});
|
|
1033
|
+
await previous;
|
|
1034
|
+
try {
|
|
1035
|
+
const draft = cloneState(this.state);
|
|
1036
|
+
const transaction = {
|
|
1037
|
+
getMatch: async (matchId) => {
|
|
1038
|
+
const row = draft.matches.find(
|
|
1039
|
+
(candidate) => candidate.matchId === matchId
|
|
1040
|
+
);
|
|
1041
|
+
return row ? { ...row } : null;
|
|
1042
|
+
},
|
|
1043
|
+
getEvents: async (matchId) => draft.events.filter((row) => row.matchId === matchId).sort((left, right) => left.sequence - right.sequence).map((row) => ({ ...row })),
|
|
1044
|
+
insertMatch: async (row) => {
|
|
1045
|
+
if (draft.matches.some((candidate) => candidate.matchId === row.matchId)) {
|
|
1046
|
+
throw new Error("match id already exists");
|
|
1047
|
+
}
|
|
1048
|
+
draft.matches.push({ ...row });
|
|
1049
|
+
draft.ordinals[row.matchId] = draft.nextOrdinal;
|
|
1050
|
+
draft.nextOrdinal += 1;
|
|
1051
|
+
},
|
|
1052
|
+
insertEvent: async (row) => {
|
|
1053
|
+
if (this.eventInsertFailure) {
|
|
1054
|
+
const failure = this.eventInsertFailure;
|
|
1055
|
+
this.eventInsertFailure = null;
|
|
1056
|
+
throw failure;
|
|
1057
|
+
}
|
|
1058
|
+
if (draft.events.some(
|
|
1059
|
+
(candidate) => candidate.eventId === row.eventId || candidate.matchId === row.matchId && candidate.sequence === row.sequence
|
|
1060
|
+
)) {
|
|
1061
|
+
throw new Error("event ordering constraint failed");
|
|
1062
|
+
}
|
|
1063
|
+
if (!draft.matches.some((candidate) => candidate.matchId === row.matchId)) {
|
|
1064
|
+
throw new Error("event foreign key constraint failed");
|
|
1065
|
+
}
|
|
1066
|
+
draft.events.push({ ...row });
|
|
1067
|
+
},
|
|
1068
|
+
updateSnapshot: async (matchId, expectedSequence, row) => {
|
|
1069
|
+
if (this.snapshotUpdateFailure) {
|
|
1070
|
+
const failure = this.snapshotUpdateFailure;
|
|
1071
|
+
this.snapshotUpdateFailure = null;
|
|
1072
|
+
throw failure;
|
|
1073
|
+
}
|
|
1074
|
+
const index = draft.matches.findIndex(
|
|
1075
|
+
(candidate) => candidate.matchId === matchId && candidate.snapshotSequence === expectedSequence
|
|
1076
|
+
);
|
|
1077
|
+
if (index < 0) return false;
|
|
1078
|
+
draft.matches[index] = { matchId, ...row };
|
|
1079
|
+
return true;
|
|
1080
|
+
},
|
|
1081
|
+
setActiveMatch: async (matchId) => {
|
|
1082
|
+
if (matchId !== null && !draft.matches.some((candidate) => candidate.matchId === matchId)) {
|
|
1083
|
+
throw new Error("active match foreign key constraint failed");
|
|
1084
|
+
}
|
|
1085
|
+
draft.activeMatchId = matchId;
|
|
1086
|
+
},
|
|
1087
|
+
clearActiveMatch: async (matchId) => {
|
|
1088
|
+
if (draft.activeMatchId === matchId) draft.activeMatchId = null;
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
const result = await work(transaction);
|
|
1092
|
+
this.state = draft;
|
|
1093
|
+
return result;
|
|
1094
|
+
} finally {
|
|
1095
|
+
release();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async readMatch(matchId) {
|
|
1099
|
+
const snapshot = cloneState(this.state);
|
|
1100
|
+
return {
|
|
1101
|
+
match: snapshot.matches.find((candidate) => candidate.matchId === matchId) ?? null,
|
|
1102
|
+
events: snapshot.events.filter((row) => row.matchId === matchId).sort((left, right) => left.sequence - right.sequence)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
async getActiveMatchId() {
|
|
1106
|
+
return this.state.activeMatchId;
|
|
1107
|
+
}
|
|
1108
|
+
async readActiveMatch() {
|
|
1109
|
+
const snapshot = cloneState(this.state);
|
|
1110
|
+
const activeMatchId = snapshot.activeMatchId;
|
|
1111
|
+
if (!activeMatchId) {
|
|
1112
|
+
return { activeMatchId: null, match: null, events: [] };
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
activeMatchId,
|
|
1116
|
+
match: snapshot.matches.find((candidate) => candidate.matchId === activeMatchId) ?? null,
|
|
1117
|
+
events: snapshot.events.filter((row) => row.matchId === activeMatchId).sort((left, right) => left.sequence - right.sequence)
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
async readMatchList() {
|
|
1121
|
+
const snapshot = cloneState(this.state);
|
|
1122
|
+
return {
|
|
1123
|
+
matches: snapshot.matches.slice().sort(
|
|
1124
|
+
(left, right) => right.updatedAt - left.updatedAt || snapshot.ordinals[right.matchId] - snapshot.ordinals[left.matchId]
|
|
1125
|
+
),
|
|
1126
|
+
activeMatchId: snapshot.activeMatchId
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
inspect() {
|
|
1130
|
+
return cloneState(this.state);
|
|
1131
|
+
}
|
|
1132
|
+
failNextSnapshotUpdate(error) {
|
|
1133
|
+
this.snapshotUpdateFailure = error;
|
|
1134
|
+
}
|
|
1135
|
+
failNextEventInsert(error) {
|
|
1136
|
+
this.eventInsertFailure = error;
|
|
1137
|
+
}
|
|
1138
|
+
corruptSnapshotJson(matchId, snapshotJson) {
|
|
1139
|
+
this.mutateMatch(matchId, (row) => ({ ...row, snapshotJson }));
|
|
1140
|
+
}
|
|
1141
|
+
corruptEventJson(matchId, sequence, eventJson) {
|
|
1142
|
+
this.mutateEvent(matchId, sequence, (row) => ({ ...row, eventJson }));
|
|
1143
|
+
}
|
|
1144
|
+
corruptEventSchema(matchId, sequence, schemaVersion) {
|
|
1145
|
+
this.mutateEvent(matchId, sequence, (row) => ({ ...row, schemaVersion }));
|
|
1146
|
+
}
|
|
1147
|
+
corruptEventSequence(matchId, sequence) {
|
|
1148
|
+
this.mutateEvent(matchId, 0, (row) => ({ ...row, sequence }));
|
|
1149
|
+
}
|
|
1150
|
+
duplicateEventRow(matchId, sequence) {
|
|
1151
|
+
const row = this.state.events.find(
|
|
1152
|
+
(candidate) => candidate.matchId === matchId && candidate.sequence === sequence
|
|
1153
|
+
);
|
|
1154
|
+
if (!row) throw new Error("event row not found");
|
|
1155
|
+
this.state.events.push({ ...row });
|
|
1156
|
+
}
|
|
1157
|
+
removeEventRow(matchId, sequence) {
|
|
1158
|
+
this.state.events = this.state.events.filter(
|
|
1159
|
+
(row) => row.matchId !== matchId || row.sequence !== sequence
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
mutateMatch(matchId, mutation) {
|
|
1163
|
+
const index = this.state.matches.findIndex((row) => row.matchId === matchId);
|
|
1164
|
+
if (index < 0) throw new Error("match row not found");
|
|
1165
|
+
this.state.matches[index] = mutation(this.state.matches[index]);
|
|
1166
|
+
}
|
|
1167
|
+
mutateEvent(matchId, sequence, mutation) {
|
|
1168
|
+
const index = this.state.events.findIndex(
|
|
1169
|
+
(row) => row.matchId === matchId && row.sequence === sequence
|
|
1170
|
+
);
|
|
1171
|
+
if (index < 0) throw new Error("event row not found");
|
|
1172
|
+
this.state.events[index] = mutation(this.state.events[index]);
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
// src/match/persistence/sqlite-driver.ts
|
|
1177
|
+
var connectionCoordinators = /* @__PURE__ */ new WeakMap();
|
|
1178
|
+
function coordinationFor(database) {
|
|
1179
|
+
const existing = connectionCoordinators.get(database);
|
|
1180
|
+
if (existing) return existing;
|
|
1181
|
+
const coordination = {
|
|
1182
|
+
tail: Promise.resolve(),
|
|
1183
|
+
poisoned: null
|
|
1184
|
+
};
|
|
1185
|
+
connectionCoordinators.set(database, coordination);
|
|
1186
|
+
return coordination;
|
|
1187
|
+
}
|
|
1188
|
+
var mapMatchRow = (row) => ({
|
|
1189
|
+
matchId: row.match_id,
|
|
1190
|
+
schemaVersion: row.schema_version,
|
|
1191
|
+
snapshotJson: row.snapshot_json,
|
|
1192
|
+
snapshotSequence: row.snapshot_sequence,
|
|
1193
|
+
status: row.status,
|
|
1194
|
+
updatedAt: row.updated_at
|
|
1195
|
+
});
|
|
1196
|
+
var mapEventRow = (row) => ({
|
|
1197
|
+
matchId: row.match_id,
|
|
1198
|
+
sequence: row.sequence,
|
|
1199
|
+
eventId: row.event_id,
|
|
1200
|
+
schemaVersion: row.schema_version,
|
|
1201
|
+
eventJson: row.event_json,
|
|
1202
|
+
createdAt: row.created_at
|
|
1203
|
+
});
|
|
1204
|
+
function transactionFor(executor) {
|
|
1205
|
+
return {
|
|
1206
|
+
async getMatch(matchId) {
|
|
1207
|
+
const row = await executor.getFirstAsync(
|
|
1208
|
+
"SELECT * FROM match_aggregates WHERE match_id = ?",
|
|
1209
|
+
matchId
|
|
1210
|
+
);
|
|
1211
|
+
return row ? mapMatchRow(row) : null;
|
|
1212
|
+
},
|
|
1213
|
+
async getEvents(matchId) {
|
|
1214
|
+
const rows = await executor.getAllAsync(
|
|
1215
|
+
"SELECT * FROM match_events WHERE match_id = ? ORDER BY sequence ASC",
|
|
1216
|
+
matchId
|
|
1217
|
+
);
|
|
1218
|
+
return rows.map(mapEventRow);
|
|
1219
|
+
},
|
|
1220
|
+
async insertMatch(row) {
|
|
1221
|
+
await executor.runAsync(
|
|
1222
|
+
`INSERT INTO match_aggregates
|
|
1223
|
+
(match_id, schema_version, snapshot_json, snapshot_sequence, status, updated_at)
|
|
1224
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1225
|
+
row.matchId,
|
|
1226
|
+
row.schemaVersion,
|
|
1227
|
+
row.snapshotJson,
|
|
1228
|
+
row.snapshotSequence,
|
|
1229
|
+
row.status,
|
|
1230
|
+
row.updatedAt
|
|
1231
|
+
);
|
|
1232
|
+
},
|
|
1233
|
+
async insertEvent(row) {
|
|
1234
|
+
await executor.runAsync(
|
|
1235
|
+
`INSERT INTO match_events
|
|
1236
|
+
(match_id, sequence, event_id, schema_version, event_json, created_at)
|
|
1237
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1238
|
+
row.matchId,
|
|
1239
|
+
row.sequence,
|
|
1240
|
+
row.eventId,
|
|
1241
|
+
row.schemaVersion,
|
|
1242
|
+
row.eventJson,
|
|
1243
|
+
row.createdAt
|
|
1244
|
+
);
|
|
1245
|
+
},
|
|
1246
|
+
async updateSnapshot(matchId, expectedSequence, row) {
|
|
1247
|
+
const result = await executor.runAsync(
|
|
1248
|
+
`UPDATE match_aggregates
|
|
1249
|
+
SET schema_version = ?, snapshot_json = ?, snapshot_sequence = ?, status = ?, updated_at = ?
|
|
1250
|
+
WHERE match_id = ? AND snapshot_sequence = ?`,
|
|
1251
|
+
row.schemaVersion,
|
|
1252
|
+
row.snapshotJson,
|
|
1253
|
+
row.snapshotSequence,
|
|
1254
|
+
row.status,
|
|
1255
|
+
row.updatedAt,
|
|
1256
|
+
matchId,
|
|
1257
|
+
expectedSequence
|
|
1258
|
+
);
|
|
1259
|
+
return result.changes === 1;
|
|
1260
|
+
},
|
|
1261
|
+
async setActiveMatch(matchId) {
|
|
1262
|
+
if (matchId === null) {
|
|
1263
|
+
await executor.runAsync("DELETE FROM match_active_pointer WHERE slot = 1");
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
await executor.runAsync(
|
|
1267
|
+
`INSERT INTO match_active_pointer (slot, match_id) VALUES (1, ?)
|
|
1268
|
+
ON CONFLICT(slot) DO UPDATE SET match_id = excluded.match_id`,
|
|
1269
|
+
matchId
|
|
1270
|
+
);
|
|
1271
|
+
},
|
|
1272
|
+
async clearActiveMatch(matchId) {
|
|
1273
|
+
await executor.runAsync(
|
|
1274
|
+
"DELETE FROM match_active_pointer WHERE slot = 1 AND match_id = ?",
|
|
1275
|
+
matchId
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
function createExpoSqliteMatchDriver(database) {
|
|
1281
|
+
const coordination = coordinationFor(database);
|
|
1282
|
+
function poisonAfterRollbackFailure(error) {
|
|
1283
|
+
if (!coordination.poisoned) {
|
|
1284
|
+
coordination.poisoned = new MatchPersistenceError(
|
|
1285
|
+
"storage_failure",
|
|
1286
|
+
"The SQLite connection is unusable after rollback failed",
|
|
1287
|
+
{ cause: error }
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
async function runSerialized(work) {
|
|
1292
|
+
const previous = coordination.tail;
|
|
1293
|
+
let release;
|
|
1294
|
+
coordination.tail = new Promise((resolve) => {
|
|
1295
|
+
release = resolve;
|
|
1296
|
+
});
|
|
1297
|
+
await previous;
|
|
1298
|
+
try {
|
|
1299
|
+
if (coordination.poisoned) throw coordination.poisoned;
|
|
1300
|
+
return await work();
|
|
1301
|
+
} finally {
|
|
1302
|
+
release();
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
function runAtomic(begin, work) {
|
|
1306
|
+
return runSerialized(async () => {
|
|
1307
|
+
let began = false;
|
|
1308
|
+
try {
|
|
1309
|
+
await database.execAsync(begin);
|
|
1310
|
+
began = true;
|
|
1311
|
+
const result = await work(transactionFor(database));
|
|
1312
|
+
await database.execAsync("COMMIT");
|
|
1313
|
+
return result;
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (!began) throw error;
|
|
1316
|
+
try {
|
|
1317
|
+
await database.execAsync("ROLLBACK");
|
|
1318
|
+
} catch (rollbackError) {
|
|
1319
|
+
poisonAfterRollbackFailure(rollbackError);
|
|
1320
|
+
}
|
|
1321
|
+
throw error;
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
return {
|
|
1326
|
+
async migrate() {
|
|
1327
|
+
await runSerialized(async () => {
|
|
1328
|
+
await database.execAsync("PRAGMA busy_timeout = 5000");
|
|
1329
|
+
await database.execAsync("PRAGMA foreign_keys = ON");
|
|
1330
|
+
const foreignKeys = await database.getFirstAsync(
|
|
1331
|
+
"PRAGMA foreign_keys"
|
|
1332
|
+
);
|
|
1333
|
+
if (foreignKeys?.foreign_keys !== 1) {
|
|
1334
|
+
throw new MatchPersistenceError(
|
|
1335
|
+
"storage_failure",
|
|
1336
|
+
"SQLite foreign-key enforcement could not be enabled"
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const version = await database.getFirstAsync(
|
|
1340
|
+
"PRAGMA user_version"
|
|
1341
|
+
);
|
|
1342
|
+
if (!version || !Number.isInteger(version.user_version)) {
|
|
1343
|
+
throw new MatchPersistenceError(
|
|
1344
|
+
"storage_failure",
|
|
1345
|
+
"SQLite did not return a valid schema version"
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
if (version.user_version > MATCH_SQL_SCHEMA_VERSION) {
|
|
1349
|
+
throw new MatchPersistenceError(
|
|
1350
|
+
"unsupported_schema",
|
|
1351
|
+
"The local match database uses a newer schema version"
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (version.user_version === MATCH_SQL_SCHEMA_VERSION) return;
|
|
1355
|
+
let began = false;
|
|
1356
|
+
try {
|
|
1357
|
+
await database.execAsync("BEGIN IMMEDIATE");
|
|
1358
|
+
began = true;
|
|
1359
|
+
for (const migration of MATCH_SQL_MIGRATIONS) {
|
|
1360
|
+
await database.execAsync(migration);
|
|
1361
|
+
}
|
|
1362
|
+
await database.execAsync(`PRAGMA user_version = ${MATCH_SQL_SCHEMA_VERSION}`);
|
|
1363
|
+
await database.execAsync("COMMIT");
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
if (began) {
|
|
1366
|
+
try {
|
|
1367
|
+
await database.execAsync("ROLLBACK");
|
|
1368
|
+
} catch (rollbackError) {
|
|
1369
|
+
poisonAfterRollbackFailure(rollbackError);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
},
|
|
1376
|
+
async transaction(work) {
|
|
1377
|
+
return runAtomic("BEGIN IMMEDIATE", work);
|
|
1378
|
+
},
|
|
1379
|
+
async readMatch(matchId) {
|
|
1380
|
+
return runAtomic("BEGIN", async (reader) => {
|
|
1381
|
+
const [match, events] = await Promise.all([
|
|
1382
|
+
reader.getMatch(matchId),
|
|
1383
|
+
reader.getEvents(matchId)
|
|
1384
|
+
]);
|
|
1385
|
+
return { match, events };
|
|
1386
|
+
});
|
|
1387
|
+
},
|
|
1388
|
+
async getActiveMatchId() {
|
|
1389
|
+
return runSerialized(async () => {
|
|
1390
|
+
const row = await database.getFirstAsync(
|
|
1391
|
+
"SELECT match_id FROM match_active_pointer WHERE slot = 1"
|
|
1392
|
+
);
|
|
1393
|
+
return row?.match_id ?? null;
|
|
1394
|
+
});
|
|
1395
|
+
},
|
|
1396
|
+
async readActiveMatch() {
|
|
1397
|
+
return runAtomic("BEGIN", async (reader) => {
|
|
1398
|
+
const active = await database.getFirstAsync(
|
|
1399
|
+
"SELECT match_id FROM match_active_pointer WHERE slot = 1"
|
|
1400
|
+
);
|
|
1401
|
+
if (!active) {
|
|
1402
|
+
return { activeMatchId: null, match: null, events: [] };
|
|
1403
|
+
}
|
|
1404
|
+
const [match, events] = await Promise.all([
|
|
1405
|
+
reader.getMatch(active.match_id),
|
|
1406
|
+
reader.getEvents(active.match_id)
|
|
1407
|
+
]);
|
|
1408
|
+
return { activeMatchId: active.match_id, match, events };
|
|
1409
|
+
});
|
|
1410
|
+
},
|
|
1411
|
+
async readMatchList() {
|
|
1412
|
+
return runAtomic("BEGIN", async () => {
|
|
1413
|
+
const [matches, activeMatch] = await Promise.all([
|
|
1414
|
+
database.getAllAsync(
|
|
1415
|
+
"SELECT * FROM match_aggregates ORDER BY updated_at DESC, rowid DESC"
|
|
1416
|
+
),
|
|
1417
|
+
database.getFirstAsync(
|
|
1418
|
+
"SELECT match_id FROM match_active_pointer WHERE slot = 1"
|
|
1419
|
+
)
|
|
1420
|
+
]);
|
|
1421
|
+
return {
|
|
1422
|
+
matches: matches.map(mapMatchRow),
|
|
1423
|
+
activeMatchId: activeMatch?.match_id ?? null
|
|
1424
|
+
};
|
|
1425
|
+
});
|
|
1426
|
+
},
|
|
1427
|
+
async getRemoteBinding() {
|
|
1428
|
+
return runSerialized(async () => {
|
|
1429
|
+
const row = await database.getFirstAsync(
|
|
1430
|
+
"SELECT device_id, label FROM remote_binding WHERE slot = 1"
|
|
1431
|
+
);
|
|
1432
|
+
return row ? { deviceId: row.device_id, ...row.label === null ? {} : { label: row.label } } : null;
|
|
1433
|
+
});
|
|
1434
|
+
},
|
|
1435
|
+
async setRemoteBinding(binding) {
|
|
1436
|
+
return runSerialized(async () => {
|
|
1437
|
+
if (!binding) {
|
|
1438
|
+
await database.runAsync("DELETE FROM remote_binding WHERE slot = 1");
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
await database.runAsync(
|
|
1442
|
+
`INSERT INTO remote_binding (slot, device_id, label) VALUES (1, ?, ?)
|
|
1443
|
+
ON CONFLICT(slot) DO UPDATE SET device_id = excluded.device_id, label = excluded.label`,
|
|
1444
|
+
binding.deviceId,
|
|
1445
|
+
binding.label ?? null
|
|
1446
|
+
);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// src/match/persistence/store.ts
|
|
1453
|
+
function asPersistenceError(error) {
|
|
1454
|
+
if (error instanceof MatchPersistenceError) return error;
|
|
1455
|
+
return new MatchPersistenceError(
|
|
1456
|
+
"storage_failure",
|
|
1457
|
+
"The local match database operation failed",
|
|
1458
|
+
{ cause: error }
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
function parseJson(json, label) {
|
|
1462
|
+
try {
|
|
1463
|
+
return JSON.parse(json);
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
throw new MatchPersistenceError("corrupt_data", `${label} contains malformed JSON`, {
|
|
1466
|
+
cause: error
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function snapshotRow(snapshot, updatedAt) {
|
|
1471
|
+
return {
|
|
1472
|
+
matchId: snapshot.id,
|
|
1473
|
+
schemaVersion: MATCH_PERSISTENCE_SCHEMA_VERSION,
|
|
1474
|
+
snapshotJson: JSON.stringify(snapshot),
|
|
1475
|
+
snapshotSequence: snapshot.sequence,
|
|
1476
|
+
status: snapshot.status,
|
|
1477
|
+
updatedAt
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function eventRow(matchId, event, createdAt) {
|
|
1481
|
+
return {
|
|
1482
|
+
matchId,
|
|
1483
|
+
sequence: event.sequence,
|
|
1484
|
+
eventId: event.id,
|
|
1485
|
+
schemaVersion: event.schemaVersion,
|
|
1486
|
+
eventJson: JSON.stringify(event),
|
|
1487
|
+
createdAt
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
function decodePersistedMatch(matchId, row, eventRows) {
|
|
1491
|
+
if (row.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION || eventRows.some((event) => event.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION)) {
|
|
1492
|
+
throw new MatchPersistenceError(
|
|
1493
|
+
"unsupported_schema",
|
|
1494
|
+
"The stored match uses an unsupported schema version"
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
if (eventRows.length === 0) {
|
|
1498
|
+
throw new MatchPersistenceError("corrupt_data", "The match creation event is missing");
|
|
1499
|
+
}
|
|
1500
|
+
eventRows.forEach((storedEvent, index) => {
|
|
1501
|
+
if (storedEvent.matchId !== matchId || storedEvent.sequence !== index || storedEvent.eventId !== `${matchId}:${index}`) {
|
|
1502
|
+
throw new MatchPersistenceError(
|
|
1503
|
+
"sequence_mismatch",
|
|
1504
|
+
"The stored match event sequence is not contiguous"
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
const events = eventRows.map((storedEvent) => {
|
|
1509
|
+
const value = parseJson(storedEvent.eventJson, "Stored match event");
|
|
1510
|
+
try {
|
|
1511
|
+
assertRuntimeMatchEvent(value);
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
throw new MatchPersistenceError("corrupt_data", "A stored match event is invalid", {
|
|
1514
|
+
cause: error
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
if (value.id !== storedEvent.eventId || value.sequence !== storedEvent.sequence || value.schemaVersion !== storedEvent.schemaVersion) {
|
|
1518
|
+
throw new MatchPersistenceError(
|
|
1519
|
+
"sequence_mismatch",
|
|
1520
|
+
"A stored event does not match its database ordering metadata"
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
return value;
|
|
1524
|
+
});
|
|
1525
|
+
let replayed;
|
|
1526
|
+
try {
|
|
1527
|
+
replayed = replayMatchEvents(events);
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
throw new MatchPersistenceError("corrupt_data", "The stored event log cannot be replayed", {
|
|
1530
|
+
cause: error
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
const cachedValue = parseJson(row.snapshotJson, "Stored match snapshot");
|
|
1534
|
+
try {
|
|
1535
|
+
assertRuntimeMatchSnapshot(cachedValue);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
throw new MatchPersistenceError("corrupt_data", "The stored match snapshot is invalid", {
|
|
1538
|
+
cause: error
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
if (row.matchId !== replayed.id || row.snapshotSequence !== replayed.sequence || row.status !== replayed.status || !valuesEqual(cachedValue, replayed)) {
|
|
1542
|
+
throw new MatchPersistenceError(
|
|
1543
|
+
"snapshot_mismatch",
|
|
1544
|
+
"The snapshot cache does not match the immutable event log"
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
return { snapshot: replayed, events };
|
|
1548
|
+
}
|
|
1549
|
+
async function readPersistedMatch(driver, matchId) {
|
|
1550
|
+
const { match, events } = await driver.readMatch(matchId);
|
|
1551
|
+
return match ? decodePersistedMatch(matchId, match, events) : null;
|
|
1552
|
+
}
|
|
1553
|
+
function resolveCreateRetry(existing, requested) {
|
|
1554
|
+
if (valuesEqual(existing.events[0], requested.events[0])) {
|
|
1555
|
+
return existing;
|
|
1556
|
+
}
|
|
1557
|
+
throw new MatchPersistenceError(
|
|
1558
|
+
"already_exists",
|
|
1559
|
+
"A different match already uses this match id"
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
function resolveAppendRetry(persisted, currentSnapshot, event) {
|
|
1563
|
+
if (event.sequence !== currentSnapshot.sequence + 1 || event.id !== `${currentSnapshot.id}:${event.sequence}`) {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
const existingEvent = persisted.events[event.sequence];
|
|
1567
|
+
if (!existingEvent) return null;
|
|
1568
|
+
let snapshotBeforeEvent;
|
|
1569
|
+
try {
|
|
1570
|
+
snapshotBeforeEvent = replayMatchEvents(persisted.events.slice(0, event.sequence));
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
throw new MatchPersistenceError(
|
|
1573
|
+
"corrupt_data",
|
|
1574
|
+
"The persisted event prefix cannot be replayed",
|
|
1575
|
+
{ cause: error }
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
if (!valuesEqual(snapshotBeforeEvent, currentSnapshot)) {
|
|
1579
|
+
throw new MatchPersistenceError(
|
|
1580
|
+
"snapshot_mismatch",
|
|
1581
|
+
"The caller snapshot does not match the event's persisted prefix"
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
if (!valuesEqual(existingEvent, event)) {
|
|
1585
|
+
throw new MatchPersistenceError(
|
|
1586
|
+
"conflict",
|
|
1587
|
+
"A different event already exists at this match sequence"
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
return persisted;
|
|
1591
|
+
}
|
|
1592
|
+
function assertMatchListMetadata(row) {
|
|
1593
|
+
if (row.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION) {
|
|
1594
|
+
throw new MatchPersistenceError(
|
|
1595
|
+
"unsupported_schema",
|
|
1596
|
+
"A stored match summary uses an unsupported schema version"
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
if (typeof row.matchId !== "string" || row.matchId.trim().length === 0 || typeof row.snapshotJson !== "string" || !Number.isSafeInteger(row.snapshotSequence) || row.snapshotSequence < 0 || row.status !== "inProgress" && row.status !== "completed" && row.status !== "ended" || !Number.isSafeInteger(row.updatedAt) || row.updatedAt < 0) {
|
|
1600
|
+
throw new MatchPersistenceError(
|
|
1601
|
+
"corrupt_data",
|
|
1602
|
+
"A stored match summary contains invalid metadata"
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
var MatchEventStore = class {
|
|
1607
|
+
constructor(driver, options = {}) {
|
|
1608
|
+
this.driver = driver;
|
|
1609
|
+
this.now = options.now ?? Date.now;
|
|
1610
|
+
}
|
|
1611
|
+
initialized = null;
|
|
1612
|
+
now;
|
|
1613
|
+
initialize() {
|
|
1614
|
+
if (!this.initialized) {
|
|
1615
|
+
this.initialized = this.driver.migrate().catch((error) => {
|
|
1616
|
+
this.initialized = null;
|
|
1617
|
+
throw asPersistenceError(error);
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
return this.initialized;
|
|
1621
|
+
}
|
|
1622
|
+
async getRemoteBinding() {
|
|
1623
|
+
await this.initialize();
|
|
1624
|
+
if (!this.driver.getRemoteBinding) return null;
|
|
1625
|
+
try {
|
|
1626
|
+
return await this.driver.getRemoteBinding();
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
throw asPersistenceError(error);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
async setRemoteBinding(binding) {
|
|
1632
|
+
if (binding && binding.deviceId.trim().length === 0) {
|
|
1633
|
+
throw new MatchPersistenceError("corrupt_data", "Remote binding identifier cannot be empty");
|
|
1634
|
+
}
|
|
1635
|
+
await this.initialize();
|
|
1636
|
+
if (!this.driver.setRemoteBinding) {
|
|
1637
|
+
throw new MatchPersistenceError("storage_failure", "Remote binding storage is unavailable");
|
|
1638
|
+
}
|
|
1639
|
+
try {
|
|
1640
|
+
await this.driver.setRemoteBinding(binding);
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
throw asPersistenceError(error);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
async createMatch(input, source = "system") {
|
|
1646
|
+
await this.initialize();
|
|
1647
|
+
const created = createMatch(input, source);
|
|
1648
|
+
const timestamp = this.now();
|
|
1649
|
+
try {
|
|
1650
|
+
return await this.driver.transaction(async (transaction) => {
|
|
1651
|
+
const [existingRow, existingEvents] = await Promise.all([
|
|
1652
|
+
transaction.getMatch(created.snapshot.id),
|
|
1653
|
+
transaction.getEvents(created.snapshot.id)
|
|
1654
|
+
]);
|
|
1655
|
+
if (existingRow) {
|
|
1656
|
+
return resolveCreateRetry(
|
|
1657
|
+
decodePersistedMatch(created.snapshot.id, existingRow, existingEvents),
|
|
1658
|
+
created
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
if (existingEvents.length > 0) {
|
|
1662
|
+
throw new MatchPersistenceError(
|
|
1663
|
+
"corrupt_data",
|
|
1664
|
+
"Match events exist without their aggregate"
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
await transaction.insertMatch(snapshotRow(created.snapshot, timestamp));
|
|
1668
|
+
await transaction.insertEvent(eventRow(created.snapshot.id, created.events[0], timestamp));
|
|
1669
|
+
await transaction.setActiveMatch(created.snapshot.id);
|
|
1670
|
+
return created;
|
|
1671
|
+
});
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (error instanceof MatchPersistenceError) throw error;
|
|
1674
|
+
try {
|
|
1675
|
+
const existing = await readPersistedMatch(this.driver, created.snapshot.id);
|
|
1676
|
+
if (existing) return resolveCreateRetry(existing, created);
|
|
1677
|
+
} catch (reconciliationError) {
|
|
1678
|
+
if (reconciliationError instanceof MatchPersistenceError) {
|
|
1679
|
+
throw reconciliationError;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
throw asPersistenceError(error);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
async appendEvent(currentSnapshot, event) {
|
|
1686
|
+
await this.initialize();
|
|
1687
|
+
const timestamp = this.now();
|
|
1688
|
+
try {
|
|
1689
|
+
return await this.driver.transaction(async (transaction) => {
|
|
1690
|
+
const [row, eventRows] = await Promise.all([
|
|
1691
|
+
transaction.getMatch(currentSnapshot.id),
|
|
1692
|
+
transaction.getEvents(currentSnapshot.id)
|
|
1693
|
+
]);
|
|
1694
|
+
if (!row) {
|
|
1695
|
+
throw new MatchPersistenceError("not_found", "The match to update is missing");
|
|
1696
|
+
}
|
|
1697
|
+
const persisted = decodePersistedMatch(currentSnapshot.id, row, eventRows);
|
|
1698
|
+
const retried = resolveAppendRetry(persisted, currentSnapshot, event);
|
|
1699
|
+
if (retried) return retried;
|
|
1700
|
+
if (!valuesEqual(persisted.snapshot, currentSnapshot)) {
|
|
1701
|
+
throw new MatchPersistenceError(
|
|
1702
|
+
"snapshot_mismatch",
|
|
1703
|
+
"The caller snapshot does not match the immutable event log"
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
let nextSnapshot;
|
|
1707
|
+
try {
|
|
1708
|
+
nextSnapshot = applyMatchEvent(currentSnapshot, event);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
throw new MatchPersistenceError(
|
|
1711
|
+
"sequence_mismatch",
|
|
1712
|
+
"The match event cannot follow the current snapshot",
|
|
1713
|
+
{ cause: error }
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
await transaction.insertEvent(eventRow(currentSnapshot.id, event, timestamp));
|
|
1717
|
+
const updated = await transaction.updateSnapshot(
|
|
1718
|
+
currentSnapshot.id,
|
|
1719
|
+
currentSnapshot.sequence,
|
|
1720
|
+
snapshotRow(nextSnapshot, timestamp)
|
|
1721
|
+
);
|
|
1722
|
+
if (!updated) {
|
|
1723
|
+
throw new MatchPersistenceError(
|
|
1724
|
+
"sequence_mismatch",
|
|
1725
|
+
"The persisted match sequence changed before this event committed"
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
if (currentSnapshot.status !== "inProgress" && nextSnapshot.status === "inProgress") {
|
|
1729
|
+
await transaction.setActiveMatch(currentSnapshot.id);
|
|
1730
|
+
} else if (currentSnapshot.status === "inProgress" && nextSnapshot.status !== "inProgress") {
|
|
1731
|
+
await transaction.clearActiveMatch(currentSnapshot.id);
|
|
1732
|
+
}
|
|
1733
|
+
return {
|
|
1734
|
+
snapshot: nextSnapshot,
|
|
1735
|
+
events: [...persisted.events, event]
|
|
1736
|
+
};
|
|
1737
|
+
});
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
if (error instanceof MatchPersistenceError) throw error;
|
|
1740
|
+
try {
|
|
1741
|
+
const persisted = await readPersistedMatch(this.driver, currentSnapshot.id);
|
|
1742
|
+
if (persisted) {
|
|
1743
|
+
const retried = resolveAppendRetry(persisted, currentSnapshot, event);
|
|
1744
|
+
if (retried) return retried;
|
|
1745
|
+
}
|
|
1746
|
+
} catch (reconciliationError) {
|
|
1747
|
+
if (reconciliationError instanceof MatchPersistenceError) {
|
|
1748
|
+
throw reconciliationError;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
throw asPersistenceError(error);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
async resumeActiveMatch() {
|
|
1755
|
+
await this.initialize();
|
|
1756
|
+
let activeMatchId;
|
|
1757
|
+
let row;
|
|
1758
|
+
let eventRows;
|
|
1759
|
+
try {
|
|
1760
|
+
({ activeMatchId, match: row, events: eventRows } = await this.driver.readActiveMatch());
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
throw asPersistenceError(error);
|
|
1763
|
+
}
|
|
1764
|
+
if (!activeMatchId) return null;
|
|
1765
|
+
if (!row) {
|
|
1766
|
+
throw new MatchPersistenceError(
|
|
1767
|
+
"corrupt_data",
|
|
1768
|
+
"The active match pointer has no aggregate"
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
const persisted = decodePersistedMatch(activeMatchId, row, eventRows);
|
|
1772
|
+
if (persisted.snapshot.status !== "inProgress") {
|
|
1773
|
+
throw new MatchPersistenceError(
|
|
1774
|
+
"corrupt_data",
|
|
1775
|
+
"The active match pointer targets a non-active match"
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
return persisted;
|
|
1779
|
+
}
|
|
1780
|
+
async resumeMatch(matchId) {
|
|
1781
|
+
await this.initialize();
|
|
1782
|
+
let row;
|
|
1783
|
+
let eventRows;
|
|
1784
|
+
try {
|
|
1785
|
+
({ match: row, events: eventRows } = await this.driver.readMatch(matchId));
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
throw asPersistenceError(error);
|
|
1788
|
+
}
|
|
1789
|
+
if (!row) return null;
|
|
1790
|
+
return decodePersistedMatch(matchId, row, eventRows);
|
|
1791
|
+
}
|
|
1792
|
+
async listMatches() {
|
|
1793
|
+
await this.initialize();
|
|
1794
|
+
try {
|
|
1795
|
+
const { matches, activeMatchId } = await this.driver.readMatchList();
|
|
1796
|
+
matches.forEach(assertMatchListMetadata);
|
|
1797
|
+
const ids = new Set(matches.map((row) => row.matchId));
|
|
1798
|
+
if (ids.size !== matches.length) {
|
|
1799
|
+
throw new MatchPersistenceError(
|
|
1800
|
+
"corrupt_data",
|
|
1801
|
+
"The stored match list contains duplicate match ids"
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
if (activeMatchId !== null && !ids.has(activeMatchId)) {
|
|
1805
|
+
throw new MatchPersistenceError(
|
|
1806
|
+
"corrupt_data",
|
|
1807
|
+
"The active match pointer is missing from the stored match list"
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
if (activeMatchId !== null && matches.find((row) => row.matchId === activeMatchId)?.status !== "inProgress") {
|
|
1811
|
+
throw new MatchPersistenceError(
|
|
1812
|
+
"corrupt_data",
|
|
1813
|
+
"The active match pointer targets a non-active match summary"
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
return matches.map((row) => ({
|
|
1817
|
+
id: row.matchId,
|
|
1818
|
+
sequence: row.snapshotSequence,
|
|
1819
|
+
status: row.status,
|
|
1820
|
+
updatedAt: row.updatedAt,
|
|
1821
|
+
isActive: row.matchId === activeMatchId
|
|
1822
|
+
}));
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
throw asPersistenceError(error);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
async archiveMatch(matchId) {
|
|
1828
|
+
await this.initialize();
|
|
1829
|
+
try {
|
|
1830
|
+
await this.driver.transaction(async (transaction) => {
|
|
1831
|
+
await transaction.clearActiveMatch(matchId);
|
|
1832
|
+
});
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
throw asPersistenceError(error);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
// src/match/persistence/default-store.ts
|
|
1840
|
+
async function createDefaultMatchEventStore(_databaseName = "pickleball-matches.db") {
|
|
1841
|
+
throw new MatchPersistenceError(
|
|
1842
|
+
"storage_failure",
|
|
1843
|
+
"The default match store requires a native iOS or Android Expo runtime"
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// src/match/provider.tsx
|
|
1848
|
+
import React, {
|
|
1849
|
+
useCallback,
|
|
1850
|
+
useEffect,
|
|
1851
|
+
useMemo,
|
|
1852
|
+
useRef,
|
|
1853
|
+
useState
|
|
1854
|
+
} from "react";
|
|
1855
|
+
|
|
1856
|
+
// src/match/remote/native-service.ts
|
|
1857
|
+
function loadNative() {
|
|
1858
|
+
const imported = __require("@pickleball/remote-native");
|
|
1859
|
+
return imported.default ?? imported;
|
|
1860
|
+
}
|
|
1861
|
+
function createDefaultRemoteService() {
|
|
1862
|
+
let native = null;
|
|
1863
|
+
let disposed = false;
|
|
1864
|
+
const getNative = () => {
|
|
1865
|
+
if (disposed) throw new RemoteError("native_unavailable", "Remote scanner is disposed");
|
|
1866
|
+
try {
|
|
1867
|
+
native ??= loadNative();
|
|
1868
|
+
return native;
|
|
1869
|
+
} catch (cause) {
|
|
1870
|
+
throw new RemoteError(
|
|
1871
|
+
"native_unavailable",
|
|
1872
|
+
"BTHome scanning requires an iOS or Android build with @pickleball/remote-native",
|
|
1873
|
+
{ cause }
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
return {
|
|
1878
|
+
start: () => getNative().start(),
|
|
1879
|
+
async stop() {
|
|
1880
|
+
await native?.stop();
|
|
1881
|
+
},
|
|
1882
|
+
async dispose() {
|
|
1883
|
+
if (disposed) return;
|
|
1884
|
+
disposed = true;
|
|
1885
|
+
await native?.destroy();
|
|
1886
|
+
native = null;
|
|
1887
|
+
},
|
|
1888
|
+
subscribeAdvertisement(listener) {
|
|
1889
|
+
const subscription = getNative().addListener("onAdvertisement", listener);
|
|
1890
|
+
return () => subscription.remove();
|
|
1891
|
+
},
|
|
1892
|
+
subscribeState(listener) {
|
|
1893
|
+
const subscription = getNative().addListener("onStateChanged", listener);
|
|
1894
|
+
return () => subscription.remove();
|
|
1895
|
+
},
|
|
1896
|
+
startHidCapture: (options) => getNative().startHidCapture(options),
|
|
1897
|
+
async stopHidCapture() {
|
|
1898
|
+
await native?.stopHidCapture();
|
|
1899
|
+
},
|
|
1900
|
+
subscribeHidVolume(listener) {
|
|
1901
|
+
const subscription = getNative().addListener("onHidVolume", listener);
|
|
1902
|
+
return () => subscription.remove();
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/match/provider.tsx
|
|
1908
|
+
import { jsx } from "react/jsx-runtime";
|
|
1909
|
+
var NoActiveMatchError = class extends Error {
|
|
1910
|
+
name = "NoActiveMatchError";
|
|
1911
|
+
code = "NO_ACTIVE_MATCH";
|
|
1912
|
+
constructor() {
|
|
1913
|
+
super("No pickleball match is loaded");
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
var INITIAL_REMOTE_STATE = {
|
|
1917
|
+
status: "idle",
|
|
1918
|
+
permission: "unknown",
|
|
1919
|
+
binding: null,
|
|
1920
|
+
candidate: null,
|
|
1921
|
+
lastSeenAt: null,
|
|
1922
|
+
error: null,
|
|
1923
|
+
hidCaptureArmed: false,
|
|
1924
|
+
lastInputSource: null,
|
|
1925
|
+
lastHidEvent: null
|
|
1926
|
+
};
|
|
1927
|
+
var MatchContext = React.createContext(null);
|
|
1928
|
+
function PickleballMatchProvider({
|
|
1929
|
+
children,
|
|
1930
|
+
store,
|
|
1931
|
+
storeFactory,
|
|
1932
|
+
autoResume = true,
|
|
1933
|
+
onError,
|
|
1934
|
+
voiceService,
|
|
1935
|
+
voiceAudioMode = "live",
|
|
1936
|
+
voiceTts,
|
|
1937
|
+
remoteService,
|
|
1938
|
+
remoteOutOfRangeTimeoutMs = 5e3,
|
|
1939
|
+
remoteInputMode = "bthome",
|
|
1940
|
+
remoteActionMapping,
|
|
1941
|
+
hidGestureMapping,
|
|
1942
|
+
consumeHidVolumeKeys = true
|
|
1943
|
+
}) {
|
|
1944
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
1945
|
+
const [events, setEvents] = useState([]);
|
|
1946
|
+
const [loading, setLoading] = useState(autoResume);
|
|
1947
|
+
const [error, setError] = useState(null);
|
|
1948
|
+
const [voiceError, setVoiceError] = useState(null);
|
|
1949
|
+
const [audioState, setAudioState] = useState(
|
|
1950
|
+
voiceService?.audioState ?? {
|
|
1951
|
+
route: "system",
|
|
1952
|
+
interrupted: false,
|
|
1953
|
+
disconnectedReason: null
|
|
1954
|
+
}
|
|
1955
|
+
);
|
|
1956
|
+
const [remoteState, setRemoteState] = useState(INITIAL_REMOTE_STATE);
|
|
1957
|
+
const snapshotRef = useRef(null);
|
|
1958
|
+
const eventsRef = useRef([]);
|
|
1959
|
+
const queueRef = useRef(Promise.resolve());
|
|
1960
|
+
const mountedRef = useRef(true);
|
|
1961
|
+
const didAutoResumeRef = useRef(false);
|
|
1962
|
+
const autoResumeOnMountRef = useRef(autoResume);
|
|
1963
|
+
const onErrorRef = useRef(onError);
|
|
1964
|
+
onErrorRef.current = onError;
|
|
1965
|
+
const storeConfigurationRef = useRef({ store, storeFactory });
|
|
1966
|
+
const storePromiseRef = useRef(null);
|
|
1967
|
+
const voiceServiceRef = useRef(voiceService ?? null);
|
|
1968
|
+
const voiceEffectGenerationRef = useRef(0);
|
|
1969
|
+
const remoteServiceRef = useRef(remoteService ?? null);
|
|
1970
|
+
const remoteBindingRef = useRef(null);
|
|
1971
|
+
const remoteScanActiveRef = useRef(false);
|
|
1972
|
+
const remoteLifecycleQueueRef = useRef(Promise.resolve());
|
|
1973
|
+
const remoteUnsubscribersRef = useRef([]);
|
|
1974
|
+
const remoteDedupeRef = useRef(new BTHomeRemoteDeduplicator());
|
|
1975
|
+
const remoteRangeTimerRef = useRef(null);
|
|
1976
|
+
const handleRemoteAdvertisementRef = useRef(() => void 0);
|
|
1977
|
+
const handleRemoteNativeStateRef = useRef(() => void 0);
|
|
1978
|
+
const handleHidVolumeRef = useRef(() => void 0);
|
|
1979
|
+
const handleRemoteMappedActionRef = useRef(() => void 0);
|
|
1980
|
+
const hidGestureRecognizerRef = useRef(null);
|
|
1981
|
+
const resolvedRemoteMapping = useMemo(
|
|
1982
|
+
() => ({ ...DEFAULT_REMOTE_ACTION_MAPPING, ...remoteActionMapping }),
|
|
1983
|
+
[remoteActionMapping]
|
|
1984
|
+
);
|
|
1985
|
+
const reportVoiceError = useCallback((caught) => {
|
|
1986
|
+
const operationError = caught instanceof Error ? caught : new Error("Unknown voice operation failure");
|
|
1987
|
+
if (mountedRef.current) setVoiceError(operationError);
|
|
1988
|
+
}, []);
|
|
1989
|
+
const voiceTtsRef = useRef(voiceTts);
|
|
1990
|
+
const requireVoiceService = useCallback(() => {
|
|
1991
|
+
voiceServiceRef.current ??= createDefaultMatchVoiceService({
|
|
1992
|
+
audioMode: voiceAudioMode,
|
|
1993
|
+
tts: voiceTtsRef.current
|
|
1994
|
+
});
|
|
1995
|
+
return voiceServiceRef.current;
|
|
1996
|
+
}, [voiceAudioMode]);
|
|
1997
|
+
const requireRemoteService = useCallback(() => {
|
|
1998
|
+
remoteServiceRef.current ??= createDefaultRemoteService();
|
|
1999
|
+
return remoteServiceRef.current;
|
|
2000
|
+
}, []);
|
|
2001
|
+
const getStore = useCallback(() => {
|
|
2002
|
+
if (!storePromiseRef.current) {
|
|
2003
|
+
const configuration = storeConfigurationRef.current;
|
|
2004
|
+
const pending = Promise.resolve().then(async () => {
|
|
2005
|
+
const persistence = configuration.store ? configuration.store : configuration.storeFactory ? await configuration.storeFactory() : await createDefaultMatchEventStore();
|
|
2006
|
+
await persistence.initialize();
|
|
2007
|
+
return persistence;
|
|
2008
|
+
});
|
|
2009
|
+
storePromiseRef.current = pending;
|
|
2010
|
+
void pending.catch(() => {
|
|
2011
|
+
if (storePromiseRef.current === pending) storePromiseRef.current = null;
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
return storePromiseRef.current;
|
|
2015
|
+
}, []);
|
|
2016
|
+
const publish = useCallback((match) => {
|
|
2017
|
+
snapshotRef.current = match?.snapshot ?? null;
|
|
2018
|
+
eventsRef.current = match?.events ?? [];
|
|
2019
|
+
if (mountedRef.current) {
|
|
2020
|
+
setSnapshot(snapshotRef.current);
|
|
2021
|
+
setEvents(eventsRef.current);
|
|
2022
|
+
}
|
|
2023
|
+
}, []);
|
|
2024
|
+
const enqueue = useCallback((work) => {
|
|
2025
|
+
const run = async () => {
|
|
2026
|
+
if (mountedRef.current) {
|
|
2027
|
+
setLoading(true);
|
|
2028
|
+
setError(null);
|
|
2029
|
+
}
|
|
2030
|
+
try {
|
|
2031
|
+
return await work();
|
|
2032
|
+
} catch (caught) {
|
|
2033
|
+
const operationError = caught instanceof Error ? caught : new Error("Unknown match operation failure");
|
|
2034
|
+
if (mountedRef.current) setError(operationError);
|
|
2035
|
+
try {
|
|
2036
|
+
const observerResult = onErrorRef.current?.(operationError);
|
|
2037
|
+
if (observerResult) void Promise.resolve(observerResult).catch(() => void 0);
|
|
2038
|
+
} catch {
|
|
2039
|
+
}
|
|
2040
|
+
throw operationError;
|
|
2041
|
+
} finally {
|
|
2042
|
+
if (mountedRef.current) setLoading(false);
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
const result = queueRef.current.then(run, run);
|
|
2046
|
+
queueRef.current = result.then(
|
|
2047
|
+
() => void 0,
|
|
2048
|
+
() => void 0
|
|
2049
|
+
);
|
|
2050
|
+
return result;
|
|
2051
|
+
}, []);
|
|
2052
|
+
const requireSnapshot = useCallback(() => {
|
|
2053
|
+
if (!snapshotRef.current) throw new NoActiveMatchError();
|
|
2054
|
+
return snapshotRef.current;
|
|
2055
|
+
}, []);
|
|
2056
|
+
const commit = useCallback(
|
|
2057
|
+
(makeEvent) => enqueue(async () => {
|
|
2058
|
+
const current = requireSnapshot();
|
|
2059
|
+
const event = makeEvent(current);
|
|
2060
|
+
if (!event) return current;
|
|
2061
|
+
const persistence = await getStore();
|
|
2062
|
+
const committed = await persistence.appendEvent(current, event);
|
|
2063
|
+
publish(committed);
|
|
2064
|
+
if (event.type === "rallyAwarded" || event.type === "courtSwitchConfirmed") {
|
|
2065
|
+
try {
|
|
2066
|
+
requireVoiceService().announce(current, committed.snapshot, event);
|
|
2067
|
+
} catch (voiceFailure) {
|
|
2068
|
+
reportVoiceError(voiceFailure);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return committed.snapshot;
|
|
2072
|
+
}),
|
|
2073
|
+
[enqueue, getStore, publish, reportVoiceError, requireSnapshot, requireVoiceService]
|
|
2074
|
+
);
|
|
2075
|
+
const createMatchAction = useCallback(
|
|
2076
|
+
(input, source = "local") => enqueue(async () => {
|
|
2077
|
+
const persistence = await getStore();
|
|
2078
|
+
const created = await persistence.createMatch(input, source);
|
|
2079
|
+
publish(created);
|
|
2080
|
+
return created.snapshot;
|
|
2081
|
+
}),
|
|
2082
|
+
[enqueue, getStore, publish]
|
|
2083
|
+
);
|
|
2084
|
+
const resumeMatchAction = useCallback(
|
|
2085
|
+
(matchId) => enqueue(async () => {
|
|
2086
|
+
const persistence = await getStore();
|
|
2087
|
+
const resumed = matchId ? await persistence.resumeMatch(matchId) : await persistence.resumeActiveMatch();
|
|
2088
|
+
publish(resumed);
|
|
2089
|
+
return resumed?.snapshot ?? null;
|
|
2090
|
+
}),
|
|
2091
|
+
[enqueue, getStore, publish]
|
|
2092
|
+
);
|
|
2093
|
+
const awardRallyToSide = useCallback(
|
|
2094
|
+
(side, source = "local") => commit((current) => awardRally(current, side, source)),
|
|
2095
|
+
[commit]
|
|
2096
|
+
);
|
|
2097
|
+
const undo = useCallback(
|
|
2098
|
+
(source = "local") => commit(() => undoLastMatchEvent(eventsRef.current, source)),
|
|
2099
|
+
[commit]
|
|
2100
|
+
);
|
|
2101
|
+
const correctState = useCallback(
|
|
2102
|
+
(correction, source = "local") => commit((current) => correctMatch(current, correction, source)),
|
|
2103
|
+
[commit]
|
|
2104
|
+
);
|
|
2105
|
+
const confirmCourtSwitch2 = useCallback(
|
|
2106
|
+
(source = "local") => commit((current) => confirmCourtSwitch(current, source)),
|
|
2107
|
+
[commit]
|
|
2108
|
+
);
|
|
2109
|
+
const endMatch2 = useCallback(
|
|
2110
|
+
(reason, source = "local") => commit((current) => endMatch(current, reason, source)),
|
|
2111
|
+
[commit]
|
|
2112
|
+
);
|
|
2113
|
+
const runVoiceAction = useCallback(
|
|
2114
|
+
async (action) => {
|
|
2115
|
+
try {
|
|
2116
|
+
setVoiceError(null);
|
|
2117
|
+
await action(requireVoiceService());
|
|
2118
|
+
} catch (caught) {
|
|
2119
|
+
reportVoiceError(caught);
|
|
2120
|
+
throw caught;
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
[reportVoiceError, requireVoiceService]
|
|
2124
|
+
);
|
|
2125
|
+
const testSpeaker = useCallback(
|
|
2126
|
+
() => runVoiceAction((service) => service.testSpeaker()),
|
|
2127
|
+
[runVoiceAction]
|
|
2128
|
+
);
|
|
2129
|
+
const replayAnnouncement = useCallback(
|
|
2130
|
+
() => runVoiceAction((service) => service.replayAnnouncement()),
|
|
2131
|
+
[runVoiceAction]
|
|
2132
|
+
);
|
|
2133
|
+
const setVoiceAudioMode = useCallback(
|
|
2134
|
+
(mode) => runVoiceAction(async (service) => {
|
|
2135
|
+
if (!service.configureAudioMode) {
|
|
2136
|
+
throw new Error("The injected voice service cannot change audio mode");
|
|
2137
|
+
}
|
|
2138
|
+
await service.configureAudioMode(mode);
|
|
2139
|
+
}),
|
|
2140
|
+
[runVoiceAction]
|
|
2141
|
+
);
|
|
2142
|
+
const publishRemote = useCallback((update) => {
|
|
2143
|
+
if (mountedRef.current) setRemoteState(update);
|
|
2144
|
+
}, []);
|
|
2145
|
+
const clearRemoteRangeTimer = useCallback(() => {
|
|
2146
|
+
if (remoteRangeTimerRef.current) clearTimeout(remoteRangeTimerRef.current);
|
|
2147
|
+
remoteRangeTimerRef.current = null;
|
|
2148
|
+
}, []);
|
|
2149
|
+
const armRemoteRangeTimer = useCallback(() => {
|
|
2150
|
+
clearRemoteRangeTimer();
|
|
2151
|
+
remoteRangeTimerRef.current = setTimeout(() => {
|
|
2152
|
+
if (!remoteScanActiveRef.current || !remoteBindingRef.current) return;
|
|
2153
|
+
publishRemote((current) => ({ ...current, status: "outOfRange" }));
|
|
2154
|
+
}, remoteOutOfRangeTimeoutMs);
|
|
2155
|
+
}, [clearRemoteRangeTimer, publishRemote, remoteOutOfRangeTimeoutMs]);
|
|
2156
|
+
const bindRemote = useCallback(async (binding) => {
|
|
2157
|
+
if (binding.deviceId.trim().length === 0) throw new Error("Remote identifier cannot be empty");
|
|
2158
|
+
const persistence = await getStore();
|
|
2159
|
+
if (!persistence.setRemoteBinding) throw new Error("Remote binding persistence is unavailable");
|
|
2160
|
+
await persistence.setRemoteBinding(binding);
|
|
2161
|
+
remoteBindingRef.current = binding;
|
|
2162
|
+
remoteDedupeRef.current.clear();
|
|
2163
|
+
publishRemote((current) => ({
|
|
2164
|
+
...current,
|
|
2165
|
+
binding,
|
|
2166
|
+
candidate: null,
|
|
2167
|
+
status: remoteScanActiveRef.current ? "bound" : "idle",
|
|
2168
|
+
error: null
|
|
2169
|
+
}));
|
|
2170
|
+
}, [getStore, publishRemote]);
|
|
2171
|
+
const unbindRemote = useCallback(async () => {
|
|
2172
|
+
const persistence = await getStore();
|
|
2173
|
+
if (!persistence.setRemoteBinding) throw new Error("Remote binding persistence is unavailable");
|
|
2174
|
+
await persistence.setRemoteBinding(null);
|
|
2175
|
+
remoteBindingRef.current = null;
|
|
2176
|
+
remoteDedupeRef.current.clear();
|
|
2177
|
+
clearRemoteRangeTimer();
|
|
2178
|
+
publishRemote((current) => ({
|
|
2179
|
+
...current,
|
|
2180
|
+
binding: null,
|
|
2181
|
+
candidate: null,
|
|
2182
|
+
lastSeenAt: null,
|
|
2183
|
+
status: remoteScanActiveRef.current ? "scanning" : "idle",
|
|
2184
|
+
error: null
|
|
2185
|
+
}));
|
|
2186
|
+
}, [clearRemoteRangeTimer, getStore, publishRemote]);
|
|
2187
|
+
const ensureRemoteSubscriptions = useCallback((service) => {
|
|
2188
|
+
if (remoteUnsubscribersRef.current.length > 0) return;
|
|
2189
|
+
remoteUnsubscribersRef.current = [
|
|
2190
|
+
service.subscribeAdvertisement((event) => handleRemoteAdvertisementRef.current(event)),
|
|
2191
|
+
service.subscribeState((event) => handleRemoteNativeStateRef.current(event)),
|
|
2192
|
+
service.subscribeHidVolume((event) => handleHidVolumeRef.current(event))
|
|
2193
|
+
];
|
|
2194
|
+
}, []);
|
|
2195
|
+
const startRemoteScan = useCallback(() => {
|
|
2196
|
+
const run = remoteLifecycleQueueRef.current.then(async () => {
|
|
2197
|
+
if (remoteScanActiveRef.current) return;
|
|
2198
|
+
const persistence = await getStore();
|
|
2199
|
+
const binding = persistence.getRemoteBinding ? await persistence.getRemoteBinding() : remoteBindingRef.current;
|
|
2200
|
+
remoteBindingRef.current = binding;
|
|
2201
|
+
const service = requireRemoteService();
|
|
2202
|
+
ensureRemoteSubscriptions(service);
|
|
2203
|
+
remoteScanActiveRef.current = true;
|
|
2204
|
+
publishRemote((current) => ({
|
|
2205
|
+
...current,
|
|
2206
|
+
binding,
|
|
2207
|
+
status: binding ? "bound" : "scanning",
|
|
2208
|
+
error: null
|
|
2209
|
+
}));
|
|
2210
|
+
try {
|
|
2211
|
+
await service.start();
|
|
2212
|
+
} catch (caught) {
|
|
2213
|
+
remoteScanActiveRef.current = false;
|
|
2214
|
+
const failure = caught instanceof RemoteError ? caught : new RemoteError("scan_failed", "Unable to start BTHome scanning", { cause: caught });
|
|
2215
|
+
publishRemote((current) => ({ ...current, status: "idle", error: failure }));
|
|
2216
|
+
throw failure;
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
remoteLifecycleQueueRef.current = run.catch(() => void 0);
|
|
2220
|
+
return run;
|
|
2221
|
+
}, [ensureRemoteSubscriptions, getStore, publishRemote, requireRemoteService]);
|
|
2222
|
+
const stopRemoteScan = useCallback(() => {
|
|
2223
|
+
const run = remoteLifecycleQueueRef.current.then(async () => {
|
|
2224
|
+
remoteScanActiveRef.current = false;
|
|
2225
|
+
clearRemoteRangeTimer();
|
|
2226
|
+
remoteDedupeRef.current.clear();
|
|
2227
|
+
await remoteServiceRef.current?.stop();
|
|
2228
|
+
for (const unsubscribe of remoteUnsubscribersRef.current) unsubscribe();
|
|
2229
|
+
remoteUnsubscribersRef.current = [];
|
|
2230
|
+
publishRemote((current) => ({ ...current, status: "idle", candidate: null }));
|
|
2231
|
+
});
|
|
2232
|
+
remoteLifecycleQueueRef.current = run.catch(() => void 0);
|
|
2233
|
+
return run;
|
|
2234
|
+
}, [clearRemoteRangeTimer, publishRemote]);
|
|
2235
|
+
const startHidCapture = useCallback(async () => {
|
|
2236
|
+
if (remoteInputMode === "bthome") throw new Error("HID volume input is disabled by remoteInputMode");
|
|
2237
|
+
hidGestureRecognizerRef.current?.dispose();
|
|
2238
|
+
hidGestureRecognizerRef.current = null;
|
|
2239
|
+
const service = requireRemoteService();
|
|
2240
|
+
ensureRemoteSubscriptions(service);
|
|
2241
|
+
await service.startHidCapture({ consume: consumeHidVolumeKeys });
|
|
2242
|
+
publishRemote((current) => ({ ...current, hidCaptureArmed: true, error: null }));
|
|
2243
|
+
}, [consumeHidVolumeKeys, ensureRemoteSubscriptions, publishRemote, remoteInputMode, requireRemoteService]);
|
|
2244
|
+
const stopHidCapture = useCallback(async () => {
|
|
2245
|
+
hidGestureRecognizerRef.current?.dispose();
|
|
2246
|
+
hidGestureRecognizerRef.current = null;
|
|
2247
|
+
await remoteServiceRef.current?.stopHidCapture();
|
|
2248
|
+
publishRemote((current) => ({ ...current, hidCaptureArmed: false }));
|
|
2249
|
+
}, [publishRemote]);
|
|
2250
|
+
handleRemoteMappedActionRef.current = (mapped) => {
|
|
2251
|
+
const action = mapped === "left" ? awardRallyToSide("left", "remote") : mapped === "right" ? awardRallyToSide("right", "remote") : mapped === "undo" ? undo("remote") : mapped === "announce" ? replayAnnouncement() : null;
|
|
2252
|
+
if (action) void action.catch(() => void 0);
|
|
2253
|
+
};
|
|
2254
|
+
handleHidVolumeRef.current = (event) => {
|
|
2255
|
+
if (!mountedRef.current) return;
|
|
2256
|
+
publishRemote((current) => ({
|
|
2257
|
+
...current,
|
|
2258
|
+
lastInputSource: "hidVolume",
|
|
2259
|
+
lastHidEvent: event
|
|
2260
|
+
}));
|
|
2261
|
+
if (hidGestureMapping) {
|
|
2262
|
+
hidGestureRecognizerRef.current ??= new HidVolumeGestureRecognizer({
|
|
2263
|
+
...hidGestureMapping,
|
|
2264
|
+
onGesture: (gesture) => {
|
|
2265
|
+
handleRemoteMappedActionRef.current(hidGestureMapping[gesture] ?? "none");
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
hidGestureRecognizerRef.current.push(event);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
const mapped = event.longPress ? resolvedRemoteMapping.longPress : resolvedRemoteMapping[event.direction];
|
|
2272
|
+
handleRemoteMappedActionRef.current(mapped);
|
|
2273
|
+
};
|
|
2274
|
+
handleRemoteNativeStateRef.current = (nativeState) => {
|
|
2275
|
+
if (!remoteScanActiveRef.current) return;
|
|
2276
|
+
if (nativeState.status === "permission") {
|
|
2277
|
+
const error2 = nativeState.permission === "denied" ? new RemoteError("permission_denied", "Bluetooth scan permission was denied") : nativeState.permission === "restricted" ? new RemoteError("permission_restricted", "Bluetooth access is restricted") : null;
|
|
2278
|
+
publishRemote((current) => ({ ...current, permission: nativeState.permission, error: error2 }));
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
if (nativeState.status === "poweredOff" || nativeState.status === "unavailable") {
|
|
2282
|
+
const error2 = nativeState.status === "poweredOff" ? new RemoteError("bluetooth_powered_off", "Bluetooth is powered off") : new RemoteError("bluetooth_unavailable", "Bluetooth LE is unavailable");
|
|
2283
|
+
publishRemote((current) => ({ ...current, status: nativeState.status, error: error2 }));
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (nativeState.status === "error") {
|
|
2287
|
+
publishRemote((current) => ({
|
|
2288
|
+
...current,
|
|
2289
|
+
error: new RemoteError("scan_failed", nativeState.message)
|
|
2290
|
+
}));
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
handleRemoteAdvertisementRef.current = (advertisement) => {
|
|
2294
|
+
if (!remoteScanActiveRef.current) return;
|
|
2295
|
+
let parsed;
|
|
2296
|
+
try {
|
|
2297
|
+
parsed = parseBTHomeV2Advertisement({
|
|
2298
|
+
deviceId: advertisement.deviceId,
|
|
2299
|
+
serviceUuid: advertisement.serviceUuid,
|
|
2300
|
+
serviceData: decodeBase64Bytes(advertisement.serviceData)
|
|
2301
|
+
});
|
|
2302
|
+
} catch {
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
const binding = remoteBindingRef.current;
|
|
2306
|
+
if (!binding) {
|
|
2307
|
+
publishRemote((current) => ({
|
|
2308
|
+
...current,
|
|
2309
|
+
candidate: {
|
|
2310
|
+
deviceId: advertisement.deviceId,
|
|
2311
|
+
...advertisement.name ? { label: advertisement.name } : {},
|
|
2312
|
+
rssi: advertisement.rssi,
|
|
2313
|
+
lastSeenAt: advertisement.timestamp
|
|
2314
|
+
}
|
|
2315
|
+
}));
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (binding.deviceId !== advertisement.deviceId) return;
|
|
2319
|
+
if (!remoteDedupeRef.current.accept(advertisement.deviceId, parsed, advertisement.timestamp)) return;
|
|
2320
|
+
armRemoteRangeTimer();
|
|
2321
|
+
publishRemote((current) => ({
|
|
2322
|
+
...current,
|
|
2323
|
+
status: "bound",
|
|
2324
|
+
lastSeenAt: advertisement.timestamp,
|
|
2325
|
+
lastInputSource: "bthome",
|
|
2326
|
+
error: null
|
|
2327
|
+
}));
|
|
2328
|
+
const action = parsed.button.action === "single" ? awardRallyToSide("left", "remote") : parsed.button.action === "double" ? awardRallyToSide("right", "remote") : undo("remote");
|
|
2329
|
+
void action.catch(() => void 0);
|
|
2330
|
+
};
|
|
2331
|
+
useEffect(() => {
|
|
2332
|
+
mountedRef.current = true;
|
|
2333
|
+
if (!didAutoResumeRef.current && autoResumeOnMountRef.current) {
|
|
2334
|
+
didAutoResumeRef.current = true;
|
|
2335
|
+
void resumeMatchAction().catch(() => void 0);
|
|
2336
|
+
}
|
|
2337
|
+
return () => {
|
|
2338
|
+
mountedRef.current = false;
|
|
2339
|
+
remoteScanActiveRef.current = false;
|
|
2340
|
+
hidGestureRecognizerRef.current?.dispose();
|
|
2341
|
+
hidGestureRecognizerRef.current = null;
|
|
2342
|
+
clearRemoteRangeTimer();
|
|
2343
|
+
for (const unsubscribe of remoteUnsubscribersRef.current) unsubscribe();
|
|
2344
|
+
remoteUnsubscribersRef.current = [];
|
|
2345
|
+
const service = remoteServiceRef.current;
|
|
2346
|
+
if (service) {
|
|
2347
|
+
void Promise.resolve().then(async () => {
|
|
2348
|
+
await service.stop();
|
|
2349
|
+
await service.stopHidCapture();
|
|
2350
|
+
await service.dispose();
|
|
2351
|
+
}).catch(() => void 0);
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
}, [clearRemoteRangeTimer, resumeMatchAction]);
|
|
2355
|
+
useEffect(() => {
|
|
2356
|
+
const effectGeneration = ++voiceEffectGenerationRef.current;
|
|
2357
|
+
const service = requireVoiceService();
|
|
2358
|
+
const refresh = () => {
|
|
2359
|
+
if (!mountedRef.current) return;
|
|
2360
|
+
setAudioState(service.audioState);
|
|
2361
|
+
if (service.lastError) setVoiceError(service.lastError);
|
|
2362
|
+
};
|
|
2363
|
+
const unsubscribe = service.subscribe(refresh);
|
|
2364
|
+
refresh();
|
|
2365
|
+
void service.initialize().then(refresh).catch(reportVoiceError);
|
|
2366
|
+
return () => {
|
|
2367
|
+
unsubscribe();
|
|
2368
|
+
if (!voiceService) {
|
|
2369
|
+
void Promise.resolve().then(async () => {
|
|
2370
|
+
if (voiceEffectGenerationRef.current === effectGeneration) await service.dispose();
|
|
2371
|
+
}).catch(() => void 0);
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
}, [reportVoiceError, requireVoiceService, voiceService]);
|
|
2375
|
+
const value = useMemo(
|
|
2376
|
+
() => ({
|
|
2377
|
+
snapshot,
|
|
2378
|
+
events,
|
|
2379
|
+
loading,
|
|
2380
|
+
error,
|
|
2381
|
+
voiceError,
|
|
2382
|
+
audioState,
|
|
2383
|
+
remoteState,
|
|
2384
|
+
createMatch: createMatchAction,
|
|
2385
|
+
resumeMatch: resumeMatchAction,
|
|
2386
|
+
awardRallyToSide,
|
|
2387
|
+
undo,
|
|
2388
|
+
correctState,
|
|
2389
|
+
confirmCourtSwitch: confirmCourtSwitch2,
|
|
2390
|
+
endMatch: endMatch2,
|
|
2391
|
+
testSpeaker,
|
|
2392
|
+
replayAnnouncement,
|
|
2393
|
+
setVoiceAudioMode,
|
|
2394
|
+
bindRemote,
|
|
2395
|
+
unbindRemote,
|
|
2396
|
+
startRemoteScan,
|
|
2397
|
+
stopRemoteScan,
|
|
2398
|
+
startHidCapture,
|
|
2399
|
+
stopHidCapture
|
|
2400
|
+
}),
|
|
2401
|
+
[
|
|
2402
|
+
snapshot,
|
|
2403
|
+
events,
|
|
2404
|
+
loading,
|
|
2405
|
+
error,
|
|
2406
|
+
voiceError,
|
|
2407
|
+
audioState,
|
|
2408
|
+
remoteState,
|
|
2409
|
+
createMatchAction,
|
|
2410
|
+
resumeMatchAction,
|
|
2411
|
+
awardRallyToSide,
|
|
2412
|
+
undo,
|
|
2413
|
+
correctState,
|
|
2414
|
+
confirmCourtSwitch2,
|
|
2415
|
+
endMatch2,
|
|
2416
|
+
testSpeaker,
|
|
2417
|
+
replayAnnouncement,
|
|
2418
|
+
setVoiceAudioMode,
|
|
2419
|
+
bindRemote,
|
|
2420
|
+
unbindRemote,
|
|
2421
|
+
startRemoteScan,
|
|
2422
|
+
stopRemoteScan,
|
|
2423
|
+
startHidCapture,
|
|
2424
|
+
stopHidCapture
|
|
2425
|
+
]
|
|
2426
|
+
);
|
|
2427
|
+
return /* @__PURE__ */ jsx(MatchContext.Provider, { value, children });
|
|
2428
|
+
}
|
|
2429
|
+
function usePickleballMatch() {
|
|
2430
|
+
const value = React.use(MatchContext);
|
|
2431
|
+
if (!value) {
|
|
2432
|
+
throw new Error("usePickleballMatch must be used inside PickleballMatchProvider");
|
|
2433
|
+
}
|
|
2434
|
+
return value;
|
|
2435
|
+
}
|
|
2436
|
+
export {
|
|
2437
|
+
MATCH_PERSISTENCE_SCHEMA_VERSION,
|
|
2438
|
+
MATCH_SQL_MIGRATIONS,
|
|
2439
|
+
MATCH_SQL_SCHEMA_VERSION,
|
|
2440
|
+
MatchEventStore,
|
|
2441
|
+
MatchPersistenceError,
|
|
2442
|
+
MemoryMatchPersistenceDriver,
|
|
2443
|
+
NoActiveMatchError,
|
|
2444
|
+
PickleballMatchProvider,
|
|
2445
|
+
applyMatchEvent,
|
|
2446
|
+
awardRally,
|
|
2447
|
+
confirmCourtSwitch,
|
|
2448
|
+
correctMatch,
|
|
2449
|
+
createDefaultMatchEventStore,
|
|
2450
|
+
createExpoSqliteMatchDriver,
|
|
2451
|
+
createMatch,
|
|
2452
|
+
endMatch,
|
|
2453
|
+
replayMatchEvents,
|
|
2454
|
+
undoLastMatchEvent,
|
|
2455
|
+
usePickleballMatch
|
|
2456
|
+
};
|