@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +69 -0
  3. package/dist/advanced-BXDrDl1h.d.ts +316 -0
  4. package/dist/advanced-BdAa2Erv.d.cts +316 -0
  5. package/dist/advanced.cjs +35 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +35 -0
  9. package/dist/chunk-2JHULJZ7.js +265 -0
  10. package/dist/chunk-6JOMESNT.js +9 -0
  11. package/dist/chunk-7OX2DSKI.cjs +33 -0
  12. package/dist/chunk-DHDX3ZOO.js +800 -0
  13. package/dist/chunk-FODE2W2H.js +1953 -0
  14. package/dist/chunk-KFQGP6VL.js +33 -0
  15. package/dist/chunk-LCWGNGEN.cjs +265 -0
  16. package/dist/chunk-TBPR2QND.cjs +1953 -0
  17. package/dist/chunk-TRLASNPQ.cjs +9 -0
  18. package/dist/chunk-YGSQ25UA.cjs +800 -0
  19. package/dist/device-agent/index.cjs +498 -0
  20. package/dist/device-agent/index.d.cts +157 -0
  21. package/dist/device-agent/index.d.ts +157 -0
  22. package/dist/device-agent/index.js +498 -0
  23. package/dist/engine-CCSSqPbZ.d.cts +516 -0
  24. package/dist/engine-CCSSqPbZ.d.ts +516 -0
  25. package/dist/index.cjs +339 -5569
  26. package/dist/index.d.cts +22 -1406
  27. package/dist/index.d.ts +22 -1406
  28. package/dist/index.js +269 -5401
  29. package/dist/match/index.cjs +2456 -0
  30. package/dist/match/index.d.cts +253 -0
  31. package/dist/match/index.d.ts +253 -0
  32. package/dist/match/index.js +2456 -0
  33. package/dist/match/remote/index.cjs +21 -0
  34. package/dist/match/remote/index.d.cts +67 -0
  35. package/dist/match/remote/index.d.ts +67 -0
  36. package/dist/match/remote/index.js +21 -0
  37. package/dist/match/voice/index.cjs +23 -0
  38. package/dist/match/voice/index.d.cts +114 -0
  39. package/dist/match/voice/index.d.ts +114 -0
  40. package/dist/match/voice/index.js +23 -0
  41. package/dist/plugin.cjs +53 -70
  42. package/dist/plugin.d.cts +13 -0
  43. package/dist/plugin.d.ts +13 -0
  44. package/dist/plugin.js +20 -15
  45. package/dist/types-CrmcX408.d.cts +176 -0
  46. package/dist/types-CrmcX408.d.ts +176 -0
  47. package/dist/types-DiKWjwvN.d.cts +96 -0
  48. package/dist/types-DiKWjwvN.d.ts +96 -0
  49. package/dist/version.cjs +4 -32
  50. package/dist/version.js +2 -1
  51. package/package.json +68 -4
  52. package/src/advanced.ts +47 -0
  53. package/src/device-agent/agent.ts +443 -0
  54. package/src/device-agent/component.tsx +49 -0
  55. package/src/device-agent/convex-control-channel.ts +82 -0
  56. package/src/device-agent/http-transport.ts +143 -0
  57. package/src/device-agent/index.ts +4 -0
  58. package/src/engine.ts +21 -5
  59. package/src/errors.ts +16 -0
  60. package/src/http-session-provider.ts +19 -13
  61. package/src/index.ts +38 -14
  62. package/src/livekit-room-adapter.ts +39 -2
  63. package/src/livestream-screen.tsx +26 -4
  64. package/src/match/index.ts +29 -0
  65. package/src/match/provider.tsx +4 -2
  66. package/src/match/remote/hid-gesture.ts +10 -2
  67. package/src/match/remote/types.ts +6 -4
  68. package/src/match/voice/scheduler.ts +6 -1
  69. package/src/match/voice/service.ts +16 -4
  70. package/src/native-runtime.ts +54 -4
  71. package/src/plugin.ts +35 -14
  72. package/src/provider.tsx +35 -4
  73. package/src/rtmp-room-adapter.ts +196 -0
  74. 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
+ };