// Share links: a minted slug names one turn of one game, and anyone // holding it may watch that turn — spectator-redacted, nothing else of // the game visible. Shares persist beside the rooms and survive restarts. import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { randomInt } from "node:crypto"; import { statsDir } from "./store"; export interface Share { id: string; roomId: string; turn: number; createdAt: string; } const SHARE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable const shares = new Map(); function sharesFile(): string { return join(statsDir(), "shares.jsonl"); } export function loadShares(): void { const file = sharesFile(); if (!existsSync(file)) return; for (const line of readFileSync(file, "utf8").trim().split("\n")) { if (!line) continue; try { const s = JSON.parse(line) as Share; if (s.id) shares.set(s.id, s); } catch { // A torn tail line loses one share, never the server. } } } export function mintShare(roomId: string, turn: number): Share { // One link per (room, turn): sharing the same turn twice hands back // the same slug, so a re-share never splits an audience. for (const s of shares.values()) { if (s.roomId === roomId && s.turn === turn) return s; } let id = ""; for (let i = 0; i < SHARE_ID_LENGTH; i++) { id += SHARE_ALPHABET[randomInt(SHARE_ALPHABET.length)]; } const share: Share = { id, roomId, turn, createdAt: new Date().toISOString() }; shares.set(id, share); appendFileSync(sharesFile(), JSON.stringify(share) + "\n", "utf8"); return share; } export function getShare(id: string): Share | undefined { return shares.get(id); }