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:
Eric Wagoner
2026-08-23 17:34:00 -04:00
co-authored by Claude Fable 5
parent 727b9144d0
commit 4838996cf0
10 changed files with 569 additions and 8 deletions
+118
View File
@@ -58,6 +58,8 @@ import {
type Room,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. -----------------
@@ -80,6 +82,7 @@ const port = Number(process.env.PORT ?? 8787);
// is not reachable from the internet. Dev default stays LAN-friendly.
const host = process.env.HOST ?? "0.0.0.0";
loadPersistedRooms();
loadShares();
// A restart can land mid-bot-turn: without a kick, a restored room whose
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
@@ -97,6 +100,74 @@ const MIME: Record<string, string> = {
// The client build may be absent in development (vite serves it instead);
// serve a 404 for static requests in that case rather than dying on boot.
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
// --- Share pages: one turn, rebuilt for the nameless viewer. ---------------
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string;
round: number;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
const hit = shareCache.get(id);
if (hit && Date.now() - hit.at < 30_000) return hit.data;
let data: ShareData | null = null;
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
const steps = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in steps) && steps.length > 0) {
let actor: string = steps[steps.length - 1]!.actor;
let round = 0;
outer: for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
actor = e.player;
if (e.type === "turnStarted") round = e.round;
break outer;
}
}
}
data = { steps: steps as unknown as ShareData["steps"], actor, round };
}
}
if (shareCache.size > 50) shareCache.clear();
shareCache.set(id, { at: Date.now(), data });
return data;
}
const escapeHtml = (t: string) =>
t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** index.html with this share's OpenGraph card folded into its head —
* crawlers never run the app, so the unfurl must arrive pre-baked. */
function shareHtml(id: string, data: ShareData, host: string, proto: string): string {
// The stock page carries its own generic card; strip it, or crawlers
// (which take the FIRST tag they meet) never see this turn's.
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`;
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/watch/${id}"/>`,
`<meta property="og:image" content="${base}/watch/${id}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
].join("\n ");
return html.replace("</head>", ` ${metas}\n </head>`);
}
const httpServer = createServer((req, res) => {
try {
if (req.method !== "GET" && req.method !== "HEAD") {
@@ -119,6 +190,36 @@ const httpServer = createServer((req, res) => {
res.writeHead(400).end();
return;
}
// Share pages: the turn's data, its card image, and its chrome.
const api = url.match(/^\/api\/share\/([a-z0-9]{4,20})$/);
if (api) {
const data = shareData(api[1]!);
if (!data) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"no such replay"}'); return; }
res.writeHead(200, {
"content-type": "application/json",
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
if (watch) {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
const last = data.steps[data.steps.length - 1]!;
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
}
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
@@ -508,6 +609,23 @@ wss.on("connection", (socket) => {
send(socket, { type: "catchUp", steps });
break;
}
case "share": {
// Mint (or re-find) the public link for one turn's replay.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, turn);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
}
case "moment": {
// A chronicle line's instant-replay eye: one turn's reel.
const room = session.roomId ? getRoom(session.roomId) : undefined;