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:
co-authored by
Claude Fable 5
parent
7c201554f5
commit
6c48174376
@@ -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=<name>: 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 @@
|
||||
|
||||
<svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
|
||||
|
||||
{#if demoReel}
|
||||
{#if scriptError}
|
||||
<pre class="script-error">{scriptError}</pre>
|
||||
{:else if scriptPlay}
|
||||
<Replay steps={scriptSteps} pov={scriptPlay.pov} onclose={() => (location.search = "?fpv")} />
|
||||
{:else if demoReel}
|
||||
<Replay steps={demoSteps} onclose={() => (location.search = "?fpv")} />
|
||||
{/if}
|
||||
|
||||
@@ -227,6 +249,8 @@
|
||||
stranger things do not — this workshop has eyes, not rules. Seed with
|
||||
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>,
|
||||
or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁).
|
||||
<code>&script=name</code> performs an authored screenplay — the engine
|
||||
plays it, the reel films it, the same footage every run.
|
||||
<code>&aim=1</code> arms a pretend cast — sighted ground lights, marked
|
||||
beings wear the ember ring — and <code>&rival=x,y</code> poses the Rival; <code>&wear=sticky-web;fear</code> dresses them in ongoing spells.
|
||||
</p>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 `<cardId>#rig<n>`. */
|
||||
rig?: Record<string, string[]>;
|
||||
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<string, number> = {};
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user