Screenplays: authored scenes the engine performs and the reel films

?fpv&script=<name> plays a screenplay — a seeded deal, cards slipped
into hands as props, then a command list run through applyCommand, so
every event on screen is a legal move under the real rules. Ten scenes
ship in the catalog: wormhole heists, a fireball threaded through a
warp, an ambush that watches its corridor through a mouth, the leap
over a conjured pit, and the wall between two homes coming down.

The server grows a public gallery to hang them in: /clips lists the
catalog, /clips/<name> pages each scene in two takes — through the
wizard's eyes and from the board — with og:video unfurls and
Range-served mp4s (Safari refuses video without it). Files live in
the clip vault beside the rooms, shipped by deploy/clips-publish.sh;
the name gate is the security.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-09-01 13:16:01 -04:00
co-authored by Claude Fable 5
parent 7c201554f5
commit 6c48174376
6 changed files with 561 additions and 3 deletions
+106
View File
@@ -0,0 +1,106 @@
// The public clip gallery: authored screenplay scenes recorded through
// the reel, each in two takes — through the wizard's eyes and from the
// board above. Standalone pages, no app bundle: a clip link must play
// anywhere a browser lands, and unfurl anywhere it's pasted.
import type { ClipMeta } from "./store.js";
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const PAGE_CSS = `
* { box-sizing: border-box; }
body { margin: 0; background: #171a21; color: #d6d2c4;
font-family: Georgia, "Times New Roman", serif; }
a { color: #e8c87a; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
h1 { font-size: 1.6rem; letter-spacing: 0.04em; margin: 0 0 0.3rem; }
h1 a { color: inherit; text-decoration: none; }
p.sub { color: #8b8778; margin: 0 0 1.6rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.4rem; }
.card { background: #1f232d; border: 1px solid #2c3140; border-radius: 8px;
overflow: hidden; text-decoration: none; color: inherit; display: block; }
.card img { width: 100%; display: block; aspect-ratio: 736 / 577; object-fit: cover; }
.card .meta { padding: 0.7rem 0.9rem 0.9rem; }
.card .t { color: #e8c87a; letter-spacing: 0.03em; }
.card .b { font-size: 0.9rem; line-height: 1.45; margin-top: 0.3rem; color: #b5b0a1; }
.card .s { float: right; color: #8b8778; font-size: 0.85rem; }
video { width: 100%; border-radius: 6px; display: block; background: #000; }
.takes { display: grid; gap: 1.5rem; margin-top: 1.4rem; }
.take h2 { font-size: 1.05rem; color: #e8c87a; font-weight: normal;
letter-spacing: 0.04em; margin: 0 0 0.5rem; }
.foot { margin-top: 2.2rem; color: #8b8778; font-size: 0.95rem; }
`;
const shell = (metas: string[], body: string) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${metas.join("\n")}
<style>${PAGE_CSS}</style>
</head>
<body><div class="wrap">${body}</div></body>
</html>`;
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
const metas = [
`<title>Wiz-War — clips from the labyrinth</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
`<meta property="og:url" content="${base}/clips"/>`,
clips[0] ? `<meta property="og:image" content="${base}/clips/${clips[0].name}.jpg"/>` : "",
`<meta name="twitter:card" content="summary_large_image"/>`,
].filter(Boolean);
const cards = clips.map((c) => `
<a class="card" href="/clips/${c.name}">
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
<div class="meta">
<span class="s">${c.seconds}s</span>
<span class="t">${esc(c.title)}</span>
<div class="b">${esc(c.blurb)}</div>
</div>
</a>`).join("\n");
const body = `
<h1>Clips from the labyrinth</h1>
<p class="sub">Scenes of Wiz-War, played by the rules and filmed through the wizards' own eyes.
Each clip has a first-person take and a board take of the same moves.</p>
<div class="grid">${cards}</div>
<p class="foot">Wiz-War is Tom Jolly's classic of magical combat.
This is a fan-built table — <a href="/">deal yourself in</a>.</p>`;
return shell(metas, body);
}
export function clipPageHtml(clip: ClipMeta, base: string): string {
const metas = [
`<title>${esc(clip.title)} — a Wiz-War clip</title>`,
`<meta property="og:type" content="video.other"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${esc(clip.title)}"/>`,
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
`<meta property="og:image" content="${base}/clips/${clip.name}.jpg"/>`,
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
`<meta property="og:video:type" content="video/mp4"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/clips/${clip.name}.jpg"/>`,
];
const body = `
<h1><a href="/clips">Clips from the labyrinth</a></h1>
<p class="sub">${esc(clip.title)}${esc(clip.blurb)}</p>
<div class="takes">
<div class="take">
<h2>Through the wizard's eyes</h2>
<video src="/clips/${clip.name}-fpv.mp4" poster="/clips/${clip.name}.jpg" controls playsinline></video>
</div>
<div class="take">
<h2>The same scene, from the board</h2>
<video src="/clips/${clip.name}-board.mp4" controls playsinline preload="metadata"></video>
</div>
</div>
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
<a href="/">Deal yourself in</a>.</p>`;
return shell(metas, body);
}
+57 -2
View File
@@ -33,7 +33,7 @@
import * as Sentry from "@sentry/node";
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync } from "node:fs";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine";
@@ -65,7 +65,8 @@ import {
abandonRoom,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback } from "./store";
import { appendFeedback, readFeedback, readClips, clipAssetPath } from "./store";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
@@ -302,6 +303,60 @@ const httpServer = createServer((req, res) => {
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
// The clip gallery: standalone pages and their media. Video ships
// with Range support — Safari refuses an mp4 whose server can't
// serve bytes 0-1 on demand.
if (url === "/clips" || url === "/clips/") {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipsIndexHtml(readClips(), base));
return;
}
const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/);
if (clipAsset) {
const path = clipAssetPath(clipAsset[1]!);
if (!path) { res.writeHead(404).end("no such clip"); return; }
const size = statSync(path).size;
const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg";
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
const head: Record<string, string> = {
"content-type": type,
"accept-ranges": "bytes",
"cache-control": "public, max-age=3600",
};
if (range && (range[1] || range[2])) {
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
if (start > end || start >= size) {
res.writeHead(416, { "content-range": `bytes */${size}` }).end();
return;
}
res.writeHead(206, { ...head,
"content-range": `bytes ${start}-${end}/${size}`,
"content-length": String(end - start + 1) });
if (req.method === "HEAD") { res.end(); return; }
createReadStream(path, { start, end }).pipe(res);
return;
}
res.writeHead(200, { ...head, "content-length": String(size) });
if (req.method === "HEAD") { res.end(); return; }
createReadStream(path).pipe(res);
return;
}
const clipPage = url.match(/^\/clips\/([a-z0-9-]{1,60})$/);
if (clipPage) {
const clip = readClips().find((c) => c.name === clipPage[1]);
if (clip) {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipPageHtml(clip, base));
return;
}
res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips");
return;
}
// Room invitations: a living room gets its recruiting card; a dead
// code falls through to the app, which reports it in the lobby.
const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
+36
View File
@@ -216,3 +216,39 @@ export function archiveRoomFile(roomId: string): void {
mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
}
// --- The clip vault. ------------------------------------------------------
// Showcase clips live beside the rooms as plain files — <slug>-fpv.mp4,
// <slug>-board.mp4, <slug>.jpg — described by clips.json, all published by
// deploy/clips-publish.sh. The server only ever reads them.
export interface ClipMeta {
name: string;
title: string;
blurb: string;
seconds: number;
}
const clipsDir = () => join(DATA_DIR, "..", "clips");
export function readClips(): ClipMeta[] {
const file = join(clipsDir(), "clips.json");
if (!existsSync(file)) return [];
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[];
return parsed.filter((c) => /^[a-z0-9-]{1,60}$/.test(c.name));
} catch {
return [];
}
}
/** Resolve a clip asset request to its path — or null for any name that
* is not exactly a published clip file shape. The gate IS the security:
* nothing outside <slug>(-fpv|-board).mp4 / <slug>.jpg can be named. */
export function clipAssetPath(file: string): string | null {
if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}\.jpg$/.test(file)) {
return null;
}
const path = join(clipsDir(), file);
return existsSync(path) ? path : null;
}