@omercnet/paseo-queens 0.1.0-next.126.2

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.
@@ -0,0 +1,769 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import {
4
+ DEFAULT_GAME_SETTINGS,
5
+ GAME_SETTINGS_LIMITS,
6
+ type GameSettings,
7
+ gameSettings,
8
+ } from "../shared/game-settings";
9
+ import {
10
+ createGameState,
11
+ type GameAction,
12
+ type GameState,
13
+ gameReducer,
14
+ isSolved,
15
+ PUZZLES,
16
+ type Puzzle,
17
+ type PuzzleSolution,
18
+ } from "./game";
19
+
20
+ const EMPTY_SOLUTIONS: Readonly<Record<string, PuzzleSolution>> = {};
21
+ const NOOP_SUBSCRIPTION = () => {};
22
+
23
+ export interface HydratedGame {
24
+ readonly state: GameState;
25
+ readonly hintUsed: Readonly<Record<string, boolean>>;
26
+ readonly records: GameSettings["records"];
27
+ }
28
+
29
+ export interface GamePersistenceControllerSnapshot {
30
+ readonly state: GameState;
31
+ readonly saving: boolean;
32
+ readonly saveError: string | null;
33
+ readonly hintUsed: boolean;
34
+ }
35
+
36
+ export interface GamePersistenceControllerOptions {
37
+ readonly settings: GameSettings;
38
+ readonly revision: string;
39
+ readonly save: SaveGameSettings;
40
+ readonly puzzles?: readonly Puzzle[];
41
+ readonly knownSolutions?: Readonly<Record<string, PuzzleSolution>>;
42
+ readonly now?: number;
43
+ }
44
+
45
+ export type SaveGameSettings = (values: GameSettings, revision: string) => Promise<boolean>;
46
+
47
+ type AttemptMetadata = {
48
+ readonly hintUsed: boolean;
49
+ readonly completionCredited: boolean;
50
+ };
51
+
52
+ type PendingSave = {
53
+ readonly generation: number;
54
+ readonly values: GameSettings;
55
+ };
56
+
57
+ type InFlightSave = PendingSave & {
58
+ readonly epoch: number;
59
+ readonly revision: string;
60
+ };
61
+
62
+ export type PersistedGameSession =
63
+ | {
64
+ readonly status: "loading";
65
+ readonly saving: boolean;
66
+ readonly saveError: string | null;
67
+ }
68
+ | {
69
+ readonly status: "error";
70
+ readonly error: string;
71
+ readonly saving: boolean;
72
+ readonly saveError: string | null;
73
+ reload(): Promise<void>;
74
+ }
75
+ | {
76
+ readonly status: "invalid";
77
+ readonly error: string;
78
+ readonly saving: boolean;
79
+ readonly saveError: string | null;
80
+ reload(): Promise<void>;
81
+ reset(): Promise<boolean>;
82
+ }
83
+ | {
84
+ readonly status: "ready";
85
+ readonly state: GameState;
86
+ readonly hintUsed: boolean;
87
+ readonly saving: boolean;
88
+ readonly saveError: string | null;
89
+ dispatch(action: GameAction): void;
90
+ reload(): Promise<void>;
91
+ };
92
+
93
+ function persistedTimer(startedAtMs: number | null, completedAtMs: number | null, now: number) {
94
+ if (startedAtMs === null) {
95
+ return {
96
+ startedAt: null,
97
+ completedAt: null,
98
+ elapsedMs: 0,
99
+ lastTickAt: null,
100
+ } as const;
101
+ }
102
+
103
+ const end = completedAtMs ?? Math.max(startedAtMs, now);
104
+ return {
105
+ startedAt: startedAtMs,
106
+ completedAt: completedAtMs,
107
+ elapsedMs: Math.max(0, end - startedAtMs),
108
+ lastTickAt: completedAtMs === null ? end : null,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Hydrates reducer state by puzzle id. Undo history and derived conflict state are
114
+ * deliberately reconstructed locally rather than persisted.
115
+ */
116
+ export function gameStateFromSettings(
117
+ settings: GameSettings,
118
+ puzzles: readonly Puzzle[] = PUZZLES,
119
+ knownSolutions: Readonly<Record<string, PuzzleSolution>> = EMPTY_SOLUTIONS,
120
+ now = Date.now(),
121
+ ): HydratedGame {
122
+ const initial = createGameState(puzzles, knownSolutions);
123
+ const progress = initial.progress.slice();
124
+ const hintUsed: Record<string, boolean> = {};
125
+
126
+ for (let index = 0; index < initial.puzzles.length; index += 1) {
127
+ const puzzle = initial.puzzles[index];
128
+ const persisted = settings.puzzleStates[puzzle.id];
129
+ if (persisted === undefined) continue;
130
+
131
+ hintUsed[puzzle.id] = persisted.hintUsed;
132
+ if (persisted.cells.length !== puzzle.size * puzzle.size) continue;
133
+
134
+ const cells = persisted.cells.slice();
135
+ progress[index] = {
136
+ cells,
137
+ history: [],
138
+ solved: isSolved(puzzle, cells),
139
+ timer: persistedTimer(persisted.startedAtMs, persisted.completedAtMs, now),
140
+ };
141
+ }
142
+
143
+ const selectedIndex = initial.puzzles.findIndex(
144
+ (puzzle) => puzzle.id === settings.currentPuzzleId,
145
+ );
146
+
147
+ return {
148
+ state: {
149
+ ...initial,
150
+ progress,
151
+ activePuzzleIndex: selectedIndex < 0 ? 0 : selectedIndex,
152
+ },
153
+ hintUsed,
154
+ records: { ...settings.records },
155
+ };
156
+ }
157
+
158
+ function persistedTimestamp(value: number | null): number | null {
159
+ if (value === null) return null;
160
+ return Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(value)));
161
+ }
162
+
163
+ function trimOldestEntries(collection: Record<string, unknown>, protectedKey: string): void {
164
+ let excess = Object.keys(collection).length - GAME_SETTINGS_LIMITS.puzzles;
165
+ if (excess <= 0) return;
166
+ for (const key of Object.keys(collection)) {
167
+ if (key === protectedKey) continue;
168
+ delete collection[key];
169
+ excess -= 1;
170
+ if (excess === 0) return;
171
+ }
172
+ }
173
+
174
+ /** Serializes only stable gameplay data; timer ticks and undo history stay local. */
175
+ export function gameSettingsFromState(
176
+ state: GameState,
177
+ metadata: Pick<HydratedGame, "hintUsed" | "records">,
178
+ baseline: GameSettings = DEFAULT_GAME_SETTINGS,
179
+ ): GameSettings {
180
+ const puzzleStates: GameSettings["puzzleStates"] = { ...baseline.puzzleStates };
181
+
182
+ for (let index = 0; index < state.puzzles.length; index += 1) {
183
+ const puzzle = state.puzzles[index];
184
+ const progress = state.progress[index];
185
+ const startedAtMs = persistedTimestamp(progress.timer.startedAt);
186
+ const rawCompletedAtMs = persistedTimestamp(progress.timer.completedAt);
187
+ const completedAtMs =
188
+ startedAtMs === null || rawCompletedAtMs === null
189
+ ? null
190
+ : Math.max(startedAtMs, rawCompletedAtMs);
191
+ const hintUsed = metadata.hintUsed[puzzle.id] ?? false;
192
+ const pristine =
193
+ startedAtMs === null && !hintUsed && progress.cells.every((cell) => cell === "empty");
194
+ if (pristine) {
195
+ delete puzzleStates[puzzle.id];
196
+ continue;
197
+ }
198
+
199
+ delete puzzleStates[puzzle.id];
200
+ puzzleStates[puzzle.id] = {
201
+ cells: progress.cells.slice(),
202
+ startedAtMs,
203
+ completedAtMs,
204
+ hintUsed,
205
+ };
206
+ }
207
+
208
+ const currentPuzzleId = state.puzzles[state.activePuzzleIndex]?.id ?? baseline.currentPuzzleId;
209
+ const records: GameSettings["records"] = { ...baseline.records, ...metadata.records };
210
+ trimOldestEntries(puzzleStates, currentPuzzleId);
211
+ trimOldestEntries(records, currentPuzzleId);
212
+
213
+ return {
214
+ boardSize: baseline.boardSize,
215
+ difficulty: baseline.difficulty,
216
+ currentPuzzleId,
217
+ puzzleStates,
218
+ records,
219
+ };
220
+ }
221
+
222
+ function sameGameSettings(left: GameSettings, right: GameSettings): boolean {
223
+ if (left.currentPuzzleId !== right.currentPuzzleId) return false;
224
+ if (left.boardSize !== right.boardSize || left.difficulty !== right.difficulty) return false;
225
+
226
+ const leftPuzzleIds = Object.keys(left.puzzleStates);
227
+ const rightPuzzleIds = Object.keys(right.puzzleStates);
228
+ if (leftPuzzleIds.length !== rightPuzzleIds.length) return false;
229
+
230
+ for (const puzzleId of leftPuzzleIds) {
231
+ const leftState = left.puzzleStates[puzzleId];
232
+ const rightState = right.puzzleStates[puzzleId];
233
+ if (
234
+ leftState === undefined ||
235
+ rightState === undefined ||
236
+ leftState.startedAtMs !== rightState.startedAtMs ||
237
+ leftState.completedAtMs !== rightState.completedAtMs ||
238
+ leftState.hintUsed !== rightState.hintUsed ||
239
+ leftState.cells.length !== rightState.cells.length
240
+ ) {
241
+ return false;
242
+ }
243
+ for (let index = 0; index < leftState.cells.length; index += 1) {
244
+ if (leftState.cells[index] !== rightState.cells[index]) return false;
245
+ }
246
+ }
247
+
248
+ const leftRecordIds = Object.keys(left.records);
249
+ const rightRecordIds = Object.keys(right.records);
250
+ if (leftRecordIds.length !== rightRecordIds.length) return false;
251
+ for (const puzzleId of leftRecordIds) {
252
+ const leftRecord = left.records[puzzleId];
253
+ const rightRecord = right.records[puzzleId];
254
+ if (
255
+ leftRecord === undefined ||
256
+ rightRecord === undefined ||
257
+ leftRecord.completions !== rightRecord.completions ||
258
+ leftRecord.bestTimeMs !== rightRecord.bestTimeMs
259
+ ) {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ return true;
265
+ }
266
+
267
+ function actionableSaveError(detail?: string | null): string {
268
+ const reason = detail?.trim();
269
+ return reason
270
+ ? `Progress is not saved: ${reason} Your local game is still available. Reload saved progress to recover.`
271
+ : "Progress is not saved. Your local game is still available. Reload saved progress to recover.";
272
+ }
273
+
274
+ /**
275
+ * Owns the optimistic reducer state and the revision-aware save queue. A
276
+ * successful write must be acknowledged with synchronize() before another
277
+ * queued write starts, so every write uses the latest host revision.
278
+ */
279
+ export interface GamePersistenceController {
280
+ getSnapshot(): GamePersistenceControllerSnapshot;
281
+ subscribe(listener: () => void): () => void;
282
+ dispatch(action: GameAction): void;
283
+ synchronize(
284
+ settings: GameSettings,
285
+ revision: string,
286
+ saveError?: string | null,
287
+ now?: number,
288
+ ): void;
289
+ replace(settings: GameSettings, revision: string, now?: number): void;
290
+ dispose(): void;
291
+ setSave(save: SaveGameSettings): void;
292
+ }
293
+
294
+ export function createGamePersistenceController(
295
+ options: GamePersistenceControllerOptions,
296
+ ): GamePersistenceController {
297
+ const puzzles = options.puzzles ?? PUZZLES;
298
+ const knownSolutions = options.knownSolutions ?? EMPTY_SOLUTIONS;
299
+ let save = options.save;
300
+ let revision = options.revision;
301
+ let baseline = options.settings;
302
+ const hydrated = gameStateFromSettings(options.settings, puzzles, knownSolutions, options.now);
303
+ let hintUsed = { ...hydrated.hintUsed };
304
+ let records = { ...hydrated.records };
305
+ const metadataHistory = new Map<string, AttemptMetadata[]>();
306
+ let completionCredited: Record<string, boolean> = {};
307
+ const listeners = new Set<() => void>();
308
+ const activePuzzle = hydrated.state.puzzles[hydrated.state.activePuzzleIndex];
309
+ let snapshot: GamePersistenceControllerSnapshot = {
310
+ state: hydrated.state,
311
+ saving: false,
312
+ saveError: null,
313
+ hintUsed: activePuzzle === undefined ? false : (hintUsed[activePuzzle.id] ?? false),
314
+ };
315
+ let pending: PendingSave | null = null;
316
+ let inFlight: InFlightSave | null = null;
317
+ let awaitingRevision: string | null = null;
318
+ let awaitingValues: GameSettings | null = null;
319
+ let blocked = false;
320
+ let generation = 0;
321
+ let epoch = 0;
322
+ let disposed = false;
323
+
324
+ function activeHintUsed(state: GameState): boolean {
325
+ const puzzle = state.puzzles[state.activePuzzleIndex];
326
+ return puzzle === undefined ? false : (hintUsed[puzzle.id] ?? false);
327
+ }
328
+
329
+ function resetAttemptMetadata(settings: GameSettings): void {
330
+ completionCredited = {};
331
+ for (const puzzle of puzzles) {
332
+ completionCredited[puzzle.id] =
333
+ settings.puzzleStates[puzzle.id]?.completedAtMs !== null &&
334
+ settings.puzzleStates[puzzle.id]?.completedAtMs !== undefined;
335
+ }
336
+ }
337
+
338
+ function publish(update: Partial<GamePersistenceControllerSnapshot>): void {
339
+ const next = { ...snapshot, ...update };
340
+ if (
341
+ next.state === snapshot.state &&
342
+ next.saving === snapshot.saving &&
343
+ next.saveError === snapshot.saveError &&
344
+ next.hintUsed === snapshot.hintUsed
345
+ ) {
346
+ return;
347
+ }
348
+ snapshot = next;
349
+ for (const listener of listeners) listener();
350
+ }
351
+
352
+ function publishSaving(): void {
353
+ publish({ saving: inFlight !== null || awaitingRevision !== null });
354
+ }
355
+
356
+ function serializeCurrentState(): GameSettings {
357
+ return gameSettingsFromState(snapshot.state, { hintUsed, records }, baseline);
358
+ }
359
+
360
+ function finishSave(job: InFlightSave, saved: boolean, detail?: string): void {
361
+ if (disposed || job.epoch !== epoch || inFlight !== job) return;
362
+ inFlight = null;
363
+ if (!saved) {
364
+ if (pending === null || pending.generation < job.generation) {
365
+ pending = { generation: job.generation, values: job.values };
366
+ }
367
+ if (revision !== job.revision) {
368
+ blocked = false;
369
+ publish({ saveError: null });
370
+ pump();
371
+ return;
372
+ }
373
+ blocked = true;
374
+ publish({
375
+ saving: false,
376
+ saveError: snapshot.saveError ?? actionableSaveError(detail),
377
+ });
378
+ return;
379
+ }
380
+
381
+ baseline = job.values;
382
+ publish({ saveError: null });
383
+ if (revision === job.revision) {
384
+ awaitingRevision = job.revision;
385
+ awaitingValues = job.values;
386
+ publishSaving();
387
+ return;
388
+ }
389
+ pump();
390
+ }
391
+
392
+ function pump(): void {
393
+ if (disposed || blocked || inFlight !== null || awaitingRevision !== null || pending === null) {
394
+ publishSaving();
395
+ return;
396
+ }
397
+ const nextPending = pending;
398
+ const job: InFlightSave = { ...nextPending, epoch, revision };
399
+ pending = null;
400
+ inFlight = job;
401
+ publishSaving();
402
+ void save(job.values, job.revision).then(
403
+ (saved) => finishSave(job, saved),
404
+ (error: unknown) =>
405
+ finishSave(
406
+ job,
407
+ false,
408
+ error instanceof Error ? error.message : "The settings write failed.",
409
+ ),
410
+ );
411
+ }
412
+
413
+ function enqueueCurrentState(): void {
414
+ generation += 1;
415
+ pending = { generation, values: serializeCurrentState() };
416
+ blocked = false;
417
+ pump();
418
+ }
419
+
420
+ function updateAttemptMetadata(
421
+ previousState: GameState,
422
+ nextState: GameState,
423
+ action: GameAction,
424
+ ): void {
425
+ if (action.type === "select-puzzle" || action.type === "tick") return;
426
+ const index = previousState.activePuzzleIndex;
427
+ const puzzle = previousState.puzzles[index];
428
+ const previous = previousState.progress[index];
429
+ const next = nextState.progress[index];
430
+ if (previous === next) return;
431
+
432
+ const history = metadataHistory.get(puzzle.id) ?? [];
433
+ if (action.type === "undo") {
434
+ const restored = history.pop();
435
+ if (restored !== undefined) {
436
+ hintUsed[puzzle.id] = restored.hintUsed;
437
+ completionCredited[puzzle.id] = restored.completionCredited;
438
+ }
439
+ metadataHistory.set(puzzle.id, history);
440
+ return;
441
+ }
442
+ if (next.history.length > previous.history.length) {
443
+ history.push({
444
+ hintUsed: hintUsed[puzzle.id] ?? false,
445
+ completionCredited: completionCredited[puzzle.id] ?? false,
446
+ });
447
+ metadataHistory.set(puzzle.id, history);
448
+ }
449
+ if (action.type === "reset") {
450
+ hintUsed[puzzle.id] = false;
451
+ completionCredited[puzzle.id] = false;
452
+ } else if (action.type === "hint") {
453
+ hintUsed[puzzle.id] = true;
454
+ }
455
+ if (!previous.solved && next.solved && !completionCredited[puzzle.id]) {
456
+ completionCredited[puzzle.id] = true;
457
+ if (!(hintUsed[puzzle.id] ?? false)) {
458
+ const elapsedMs = Math.min(
459
+ Number.MAX_SAFE_INTEGER,
460
+ Math.max(0, Math.floor(next.timer.elapsedMs)),
461
+ );
462
+ const previousRecord = records[puzzle.id];
463
+ records = {
464
+ ...records,
465
+ [puzzle.id]: previousRecord
466
+ ? {
467
+ completions: Math.min(
468
+ GAME_SETTINGS_LIMITS.completionsPerPuzzle,
469
+ previousRecord.completions + 1,
470
+ ),
471
+ bestTimeMs: Math.min(previousRecord.bestTimeMs, elapsedMs),
472
+ }
473
+ : { completions: 1, bestTimeMs: elapsedMs },
474
+ };
475
+ }
476
+ }
477
+ }
478
+
479
+ function getSnapshot(): GamePersistenceControllerSnapshot {
480
+ return snapshot;
481
+ }
482
+
483
+ function subscribe(listener: () => void): () => void {
484
+ if (disposed) return NOOP_SUBSCRIPTION;
485
+ listeners.add(listener);
486
+ return () => listeners.delete(listener);
487
+ }
488
+
489
+ function dispatch(action: GameAction): void {
490
+ if (disposed) return;
491
+ const previousState = snapshot.state;
492
+ const nextState = gameReducer(previousState, action);
493
+ if (nextState === previousState) return;
494
+ updateAttemptMetadata(previousState, nextState, action);
495
+ publish({ state: nextState, hintUsed: activeHintUsed(nextState) });
496
+ if (action.type !== "tick") enqueueCurrentState();
497
+ }
498
+
499
+ function replace(settings: GameSettings, nextRevision: string, now = Date.now()): void {
500
+ if (disposed) return;
501
+ epoch += 1;
502
+ pending = null;
503
+ inFlight = null;
504
+ awaitingRevision = null;
505
+ awaitingValues = null;
506
+ blocked = false;
507
+ revision = nextRevision;
508
+ baseline = settings;
509
+ const nextHydrated = gameStateFromSettings(settings, puzzles, knownSolutions, now);
510
+ hintUsed = { ...nextHydrated.hintUsed };
511
+ records = { ...nextHydrated.records };
512
+ metadataHistory.clear();
513
+ resetAttemptMetadata(settings);
514
+ publish({
515
+ state: nextHydrated.state,
516
+ saving: false,
517
+ hintUsed: activeHintUsed(nextHydrated.state),
518
+ saveError: null,
519
+ });
520
+ }
521
+
522
+ function synchronize(
523
+ settings: GameSettings,
524
+ nextRevision: string,
525
+ saveError: string | null = null,
526
+ now = Date.now(),
527
+ ): void {
528
+ if (disposed) return;
529
+ const revisionChanged = nextRevision !== revision;
530
+ if (revisionChanged) {
531
+ const acknowledgesOwnSave =
532
+ awaitingRevision !== null &&
533
+ awaitingValues !== null &&
534
+ sameGameSettings(settings, awaitingValues);
535
+ const hasUnsavedDraft = pending !== null || inFlight !== null;
536
+ if (!acknowledgesOwnSave && !hasUnsavedDraft) {
537
+ replace(settings, nextRevision, now);
538
+ return;
539
+ }
540
+ revision = nextRevision;
541
+ baseline = settings;
542
+ blocked = false;
543
+ awaitingRevision = null;
544
+ awaitingValues = null;
545
+ if (hasUnsavedDraft) {
546
+ const nextGeneration = pending?.generation ?? inFlight?.generation ?? generation;
547
+ pending = { generation: nextGeneration, values: serializeCurrentState() };
548
+ }
549
+ publish({ saveError: null });
550
+ pump();
551
+ return;
552
+ }
553
+ if (awaitingValues === null) baseline = settings;
554
+ if (saveError !== null && saveError.trim() !== "") {
555
+ blocked = true;
556
+ publish({ saveError: actionableSaveError(saveError) });
557
+ }
558
+ }
559
+
560
+ function setSave(nextSave: SaveGameSettings): void {
561
+ save = nextSave;
562
+ }
563
+
564
+ function dispose(): void {
565
+ if (disposed) return;
566
+ disposed = true;
567
+ epoch += 1;
568
+ pending = null;
569
+ inFlight = null;
570
+ awaitingRevision = null;
571
+ awaitingValues = null;
572
+ listeners.clear();
573
+ }
574
+
575
+ resetAttemptMetadata(options.settings);
576
+ return { getSnapshot, subscribe, dispatch, synchronize, replace, dispose, setSave };
577
+ }
578
+
579
+ const CONTROLLERS_BY_HOST = new Map<string, GamePersistenceController>();
580
+ const CONTROLLER_RELEASE_DELAY_MS = 30_000;
581
+ const CONTROLLER_RELEASES = new Map<string, () => void>();
582
+ const CONTROLLER_MOUNTS = new Map<string, number>();
583
+
584
+ export function disposePersistedGames(): void {
585
+ for (const controller of CONTROLLERS_BY_HOST.values()) controller.dispose();
586
+ for (const cancelRelease of CONTROLLER_RELEASES.values()) cancelRelease();
587
+ CONTROLLER_RELEASES.clear();
588
+ CONTROLLER_MOUNTS.clear();
589
+ CONTROLLERS_BY_HOST.clear();
590
+ }
591
+
592
+ export function usePersistedGame(
593
+ hostId: string,
594
+ puzzles: readonly Puzzle[] = PUZZLES,
595
+ knownSolutions: Readonly<Record<string, PuzzleSolution>> = EMPTY_SOLUTIONS,
596
+ ): PersistedGameSession {
597
+ const settings = useSettings(gameSettings);
598
+ const settingsRef = useRef(settings);
599
+ settingsRef.current = settings;
600
+
601
+ const mounted = useRef(false);
602
+ const recoveringRef = useRef(false);
603
+ const [recovering, setRecovering] = useState(false);
604
+ const [controller, setController] = useState<GamePersistenceController | null>(
605
+ () => CONTROLLERS_BY_HOST.get(hostId) ?? null,
606
+ );
607
+ const controllerRef = useRef<GamePersistenceController | null>(null);
608
+
609
+ const save = useCallback<SaveGameSettings>(async (values, revision) => {
610
+ const current = settingsRef.current;
611
+ return current.status === "ready" ? current.save(values, revision) : false;
612
+ }, []);
613
+
614
+ useEffect(() => {
615
+ mounted.current = true;
616
+ return () => {
617
+ mounted.current = false;
618
+ controllerRef.current = null;
619
+ };
620
+ }, []);
621
+
622
+ useEffect(() => {
623
+ CONTROLLER_MOUNTS.set(hostId, (CONTROLLER_MOUNTS.get(hostId) ?? 0) + 1);
624
+ CONTROLLER_RELEASES.get(hostId)?.();
625
+ CONTROLLER_RELEASES.delete(hostId);
626
+ return () => {
627
+ const remaining = (CONTROLLER_MOUNTS.get(hostId) ?? 1) - 1;
628
+ if (remaining > 0) {
629
+ CONTROLLER_MOUNTS.set(hostId, remaining);
630
+ return;
631
+ }
632
+ CONTROLLER_MOUNTS.delete(hostId);
633
+ const timeout = setTimeout(() => {
634
+ CONTROLLER_RELEASES.delete(hostId);
635
+ CONTROLLERS_BY_HOST.get(hostId)?.dispose();
636
+ CONTROLLERS_BY_HOST.delete(hostId);
637
+ }, CONTROLLER_RELEASE_DELAY_MS);
638
+ CONTROLLER_RELEASES.set(hostId, () => clearTimeout(timeout));
639
+ };
640
+ }, [hostId]);
641
+
642
+ useEffect(() => {
643
+ if (recovering) {
644
+ if (controller !== null) {
645
+ if (CONTROLLERS_BY_HOST.get(hostId) === controller) CONTROLLERS_BY_HOST.delete(hostId);
646
+ controller.dispose();
647
+ if (controllerRef.current === controller) controllerRef.current = null;
648
+ setController(null);
649
+ }
650
+ return;
651
+ }
652
+ if (settings.status !== "ready") return;
653
+
654
+ if (controller === null) {
655
+ const existing = CONTROLLERS_BY_HOST.get(hostId);
656
+ const next =
657
+ existing ??
658
+ createGamePersistenceController({
659
+ settings: settings.values,
660
+ revision: settings.revision,
661
+ save,
662
+ puzzles,
663
+ knownSolutions,
664
+ });
665
+ CONTROLLERS_BY_HOST.set(hostId, next);
666
+ next.setSave(save);
667
+ controllerRef.current = next;
668
+ setController(next);
669
+ return;
670
+ }
671
+
672
+ controllerRef.current = controller;
673
+ controller.setSave(save);
674
+ controller.synchronize(settings.values, settings.revision, settings.saveError);
675
+ }, [controller, hostId, knownSolutions, puzzles, recovering, save, settings]);
676
+
677
+ const subscribe = useCallback(
678
+ (listener: () => void) => controller?.subscribe(listener) ?? NOOP_SUBSCRIPTION,
679
+ [controller],
680
+ );
681
+ const getSnapshot = useCallback(() => controller?.getSnapshot() ?? null, [controller]);
682
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
683
+ const dispatch = useCallback((action: GameAction) => controller?.dispatch(action), [controller]);
684
+
685
+ const beginRecovery = useCallback(() => {
686
+ recoveringRef.current = true;
687
+ const current = controllerRef.current;
688
+ if (current && CONTROLLERS_BY_HOST.get(hostId) === current) CONTROLLERS_BY_HOST.delete(hostId);
689
+ current?.dispose();
690
+ controllerRef.current = null;
691
+ setController(null);
692
+ setRecovering(true);
693
+ }, [hostId]);
694
+
695
+ const finishRecovery = useCallback(() => {
696
+ if (!mounted.current) return;
697
+ recoveringRef.current = false;
698
+ setRecovering(false);
699
+ }, []);
700
+
701
+ const reload = useCallback(async () => {
702
+ if (recoveringRef.current) return;
703
+ const operation = settingsRef.current.reload;
704
+ beginRecovery();
705
+ try {
706
+ await operation();
707
+ } finally {
708
+ finishRecovery();
709
+ }
710
+ }, [beginRecovery, finishRecovery]);
711
+
712
+ const reset = useCallback(async () => {
713
+ if (recoveringRef.current) return false;
714
+ const operation = settingsRef.current.reset;
715
+ beginRecovery();
716
+ try {
717
+ return await operation();
718
+ } finally {
719
+ finishRecovery();
720
+ }
721
+ }, [beginRecovery, finishRecovery]);
722
+
723
+ if (recovering || settings.status === "loading") {
724
+ return {
725
+ status: "loading",
726
+ saving: settings.saving,
727
+ saveError: settings.saveError,
728
+ };
729
+ }
730
+
731
+ if (settings.status === "error") {
732
+ return {
733
+ status: "error",
734
+ error: settings.error,
735
+ saving: settings.saving,
736
+ saveError: settings.saveError,
737
+ reload,
738
+ };
739
+ }
740
+
741
+ if (settings.status === "invalid") {
742
+ return {
743
+ status: "invalid",
744
+ error: settings.error,
745
+ saving: settings.saving,
746
+ saveError: settings.saveError,
747
+ reload,
748
+ reset,
749
+ };
750
+ }
751
+
752
+ if (snapshot === null || controller === null) {
753
+ return {
754
+ status: "loading",
755
+ saving: settings.saving,
756
+ saveError: settings.saveError,
757
+ };
758
+ }
759
+
760
+ return {
761
+ status: "ready",
762
+ state: snapshot.state,
763
+ hintUsed: snapshot.hintUsed,
764
+ saving: snapshot.saving,
765
+ saveError: snapshot.saveError,
766
+ dispatch,
767
+ reload,
768
+ };
769
+ }