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
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** 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;
|
||||
|
||||
Reference in New Issue
Block a user