anvilsign in

collin/mahjong

1import type { GameState } from './engine';
2import { NUM_BASIC, WALL_SIZE } from './tiles';
3
4const KEY = 'taiwanese-mahjong/save';
5/** Bump when GameState changes shape; older saves are then ignored rather than crashing. */
6const VERSION = 4;
7
8export interface SavedGame {
9 version: number;
10 savedAt: number;
11 state: GameState;
12}
13
14/**
15 * The game state is plain data, so a save is just its JSON. Written after every
16 * move, so a closed lid or a stray refresh costs nothing.
17 */
18export function saveGame(state: GameState) {
19 if (state.phase === 'lobby') return;
20 try {
21 const payload: SavedGame = { version: VERSION, savedAt: Date.now(), state };
22 localStorage.setItem(KEY, JSON.stringify(payload));
23 } catch {
24 // Storage unavailable or full — play carries on unsaved rather than dying.
25 }
26}
27
28export function loadSave(): SavedGame | null {
29 try {
30 const raw = localStorage.getItem(KEY);
31 if (!raw) return null;
32 const parsed = JSON.parse(raw) as SavedGame;
33 if (parsed?.version !== VERSION) return null;
34 if (!isPlausible(parsed.state)) return null;
35 return parsed;
36 } catch {
37 return null;
38 }
39}
40
41export function clearSave() {
42 try {
43 localStorage.removeItem(KEY);
44 } catch {
45 // nothing to do
46 }
47}
48
49/** Guards against a truncated or hand-edited save putting the table in a bad state. */
50function isPlausible(s: GameState | undefined): boolean {
51 if (!s || !Array.isArray(s.players) || s.players.length !== 4) return false;
52 if (!Array.isArray(s.wall) || typeof s.phase !== 'string') return false;
53 if (s.phase === 'lobby') return false;
54 if (!Array.isArray(s.sacred) || s.sacred.length !== 4) return false;
55 if (!Array.isArray(s.bots) || s.bots.length !== 4) return false;
56 if (!s.rules || typeof s.rules.base !== 'number') return false;
57
58 let tiles = s.wall.length;
59 for (const p of s.players) {
60 if (!p || !Array.isArray(p.hand) || !Array.isArray(p.melds)) return false;
61 tiles += p.hand.length + p.flowers.length + p.discards.length;
62 for (const m of p.melds) tiles += m.tiles.length;
63 }
64 if (tiles !== WALL_SIZE) return false;
65 return s.players.every((p) => p.hand.every((t) => t >= 0 && t < NUM_BASIC + 8));
66}