The eleventh scene, written for the new conjurations. Wanderer fills the corridor between them with a dust cloud — no sight, no spells — so Rival lays a dimensional warp with one mouth under his own feet and the other at her back, steps through the light, and lands on her square fists first. She bolts into her own cloud, and the die picks where she goes: into a wall. Three beats through the eyes — the cloud billowing, the arrival, the blind stagger with the far pillar of light at the corridor's end — and the board at the arrival. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
374 lines
17 KiB
TypeScript
374 lines
17 KiB
TypeScript
// 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[];
|
|
/** Step indices the render harness pins as golden frames, first-person:
|
|
* the scene's money shots — a warp transit, an impact, a curse landing.
|
|
* deploy/verify-scenes.sh screenshots each beat and diffs it against
|
|
* the committed golden. */
|
|
beats?: number[];
|
|
/** One step pinned from the board view. */
|
|
boardBeat?: number;
|
|
}
|
|
|
|
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"] },
|
|
beats: [5, 10],
|
|
boardBeat: 5,
|
|
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"] },
|
|
beats: [6, 16],
|
|
boardBeat: 16,
|
|
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"] },
|
|
beats: [5, 6],
|
|
boardBeat: 5,
|
|
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"] },
|
|
beats: [8, 9],
|
|
boardBeat: 8,
|
|
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"] },
|
|
beats: [10, 13],
|
|
boardBeat: 10,
|
|
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"] },
|
|
beats: [10, 14],
|
|
boardBeat: 10,
|
|
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"] },
|
|
beats: [4, 11],
|
|
boardBeat: 11,
|
|
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"] },
|
|
beats: [6, 7],
|
|
boardBeat: 6,
|
|
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"] },
|
|
beats: [11, 12],
|
|
boardBeat: 11,
|
|
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"] },
|
|
beats: [11, 14],
|
|
boardBeat: 11,
|
|
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 } },
|
|
],
|
|
},
|
|
{
|
|
...DUEL,
|
|
name: "dust-and-light",
|
|
title: "Dust and Light",
|
|
blurb: "A dust cloud hides her from his spells — so he throws a warp behind her, steps through the light, and she flees blind into her own dust.",
|
|
pov: "Rival",
|
|
rig: { Rival: ["number-2", "dimensional-warp"], Wanderer: ["dust-cloud"] },
|
|
beats: [12, 14, 20],
|
|
boardBeat: 14,
|
|
moves: [
|
|
...STANDOFF,
|
|
// Round two. Rival lays a warp: one mouth under his own feet at the
|
|
// north mouth, the other at Wanderer's back, and waits a turn.
|
|
{ seat: "Rival", cmd: { type: "cast", instanceId: rigId("dimensional-warp"), target: { kind: "cell", cell: { x: 2, y: 5 } }, params: { cell: { x: 2, y: 0 } } } },
|
|
{ seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
|
|
// Wanderer fills the corridor between them with dust: no sight, no spells.
|
|
{ seat: "Wanderer", cmd: { type: "cast", instanceId: rigId("dust-cloud"), target: { kind: "cell", cell: { x: 2, y: 3 } } } },
|
|
{ seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
|
|
// Round three. Through the light, onto her square, fists first.
|
|
{ seat: "Rival", cmd: { type: "warpStep" } },
|
|
{ seat: "Rival", cmd: { type: "move", direction: "N" } },
|
|
{ seat: "Rival", cmd: { type: "punch", targetId: "Wanderer" } },
|
|
{ seat: "Wanderer", cmd: { type: "pass" } },
|
|
{ seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
|
|
// She bolts into her own cloud — and the die picks where she goes.
|
|
{ seat: "Wanderer", cmd: { type: "move", direction: "N" } },
|
|
{ seat: "Wanderer", cmd: { type: "move", direction: "N" } },
|
|
{ seat: "Wanderer", cmd: { type: "endTurn", draw: 0 } },
|
|
],
|
|
},
|
|
];
|
|
|
|
export function screenplayByName(name: string): Screenplay | null {
|
|
return SCREENPLAYS.find((sp) => sp.name === name) ?? null;
|
|
}
|