diff --git a/deploy/clips-publish.sh b/deploy/clips-publish.sh
new file mode 100644
index 0000000..c0f1ce4
--- /dev/null
+++ b/deploy/clips-publish.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Publish showcase clips to the droplet's clip vault (/var/lib/wizwar/clips).
+# The gallery at /clips serves whatever this ships: -fpv.mp4,
+# -board.mp4, .jpg per clip, described by clips.json.
+set -euo pipefail
+host=${1:?usage: clips-publish.sh }
+dir=${2:?usage: clips-publish.sh }
+test -f "$dir/clips.json" || { echo "no clips.json in $dir" >&2; exit 1; }
+ssh "root@$host" 'mkdir -p /var/lib/wizwar/clips'
+scp "$dir"/*.mp4 "$dir"/*.jpg "$dir/clips.json" "root@$host:/var/lib/wizwar/clips/"
+count=$(ls "$dir"/*-fpv.mp4 | wc -l | tr -d ' ')
+echo "published $count clips to $host"
diff --git a/packages/server/src/clips.ts b/packages/server/src/clips.ts
new file mode 100644
index 0000000..4357637
--- /dev/null
+++ b/packages/server/src/clips.ts
@@ -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, "&").replace(//g, ">").replace(/"/g, """);
+
+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) => `
+
+
+
+
+${metas.join("\n")}
+
+
+${body}
+`;
+
+export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
+ const metas = [
+ `Wiz-War — clips from the labyrinth`,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ clips[0] ? `` : "",
+ ``,
+ ].filter(Boolean);
+ const cards = clips.map((c) => `
+
+
+
+ `).join("\n");
+ const body = `
+ Clips from the labyrinth
+ 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.
+ ${cards}
+ `;
+ return shell(metas, body);
+}
+
+export function clipPageHtml(clip: ClipMeta, base: string): string {
+ const metas = [
+ `${esc(clip.title)} — a Wiz-War clip`,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ];
+ const body = `
+
+ ${esc(clip.title)} — ${esc(clip.blurb)}
+
+
+
Through the wizard's eyes
+
+
+
+
The same scene, from the board
+
+
+
+ `;
+ return shell(metas, body);
+}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 82084ab..e25b942 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -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 = {
+ "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})$/);
diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts
index f26f1bc..fc2cbe4 100644
--- a/packages/server/src/store.ts
+++ b/packages/server/src/store.ts
@@ -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 — -fpv.mp4,
+// -board.mp4, .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 (-fpv|-board).mp4 / .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;
+}
diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte
index 3f68bc5..e212480 100644
--- a/packages/web/src/fpv/FpvWorkshop.svelte
+++ b/packages/web/src/fpv/FpvWorkshop.svelte
@@ -8,6 +8,7 @@
import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte";
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
+ import { buildScreenplaySteps, screenplayByName, SCREENPLAYS, type ScreenplayStep } from "./screenplays";
const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42);
@@ -198,6 +199,23 @@
}
const demoSteps = demoReel ? buildDemoSteps() : [];
+ // ?script=: an authored screenplay, performed by the engine and
+ // filmed by the reel — the same footage every run. An unknown name or
+ // a refused move reports itself instead of a blank stage.
+ const scriptName = q.get("script");
+ const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
+ let scriptSteps: ScreenplayStep[] = [];
+ let scriptError: string | null = null;
+ if (scriptName && !scriptPlay) {
+ scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`;
+ } else if (scriptPlay) {
+ try {
+ scriptSteps = buildScreenplaySteps(scriptPlay);
+ } catch (e) {
+ scriptError = e instanceof Error ? e.message : String(e);
+ }
+ }
+
// Minimap geometry (top-down, one small square per cell).
const MM = 9;
const cells = Object.keys(view.board.cells).map((k) => {
@@ -215,7 +233,11 @@
onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
-{#if demoReel}
+{#if scriptError}
+ {scriptError}
+{:else if scriptPlay}
+ (location.search = "?fpv")} />
+{:else if demoReel}
(location.search = "?fpv")} />
{/if}
@@ -227,6 +249,8 @@
stranger things do not — this workshop has eyes, not rules. Seed with
?fpv&seed=N, stand anywhere with &x=&y=&dir=,
or watch the clockwork's reel with &demo=1 (toggle 👁).
+ &script=name performs an authored screenplay — the engine
+ plays it, the reel films it, the same footage every run.
&aim=1 arms a pretend cast — sighted ground lights, marked
beings wear the ember ring — and &rival=x,y poses the Rival; &wear=sticky-web;fear dresses them in ongoing spells.
@@ -268,4 +292,13 @@
.mm-cell { fill: #1d212b; stroke: #262b36; stroke-width: 0.5; }
.mm-wall { stroke: #9a927c; stroke-width: 1.6; stroke-linecap: square; }
.mm-eye { fill: #e0b34a; }
+ .script-error {
+ margin: 1rem 2rem;
+ padding: 1rem;
+ background: #3a1215;
+ color: #f0c0c0;
+ border: 1px solid #7a2a30;
+ border-radius: 6px;
+ white-space: pre-wrap;
+ }
diff --git a/packages/web/src/fpv/screenplays.ts b/packages/web/src/fpv/screenplays.ts
new file mode 100644
index 0000000..64215d4
--- /dev/null
+++ b/packages/web/src/fpv/screenplays.ts
@@ -0,0 +1,316 @@
+// Authored scenes, performed by the real engine and filmed by the reel.
+// A screenplay deals a seeded game, slips the named cards into hands (a
+// film set may rig its props), then runs its command list through
+// applyCommand — every event on screen really happened under the rules.
+// A command the engine refuses is an authoring error and says so loudly.
+import {
+ applyCommand, createGame, viewFor,
+ type Command, type GameEvent, type GameState, type GameView,
+} from "@wizwar/engine";
+
+export interface ScreenplayMove {
+ seat: string;
+ cmd: Command;
+}
+
+export interface Screenplay {
+ /** URL slug and clip filename: lowercase kebab. */
+ name: string;
+ title: string;
+ /** One gallery sentence: what the clip shows off. */
+ blurb: string;
+ players: string[];
+ seed: number;
+ /** Whose eyes the reel opens behind (the camera still follows each
+ * turn's owner, as the reel always does). */
+ pov: string;
+ /** Cards slipped into hands before the action: seat → cardIds. Rigged
+ * instanceIds are `#rig`. */
+ rig?: Record;
+ moves: ScreenplayMove[];
+}
+
+export interface ScreenplayStep {
+ seq: number;
+ actor: string;
+ events: GameEvent[];
+ view: GameView;
+}
+
+/** The card a rig entry minted, for use in the same screenplay's moves. */
+export function rigId(cardId: string, n = 1): string {
+ return `${cardId}#rig${n}`;
+}
+
+export function buildScreenplaySteps(sp: Screenplay): ScreenplayStep[] {
+ const { state: dealt } = createGame({
+ playerIds: sp.players,
+ seed: sp.seed,
+ sets: ["basic", "expansion1"],
+ });
+ let s: GameState = dealt;
+ for (const [seat, cardIds] of Object.entries(sp.rig ?? {})) {
+ const p = s.players.find((q) => q.id === seat);
+ if (!p) throw new Error(`screenplay ${sp.name}: rig names unknown seat ${seat}`);
+ const counts: Record = {};
+ for (const cardId of cardIds) {
+ counts[cardId] = (counts[cardId] ?? 0) + 1;
+ p.hand.push({ instanceId: rigId(cardId, counts[cardId]!), cardId });
+ }
+ }
+ const steps: ScreenplayStep[] = [];
+ sp.moves.forEach((m, i) => {
+ const r = applyCommand(s, m.seat, m.cmd);
+ if (!r.ok) {
+ throw new Error(
+ `screenplay ${sp.name}: move ${i} (${m.seat} ${m.cmd.type}) refused — ${r.error}`);
+ }
+ s = r.state;
+ steps.push({
+ seq: i,
+ actor: m.seat,
+ // The reel is omniscient cinema — no fog, no redaction — but what
+ // was dealt or drawn stays off the record even here.
+ events: r.events.filter((e) => !e.type.endsWith("Private")),
+ view: viewFor(s, sp.pov),
+ });
+ });
+ return steps;
+}
+
+// ---------------------------------------------------------------------------
+// The catalog. All scenes play on the two-wizard seed-42 board:
+// homes at (2,2) and (2,7); rim warps (2,0)N↔(2,9)S, (0,2)W↔(4,7)E,
+// (4,2)E↔(0,7)W — the last pair guarding a chamber no corridor reaches.
+// ---------------------------------------------------------------------------
+
+const DUEL = { players: ["Wanderer", "Rival"], seed: 42 };
+
+/** Rival's road from home (2,7) to the south warp mouth: five strides
+ * ending through the wormhole onto (2,0), in the enemy's sightline. */
+const RIVAL_TO_NORTH: ScreenplayMove[] = [
+ { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-2") } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "E" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } }, // through the warp
+];
+
+/** Round one bars combat, so every duel opens as a standoff: Rival ends
+ * his marching turn at the north mouth, Wanderer backs two squares down
+ * his corridor — still in the sightline — and round two begins. */
+const STANDOFF: ScreenplayMove[] = [
+ ...RIVAL_TO_NORTH,
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+];
+
+export const SCREENPLAYS: Screenplay[] = [
+ {
+ ...DUEL,
+ name: "wormhole-heist",
+ title: "The Wormhole Heist",
+ blurb: "Nine strides on a number card: through the south mouth, around the back row, out with a treasure.",
+ pov: "Rival",
+ rig: { Rival: ["number-6"] },
+ moves: [
+ { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-6") } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "E" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } }, // through the warp
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "E" } },
+ { seat: "Rival", cmd: { type: "pickUpTreasure" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "warp-sniper",
+ title: "The Warp Sniper",
+ blurb: "Line of sight threads a wormhole: a fireball cast into one mouth arrives through the other.",
+ pov: "Wanderer",
+ rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] },
+ moves: [
+ { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-3") } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "N" } },
+ { seat: "Rival", cmd: { type: "move", direction: "N" } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } }, // parked on the west mouth
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "playNumberForMovement", instanceId: rigId("number-3") } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "E" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "N" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "N" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "E" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } }, // the east mouth's chamber
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // waits at the far mouth
+ { seat: "Wanderer", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Rival" } } },
+ { seat: "Rival", cmd: { type: "pass" } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "springing-the-trap",
+ title: "Springing the Trap",
+ blurb: "An ambush watches its corridor through a wormhole — and fires the moment a wizard crosses the far sightline.",
+ pov: "Rival",
+ rig: { Wanderer: ["opportunity-fire", "fireball"] },
+ moves: [
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // one square shy of the sightline
+ { seat: "Wanderer", cmd: { type: "setAmbush", instanceId: rigId("opportunity-fire"), trigger: { kind: "los" }, spellInstanceId: rigId("fireball") } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "move", direction: "E" } }, // into the corridor the wormhole watches
+ { seat: "Rival", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "move", direction: "W" } }, // singed, back out of the light
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "the-leap",
+ title: "The Leap",
+ blurb: "A pit yawns in the corridor — but the die says the intruder sails clean over it, onto the trapper's doorstep.",
+ pov: "Rival",
+ rig: { Wanderer: ["create-pit"] },
+ moves: [
+ { seat: "Rival", cmd: { type: "move", direction: "W" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "E" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "cast", instanceId: rigId("create-pit"), target: { kind: "cell", cell: { x: 2, y: 1 } } } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } },
+ { seat: "Rival", cmd: { type: "move", direction: "S" } }, // through the warp
+ { seat: "Rival", cmd: { type: "move", direction: "S" } }, // the pit — and the leap
+ { seat: "Rival", cmd: { type: "punch", targetId: "Wanderer" } }, // landing fists first
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "come-here",
+ title: "The Puppeteer",
+ blurb: "Mental Force walks the enemy to exactly where you want them — which has its risks.",
+ pov: "Wanderer",
+ rig: { Wanderer: ["mental-force"], Rival: ["number-2"] },
+ moves: [
+ ...RIVAL_TO_NORTH,
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // glowers from the mouth
+ { seat: "Wanderer", cmd: { type: "cast", instanceId: rigId("mental-force"), target: { kind: "player", playerId: "Rival" }, params: { cell: { x: 2, y: 2 } } } },
+ { seat: "Rival", cmd: { type: "pass" } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "punch", targetId: "Wanderer" } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "stone-gaze",
+ title: "The Stone Gaze",
+ blurb: "Medusa freezes a wizard solid — and stone, inconveniently, cannot be hurt.",
+ pov: "Rival",
+ rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] },
+ moves: [
+ ...STANDOFF,
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("medusa"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } }, // stone does not move
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "the-rout",
+ title: "The Rout",
+ blurb: "Go Away hurls a wizard down his own corridor, as straight a line as the maze allows.",
+ pov: "Rival",
+ rig: { Rival: ["number-2", "go-away", "number-5"] },
+ moves: [
+ ...STANDOFF,
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("go-away"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "wall-comes-down",
+ title: "Knock Knock",
+ blurb: "Blast the wall between you — the rubble bloodies both sides of it — then step through the breach.",
+ pov: "Wanderer",
+ rig: { Wanderer: ["number-2", "destroy-wall"] },
+ moves: [
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // stands his ground at home
+ { seat: "Wanderer", cmd: { type: "playNumberForMovement", instanceId: rigId("number-2") } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } }, // at (2,6), the wall between them
+ { seat: "Wanderer", cmd: { type: "cast", instanceId: rigId("destroy-wall"), target: { kind: "edge", cell: { x: 2, y: 6 }, side: "S" } } },
+ { seat: "Wanderer", cmd: { type: "move", direction: "S" } }, // through the breach
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Rival", cmd: { type: "punch", targetId: "Wanderer" } }, // the host objects
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "shield-holds",
+ title: "The Shield Holds",
+ blurb: "Five points of fire meet a Full Shield, and the shield wins.",
+ pov: "Wanderer",
+ rig: { Rival: ["number-2", "fireball"], Wanderer: ["full-shield"] },
+ moves: [
+ ...STANDOFF,
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "counteract", instanceId: rigId("full-shield") } },
+ { seat: "Rival", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+ {
+ ...DUEL,
+ name: "fire-in-the-webs",
+ title: "Fire in the Webs",
+ blurb: "The Sticky Wand binds a wizard in silk — and fire burns twice as hot in a web.",
+ pov: "Rival",
+ rig: { Rival: ["number-2", "sticky-wand", "fireball"] },
+ moves: [
+ ...STANDOFF,
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("sticky-wand"), target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ { seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } }, // three fewer steps, nowhere to run
+ { seat: "Rival", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Wanderer" } } },
+ { seat: "Wanderer", cmd: { type: "pass" } },
+ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
+ ],
+ },
+];
+
+export function screenplayByName(name: string): Screenplay | null {
+ return SCREENPLAYS.find((sp) => sp.name === name) ?? null;
+}