Share a turn with the world: /watch links, cards and chrome included
The share button lands on the instant replay. One click mints a slug — persistent, unguessable, one per (room, turn) — and copies a /watch link anyone can open: the server rebuilds exactly that turn for the nameless SPECTATOR, public knowledge only, none of the rest of the game visible. The page arrives with its OpenGraph card pre-baked into the head (crawlers never run the app): the turn's title and round in the text, and an og.png of the maze itself — cells, walls, doors, warp mouths, homes, standees — painted server-side and PNG-encoded with nothing but node's own zlib. The viewer page wears its chrome: the WIZ-WAR masthead linking home, the turn's title, the reel opening straight into the turn-owner's eyes with a "watch it again" loop, and a footer that says what this table is and deals the visitor in. The share page loads as its own entry so a shared link never drags the full app's socket appetite along; the reel's modal scrim learns to stand in a page instead of over one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
727b9144d0
commit
4838996cf0
@@ -0,0 +1,58 @@
|
||||
// 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<string, Share>();
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user