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) => ` + + ${esc(c.title)} +
+ ${c.seconds}s + ${esc(c.title)} +
${esc(c.blurb)}
+
+
`).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}
+

Wiz-War is Tom Jolly's classic of magical combat. + This is a fan-built table — deal yourself in.

`; + return shell(metas, body); +} + +export function clipPageHtml(clip: ClipMeta, base: string): string { + const metas = [ + `${esc(clip.title)} — a Wiz-War clip`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]; + const body = ` +

Clips from the labyrinth

+

${esc(clip.title)} — ${esc(clip.blurb)}

+
+
+

Through the wizard's eyes

+ +
+
+

The same scene, from the board

+ +
+
+

Played by the real rules engine — every move on screen is a legal move. + Deal yourself in.

`; + 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; +}