Engine: pit-rim exits live in one helper (pitRimExits) shared by the resolver and the automaton; chargeReach names the six-stride cap; dust sight is inDust + dustAtEnd; PushPending is exported once; the force-field counter builds its events in one list; drawUnderSlowDeath says what it draws under. Test groups are named for the rule they pin, not the revision that introduced it. Client: one smoothstep; one edgeScar per battle-scarred edge; one board-zone rule (which also seats the caption strip on phones); the compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery replaces two hand-rolled matchMedia listeners; sprites carry their sizes as plain numbers and their CSS in one rule each; canvas helpers are formatted like the rest of the tree. Comments that told how a cel or a fallback used to look now say what it draws. The actions-over notice no longer blames a pickup when slime ended the turn. Server and ops: dataRoot() is the one data directory; headlineOf builds its deeds without a cast; the rollup and skills read the same way the code behaves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
59 lines
1.8 KiB
TypeScript
59 lines
1.8 KiB
TypeScript
// 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 { dataRoot } 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<string, Share>();
|
||
|
||
function sharesFile(): string {
|
||
return join(dataRoot(), "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);
|
||
}
|