Files
wizwar6e/packages/server/src/shares.ts
T
Eric WagonerandClaude Fable 5 4838996cf0 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>
2026-08-23 17:34:00 -04:00

59 lines
1.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}