Files
wizwar6e/packages/engine/src/game.ts
T
Eric WagonerandClaude Fable 5 7585a5e88d Two idle-bot loops cured; visionstone reaches the walls
Game eleven's silent turns, diagnosed from the ledger. Automaton III
proposed DESTROY WALL at a wall its visionstone could see — but the
engine's edge-target casts never honored the stone, despite the
declared policy that it is the bearer's sight wherever sight is asked.
castSightEdge now dissolves one wall for the look, and the three raw
losToEdge gates (create-wall, destroy-wall, attacks on walls) route
through it, gaining visionstone and bent-corner sight alike. Automaton
II cast REMOVE CURSE with no patient and no ailment named; the rung
now writes the full prescription. Refusal-fallback loops read as idle
bots — both replays march again. Ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 12:58:57 -04:00

6735 lines
284 KiB
TypeScript

// Pure game core: GameState + applyCommand(state, player, command) -> events.
// No I/O, no clocks, no Math.random — all randomness flows through the seeded
// RNG inside the state, so a (seed, commands) pair replays identically.
//
// Events deliberately carry full spatial/causal detail (who, from where, to
// where, via what) so that replays — including the planned first-person
// wizard's-eye renderings — can reconstruct scenes without re-deriving them.
//
// Rules sources: the owner's 6e rulebook (research/official-6e-card-list.md),
// verbatim card text in data/cards.json, and Jolly's 2002 FAQ. Card effects
// register in CARD_EFFECTS as they are implemented; unimplemented cards
// refuse to cast with a clear error.
import {
type AssembledBoard,
type Cell,
type EdgeState,
type Side,
SIDES,
assembleBoard,
cellKey,
edgeKey,
findWarp,
type Warp,
hasLineOfSight,
sightBetween,
bentSightThroughGap,
neighbor,
stepTarget,
opposite,
} from "./board";
import {
buildDeck,
cardDef,
isMagicStone,
isNumberCard,
isTrap,
numberValue,
type CardInstance,
type CardSet,
} from "./cards";
import { createRng, nextInt, rollDie, shuffle, type RngState } from "./rng";
import { setupBoard } from "./setups";
export type PlayerId = string;
export const STARTING_LIFE = 15;
export const HAND_LIMIT = 7;
export const BASE_MOVEMENT = 3;
export const DRAW_PER_TURN = 2;
export interface TreasureState {
id: string;
owner: PlayerId;
position: Cell | null;
carriedBy: PlayerId | null;
}
export interface PlayerState {
id: PlayerId;
sectorIndex: number;
home: Cell;
position: Cell;
life: number;
alive: boolean;
hand: CardInstance[];
/** Instance ids of cards displayed face-up (Master Key, Wizardblade, stones). */
displayed: string[];
/** The hand held at the moment of elimination — for the post-game reveal. */
finalHand?: CardInstance[];
carriedTreasureId: string | null;
lostTurns: number;
extraTurns: number;
/** PASS THROUGH WALL charges (each lets one step through a wall). */
passWallCharges: number;
/** KILLER OOZE: slipped and fell; must roll to stand. */
fallenInOoze: boolean;
/** CREATE PIT: down in a pit; must roll to climb out. */
inPit: boolean;
}
/** A duration spell in play. Expires at the START of the caster's turns. */
export interface SustainedEffect {
id: string;
cardId: string;
casterId: PlayerId;
targetId: PlayerId;
remainingTurns: number;
/** Per-card scratch (e.g. SLOW's turn parity counter). */
data: Record<string, number>;
/** For edge-bound spells (WALL OF FIRE): the edge to clean up on expiry. */
edge?: string;
}
export type AmbushTrigger =
| { kind: "los" } // an opponent enters my line of sight
| { kind: "near" } // an opponent comes within one space of me
| { kind: "treasure" }; // an opponent picks up any treasure
export interface AmbushState {
id: string;
ownerId: PlayerId;
/** The card that grants the interruption. */
via: CardInstance;
trigger: AmbushTrigger;
/** The committed attack and its number cards, held out of the hand. */
spell: CardInstance;
numbers: CardInstance[];
/** Destination chosen at arm time for cell-needing spells ("wherever
* you say", said in advance): teleport-opponent, mental-force. */
cell?: Cell;
}
/** A summoned creature (or SHADOW/ALTER EGO double). */
export interface CreatureState {
id: string;
kind: "troll" | "skeleton" | "wraith" | "fire-imp" | "democratic-monster" | "shadow" | "alter-ego";
controllerId: PlayerId;
position: Cell;
/** Damage taken so far. */
damage: number;
/** Damage needed to destroy (Infinity: immune to ordinary damage). */
maxDamage: number;
movesPerTurn: number;
movementUsed: number;
attackUsed: boolean;
/** No attacks on the turn it was created. */
justCreated: boolean;
/** WRAITH: may pass through one wall/object per turn. */
wallPassesPerTurn: number;
wallPassUsed: number;
/** FIRE IMP: players already scorched this game-turn. */
scorchedThisTurn: PlayerId[];
}
/** Something occupying a whole square (stone, bushes, ooze, pits, ...). */
export interface SquareContent {
kind: "stone" | "thornbush" | "rosebush" | "ooze" | "dust" | "slime" | "tacks" | "pit" | "safe";
/** Damage taken so far (thornbush dies at 5, rosebush at 5, ooze at 5 fire). */
damage: number;
createdBy: PlayerId;
}
const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"] as const;
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
const PERMANENT_TURNS = 1_000_000_000;
/** Whether a sustained duration means "until used/removed", not a count. */
export function isPermanentDuration(turns: number): boolean {
return turns >= PERMANENT_TURNS / 2;
}
/** Which square contents block line of sight. */
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
stone: true, thornbush: true, rosebush: true, dust: true, slime: true,
ooze: false, tacks: false, pit: false, safe: false,
};
export interface TurnState {
round: number;
firstIndex: number;
activeIndex: number;
movementAllowance: number;
movementUsed: number;
numberPlayedForMovement: boolean;
attackUsed: boolean;
/** ADRENALINE's second attack, once spent. */
secondAttackUsed: boolean;
/** Wand instances already used this turn ("maximum of once per turn"). */
wandsUsed: string[];
/** ADD spent to allow a second movement number card this turn. */
movementAddUsed: boolean;
/** SLOW: "his attacks [reduce] to every other turn". */
attackForbidden: boolean;
actionsEnded: boolean;
/** A reflected LIGHTNING BLAST stuns mid-turn: no end-of-turn draw either
* (FAQ: "you lose the rest of your turn and can't draw cards"). */
drawForbidden?: boolean;
}
export interface CastStack {
attackerId: PlayerId;
defenderId: PlayerId;
/** null = a punch (physical attack with no card). */
attackCard: CardInstance | null;
/** Combined number value (ADD may join two number cards); null = none played. */
numberValue: number | null;
/** Power/duration multiplier from AMPLIFY (and EXTEND for durations). */
amplifyFactor: number;
extendFactor: number;
/** POWER ATTACK: extra damage bought with the caster's life. */
powerAttackPoints: number;
params: CastParams | null;
kind: "spell" | "physical";
counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell;
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
cardId?: string }[];
waitingOn: PlayerId;
/** AROUND THE CORNER carried this attack: sight bent through a middle
* square, so the straight line the table would look for does not exist. */
bentCorner?: true;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
creatureId?: string;
/** A FULL REFLECTION turned the spell around: this stack
* is the returned blow, damage and duration already settled — the new
* defender (the original caster) answers with counteractions of their
* own. Another full reflection here turns it around again. */
reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
/** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" — the grab rides the stack
* like a punch, and the clutch roll waits for the counteractions. */
tearTreasure?: true;
}
export interface CastParams {
damage?: number;
knockback?: number;
cell?: Cell;
/** BOOBYTRAP: the four token cells; the FIRST is the real trap. */
cells?: Cell[];
cardId?: string;
points?: number;
clockwise?: boolean;
/** MEGA-MONSTER: which stat doubles. */
boost?: "life" | "movement";
/** PICK LOCK / MASTER KEY: hold the door open for others. */
hold?: boolean;
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game.
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would
* be an attack") instead of resolving instantly.
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster
* carries it, sharing a square bites both directions, no counteraction. */
export const CURRENT_RULES_REV = 7;
export interface GameConfig {
playerIds: PlayerId[];
seed: number;
sets: CardSet[];
/** Chosen wizard colors, per player (index 0-5 into the six physical
* standees: green, red, magenta, blue, light blue, yellow). Defaults to
* seat order. */
colors?: number[];
/**
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = rev 1, the baseline all earlier revisions collapsed into.
* Rev 2: a wizard beside a door they can open (lock removed, door
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
* the doorway; the hallway behind them still cannot.
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
* asks of its bearer — creations and utility spells included — not
* only direct attacks.
* Rev 4: "the door will relock behind you" means it — passing through
* an unlocked door shuts it at the walker's back unless a hand holds
* it; unpassed, it relocks at turn's end as before.
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
* in three spaces, instead of eating the card silently at resolution.
*/
deckRev?: number;
}
export interface GameState {
config: GameConfig;
phase: "playing" | "finished";
board: AssembledBoard;
/** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */
edgeOverrides: Record<string, EdgeState>;
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
wallDamage: Record<string, number>;
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
/** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
/** Permanent door-lock changes, by edge key. */
doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */
openDoorEdges: string[];
/** Doors physically HELD open ("You may 'hold the door open' for others"
* — MASTER KEY, PICK LOCK): unlocked past end of turn, until the holder
* steps out of adjacency or falls. */
heldDoors: { key: string; by: PlayerId; cell: Cell; side: Side }[];
/** Walls/firewalls conjured during play (dispellable), by edge key. */
createdEdges: Record<string, true>;
/** Square-filling creations, by cell key. */
squareContents: Record<string, SquareContent>;
/** Object cards lying on the floor, by cell key. */
groundObjects: Record<string, CardInstance[]>;
/** The last spell card each player used (for REUSE SPELL). */
lastSpellUsed: Record<PlayerId, string>;
/** ILLUSION WALLs by edge key: real only for those who believe. */
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
creatures: CreatureState[];
nextCreatureId: number;
/** Remaining charges per wand card instance (set on first use). */
wandCharges: Record<string, number>;
/** WARP WAND: walls opened for this turn only, with their prior state. */
tempWarpEdges: {
key: string;
prior: EdgeState | null;
/** A rim wall warped open bores a temporary wraparound; unwind it too. */
unwarp?: { a: Cell; aSide: Side; b: Cell; bSide: Side };
}[];
/** BOOBYTRAP: four face-down tokens, one real (its cell key is secret). */
boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[];
/** GLUE: object cells that cannot be picked up from, by cell key. */
gluedCells: Record<string, true>;
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
openSafes: string[];
/** SWARTHMORE'S ENCHANTMENT: enchanted object instances (+1 magical). */
enchantedObjects: Record<string, true>;
/** DIMENSIONAL WARP token pairs. */
dimWarps: { a: Cell; b: Cell }[];
/** INTERRUPT / OPPORTUNITY FIRE: one out-of-turn action window. */
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** Armed ambushes: an Interrupt/Opportunity Fire committed with an attack
* and a trigger, springing automatically — the async form of "in the
* moment" interruption. Hidden from everyone but the owner. */
ambushes: AmbushState[];
nextAmbushId: number;
players: PlayerState[];
treasures: TreasureState[];
sustained: SustainedEffect[];
deck: CardInstance[];
discard: CardInstance[];
turn: TurnState;
stack: CastStack | null;
rng: RngState;
winner: PlayerId | null;
winReason: "treasures" | "lastStanding" | null;
pendingDiscard: PlayerId | null;
/** Monotonic counter for sustained-effect ids. */
nextEffectId: number;
}
/** The board with dynamic wall changes applied — use for movement and LOS. */
export function boardView(state: GameState): AssembledBoard {
if (Object.keys(state.edgeOverrides).length === 0) return state.board;
return { ...state.board, edges: { ...state.board.edges, ...state.edgeOverrides } };
}
export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: string): SustainedEffect[] {
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
}
/** Square-filling sight blockers: stone, bushes — and the BIG MAN, whom no spell passes. */
export function losBlockers(state: GameState): Record<string, true> {
const blockers: Record<string, true> = {};
for (const [key, content] of Object.entries(state.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
}
for (const p of state.players) {
if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true;
}
return blockers;
}
/** A door HELD open is propped ajar for every eye in the hallway. Any
* other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar
* for the one wizard who may pull it open and peek. */
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board;
const edges = { ...board.edges };
for (const h of state.heldDoors) delete edges[h.key];
return { ...board, edges };
}
/** A wizard at a door's threshold may pull it open and look through
* without stepping through: any wizard beside a door whose lock is
* REMOVED or that stands unlocked this turn, and a holder of PICK LOCK
* or MASTER KEY beside any workable lock. The door still hangs shut to
* everyone down the hallway. (Rules rev 2; earlier games replay with
* doors dark.) */
function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard {
if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board;
const viewer = state.players.find((p) => p.id === viewerId);
if (!viewer || !viewer.alive) return board;
const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
let edges: Record<string, EdgeState> | null = null;
for (const side of SIDES) {
const key = edgeKey(viewer.position, side);
if (board.edges[key] !== "door") continue;
const workable = carriesKey && state.doorStates[key] !== "jammed";
if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue;
if (!edges) edges = { ...board.edges };
delete edges[key];
}
return edges ? { ...board, edges } : board;
}
/** LOS including square-filling blockers. */
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
* whole power, shared by every path that honors it. */
function sightThroughOneEdge(
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
): boolean {
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
}
return false;
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them — creations, dispels, utility
* spells — not just attacks: one wall or door, any type, falls away.
* Ungated by design: validation may widen without a rev bump, because
* refused commands never entered any ledger. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true);
}
/** The eye of an armed ambush. Springing is decided mid-move and lands
* in the ledger's flow, so this sight is frozen per game: before rev 3
* an ambush never looked through its owner's VISIONSTONE, and stored
* games must replay that blindness. */
function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean {
return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3);
}
/** Parse an edge key back into its north/west cell and side. */
function parseEdgeKey(key: string): { cell: Cell; side: Side } {
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { cell: { x, y }, side: kind === "V" ? "E" : "S" };
}
/**
* What does this player believe about an illusion wall? The test is an
* explicit act (the testIllusion command); an untested wall simply IS a
* wall to this player until they choose to doubt it.
*/
function illusionBelief(
state: GameState,
playerId: PlayerId,
key: string,
): "believes" | "seesThrough" {
const wall = state.illusionWalls[key]!;
if (wall.createdBy === playerId) return "seesThrough";
// An untested illusion blocks; testing it is its own free action.
return wall.belief[playerId] ?? "believes";
}
/** The board as one player perceives it: every illusion this viewer has not
* seen through — their own excepted — stands as a wall. */
function perceivedBoard(state: GameState, viewerId: PlayerId): AssembledBoard {
const view = boardView(state);
const keys = Object.keys(state.illusionWalls);
if (keys.length === 0) return view;
const edges = { ...view.edges };
for (const key of keys) {
if (illusionBelief(state, viewerId, key) === "believes") edges[key] = "wall";
}
return { ...view, edges };
}
/**
* LOS for a caster: VISIONSTONE lets its holder see through exactly one
* wall or door (of any type); believed ILLUSION WALLs block them.
*/
function casterLos(
state: GameState,
caster: PlayerState,
from: Cell,
to: Cell,
): boolean {
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
* the target — or, for the full 180-degree hairpin, the sight line doubles
* back around a wall's end, pivoting in the open gap itself. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
if (casterLos(state, caster, from, to)) return true;
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (casterLos(state, caster, from, mid) && casterLos(state, caster, mid, to)) {
return true;
}
}
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
return bentSightThroughGap(board, from, to, losBlockers(state));
}
/** Sight for a square-aimed cast. "You may cast any line of sight spell
* around one corner (up to 180 degrees)" — the attachment bends every
* L.O.S. spell, creations and utilities included, not just attacks. */
function castSight(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
to: Cell,
): boolean {
if (gameLos(state, caster.position, to, caster.id)) return true;
return !!cmd.aroundCornerInstanceId && bentLos(state, caster, caster.position, to);
}
/** Sight for an edge-aimed cast (walls, doors), bend-aware like castSight.
* VISIONSTONE is the bearer's sight here too — one wall or door, any one,
* falls away for the look, exactly as it does for square-aimed casts. */
function castSightEdge(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
board: AssembledBoard,
cell: Cell,
side: Side,
): boolean {
if (losToEdge(board, caster.position, cell, side)) return true;
if (displays(caster, "visionstone")) {
for (const [key, edge] of Object.entries(board.edges)) {
if (edge === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (losToEdge({ ...board, edges }, caster.position, cell, side)) return true;
}
}
if (!cmd.aroundCornerInstanceId) return false;
for (const key of Object.keys(board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (casterLos(state, caster, caster.position, mid) && losToEdge(board, mid, cell, side)) {
return true;
}
}
return false;
}
function inThornbush(state: GameState, p: PlayerState): boolean {
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
}
function isMisted(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "mist-body").length > 0;
}
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "lock-in-place").length > 0;
}
/** BLIND spell, or standing inside a DUST CLOUD. */
function isBlinded(state: GameState, p: PlayerState): boolean {
if (sustainedOn(state, p.id, "blind").length > 0) return true;
return state.squareContents[cellKey(p.position)]?.kind === "dust";
}
/** Is a stone (or other displayable) face-up in front of this player? */
export function displays(p: PlayerState, cardId: string): boolean {
return p.hand.some((c) => c.cardId === cardId && p.displayed.includes(c.instanceId));
}
/** BRAINSTONE: "Hand limit is now nine cards (including this card)." */
export function handLimit(p: PlayerState): number {
return displays(p, "brainstone") ? HAND_LIMIT + 2 : HAND_LIMIT;
}
// ---------------------------------------------------------------------------
// Events
export type GameEvent =
| { type: "gameStarted"; players: PlayerId[]; firstPlayer: PlayerId; dieRolls: Record<PlayerId, number[]>; placements: AssembledBoard["placements"]; homes: Cell[] }
| { type: "cardsDealt"; player: PlayerId; count: number }
| { type: "cardsDealtPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
| { type: "trapRedrawnDuringDeal"; player: PlayerId }
| { type: "turnStarted"; player: PlayerId; round: number }
| { type: "turnSkipped"; player: PlayerId; reason: "lostTurn" }
| { type: "extraTurnStarted"; player: PlayerId }
| { type: "moved"; player: PlayerId; from: Cell; to: Cell; direction: Side; via: "step" | "warp" | "passWall" }
| { type: "numberPlayedForMovement"; player: PlayerId; card: CardInstance; value: number; newAllowance: number }
| { type: "punched"; attacker: PlayerId; target: PlayerId; at: Cell }
| { type: "spellCast"; caster: PlayerId; card: CardInstance; cardId: string; numberCards: CardInstance[]; numberValue: number | null; from: Cell; target: PlayerId | null; targetCell: Cell | null;
/** AMPLIFY cards attached (each doubles). */ amplifies?: number }
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" }
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" }
| { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number }
| { type: "stunned"; player: PlayerId; turnsLost: number }
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
| { type: "stonesDestroyed"; player: PlayerId; cards: CardInstance[] }
| { type: "spellSustained"; effectId: string; cardId: string; caster: PlayerId; target: PlayerId; turns: number }
| { type: "spellExpired"; effectId: string; cardId: string; target: PlayerId }
| { type: "teleported"; player: PlayerId; from: Cell; to: Cell; by: PlayerId; cardId: string }
| { type: "homeBasesSwapped"; a: PlayerId; b: PlayerId; aHome: Cell; bHome: Cell }
| { type: "positionsSwapped"; a: PlayerId; b: PlayerId; aTo: Cell; bTo: Cell }
| { type: "cardErased"; player: PlayerId; cardId: string | null; found: boolean }
| { type: "cardsStolen"; from: PlayerId; to: PlayerId; count: number }
| { type: "cardsStolenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
| { type: "handRevealed"; player: PlayerId; to: PlayerId }
| { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number }
| { type: "firewallExpired"; edge: string }
| { type: "firewallBurned"; player: PlayerId }
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: SquareContent["kind"] }
| { type: "creationDispelled"; caster: PlayerId; what: string }
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
| { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean }
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side }
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
| { type: "tableTalk"; player: PlayerId; text: string }
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId }
| { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell }
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
/** Origins in FINAL coordinates (the maze may renormalize after the
* landing); from/to record the pre-shift request. */
finalFrom: Cell; finalTo: Cell }
| { type: "creatureCreated"; creatureId: string; kind: CreatureState["kind"]; controller: PlayerId; at: Cell }
| { type: "creatureMoved"; creatureId: string; from: Cell; to: Cell; direction: Side; by: PlayerId }
| { type: "creatureAttacked"; creatureId: string; kind: CreatureState["kind"]; target: PlayerId | string; dieRoll: number | null }
| { type: "creatureTouched"; creatureId: string; kind: CreatureState["kind"]; player: PlayerId }
| { type: "creatureDamaged"; creatureId: string; kind: CreatureState["kind"]; amount: number; source: string; damageTotal: number }
| { type: "creatureDestroyed"; creatureId: string; kind: CreatureState["kind"]; by: string }
| { type: "trollRegenerated"; creatureId: string }
| { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number }
| { type: "impScorches"; creatureId: string; player: PlayerId }
| { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" }
| { type: "wandCharged"; player: PlayerId; card: CardInstance; charges: number }
| { type: "wandUsed"; player: PlayerId; cardId: string; chargesLeft: number }
| { type: "wandExhausted"; player: PlayerId; card: CardInstance }
| { type: "wallWarpedOpen"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "wallsWarpedBack"; count: number }
| { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId }
| { type: "webbed"; player: PlayerId }
| { type: "cardRetrieved"; player: PlayerId; cardId: string }
| { type: "slippedInOoze"; player: PlayerId; at: Cell }
| { type: "struggledInOoze"; player: PlayerId; stood: boolean }
| { type: "steppedOnTacks"; player: PlayerId; at: Cell }
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell }
| { type: "fellInPit"; player: PlayerId; at: Cell }
| { type: "climbedFromPit"; player: PlayerId; success: boolean }
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
| { type: "boobytrapSprung"; player: PlayerId; at: Cell }
| { type: "boobytrapBlank"; player: PlayerId; at: Cell }
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
| { type: "safeCreated"; caster: PlayerId; at: Cell }
| { type: "safeOpened"; player: PlayerId; at: Cell }
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null; edge?: { cell: Cell; side: Side } }
| { type: "handsSwapped"; a: PlayerId; b: PlayerId }
| { type: "handsScrambled"; caster: PlayerId }
| { type: "rammed"; attacker: PlayerId; target: PlayerId; distance: number }
| { type: "treasureThrown"; attacker: PlayerId; at: Cell; distance: number }
| { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean }
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
| { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
| { type: "slimeWashed"; cell: Cell }
| { type: "pushed"; by: PlayerId; player?: PlayerId; creatureId?: string; from: Cell; to: Cell }
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
| { type: "warpStepped"; player: PlayerId; from: Cell; to: Cell }
| { type: "exitsRedirected"; caster: PlayerId }
| { type: "outOfTurnWindow"; player: PlayerId; kind: "interrupt" | "opportunity-fire" }
| { type: "thumbOfGod"; caster: PlayerId; aimedAt: Cell; landedAt: Cell }
| { type: "tokenScattered"; what: string; from: Cell; to: Cell }
| { type: "ambushSet"; visibleTo: PlayerId; ambushId: string; via: string; spell: string; trigger: AmbushTrigger }
| { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string }
| { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "warpOpened"; a: { cell: Cell; side: Side }; b: { cell: Cell; side: Side } }
| { type: "wallDamaged"; player: PlayerId; edge: { cell: Cell; side: Side }; amount: number; total: number; needed: number; source: string }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorHeld"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "doorReleased"; edge: { cell: Cell; side: Side } }
| { type: "doorsRelocked"; count: number }
| { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "lockRemoved"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "cardDisplayed"; player: PlayerId; card: CardInstance }
| { type: "extraTurnGranted"; player: PlayerId }
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
| { type: "trapSprung"; player: PlayerId; cardId?: string }
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
| { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
| { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean }
| { type: "tearResisted"; attacker: PlayerId; defender: PlayerId }
| { type: "treasureTearAttempted"; attacker: PlayerId; defender: PlayerId }
| { type: "powerstoneBoost"; player: PlayerId; newAllowance: number }
| { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
| { type: "cardsDrawn"; player: PlayerId; count: number }
| { type: "cardsDrawnPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
| { type: "deckReshuffled"; size: number }
| { type: "turnEnded"; player: PlayerId }
| { type: "gameWon"; player: PlayerId; reason: "treasures" | "lastStanding" };
export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | null {
if ("visibleTo" in event && event.visibleTo !== viewer) return null;
return event;
}
// ---------------------------------------------------------------------------
// Commands
export type CastTarget =
| { kind: "player"; playerId: PlayerId }
| { kind: "creature"; creatureId: string }
| { kind: "edge"; cell: Cell; side: Side }
| { kind: "cell"; cell: Cell };
export type Command =
| { type: "move"; direction: Side; over?: boolean }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId }
| { type: "tearTreasure"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard" }
| { type: "wardChoice"; play: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string }
| {
type: "cast";
instanceId: string;
/** Number cards powering the cast (two allowed when an ADD is attached). */
numberInstanceIds?: string[];
/** Single-number form still present in stored command logs; folded into numberInstanceIds. */
numberInstanceId?: string;
/** AMPLIFY cards attached (each doubles power/duration). */
amplifyInstanceIds?: string[];
/** ADD card attached (permits a second number card). */
addInstanceId?: string;
/** EXTEND card attached (doubles duration). */
extendInstanceId?: string;
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
aroundCornerInstanceId?: string;
/** POWER ATTACK card attached: burn life for extra damage. */
powerAttackInstanceId?: string;
powerAttackPoints?: number;
target?: CastTarget;
params?: CastParams;
}
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
| { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string; params?: { cell?: Cell; cardId?: string }; numberInstanceIds?: string[] }
| { type: "pass" }
| { type: "pickUpTreasure"; treasureId?: string }
| { type: "pickUpObject"; instanceId: string }
| { type: "dropObject"; instanceId: string }
| { type: "dropTreasure" }
| { type: "discard"; instanceIds: string[] }
| { type: "endTurn"; draw: number };
export type CommandResult =
| { ok: true; state: GameState; events: GameEvent[] }
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Card effects registry
type AttackEffect = {
kind: "attack";
requiresLos?: boolean;
/** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */
physical?: boolean;
/** Attacker must share the target's square (WIZARDBLADE). */
sameSquare?: boolean;
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
/** A duration spell: attach a sustained effect on resolution. */
sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
onResolved?: (ctx: ResolutionContext) => void;
};
type NeutralEffect = {
kind: "neutral";
/** Card stays in hand and is displayed (MASTER KEY). */
keepInHand?: boolean;
/** Displaying is a one-time action (magic stones): re-casting is an error. */
displayOnce?: boolean;
resolve: (
state: GameState,
events: GameEvent[],
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
magnitude: Magnitude,
) => string | null;
};
type CounterEffect = {
kind: "counter";
apply: (pipe: DamagePipeline) => void;
};
/** Computed power/duration for a cast, after ADD/AMPLIFY/EXTEND. */
interface Magnitude {
numberValue: number | null;
power: number;
duration: number;
}
interface ResolutionContext {
state: GameState;
events: GameEvent[];
attacker: PlayerState;
defender: PlayerState;
damageDealt: number;
fullyStopped: boolean;
duration: number;
stack: CastStack;
}
interface DamagePipeline {
damage: number;
duration: number;
reflectedDamage: number;
/** Reflection splits a duration spell onto both parties. */
splitDuration: boolean;
redirected: boolean;
fullyStopped: boolean;
reversed: boolean;
kind: "spell" | "physical";
}
/** Conjured or demolished edges straddling a sector boundary. "If someone
* has cast CREATE WALL or DESTROY WALL on a junction between sectors ...
* roll the die for a 50-50% chance to see which one of the two sectors the
* 'alteration' stays with" (rulebook, Cards And Actions That Change The
* Map). 1-2 stays behind; 3-4 travels (the book's own low/high call). */
function rollJunctionAlterations(
state: GameState, events: GameEvent[], casterId: PlayerId,
origin: Cell,
): Set<string> {
const travel = new Set<string>();
const inSector = (c: Cell) =>
c.x >= origin.x && c.x < origin.x + 5 && c.y >= origin.y && c.y < origin.y + 5;
const altered = new Set<string>([
...Object.keys(state.createdEdges),
...Object.keys(state.edgeOverrides).filter(
(k) => state.edgeOverrides[k] === "open" && (state.board.edges[k] ?? "open") !== "open",
),
]);
for (const key of altered) {
const { cell, side } = parseEdgeKey(key);
if (inSector(cell) === inSector(neighbor(cell, side))) continue;
const roll = rollD4(state, events, casterId,
"the junction alteration picks a side — 1-2 stays, 3-4 travels");
if (roll >= 3) travel.add(key);
}
return travel;
}
/** The opposite perimeter edge in the same row or column — where the
* maze's default wraparound would connect ("only opposite board edges
* connect"). Null when the board's shape offers no counterpart. */
function oppositePerimeter(
board: AssembledBoard, cell: Cell, side: Side,
): { cell: Cell; side: Side } | null {
const along = side === "E" || side === "W" ? "x" : "y";
const fixed = along === "x" ? cell.y : cell.x;
let best: Cell | null = null;
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if ((along === "x" ? y : x) !== fixed) continue;
const c = { x, y };
if (best === null ||
(side === "E" || side === "S" ? (along === "x" ? x < best.x : y < best.y)
: (along === "x" ? x > best.x : y > best.y))) {
best = c;
}
}
if (!best || cellKey(best) === cellKey(cell)) return null;
const oppSide = opposite(side);
// The counterpart must itself be a rim: nothing beyond it.
if (board.cells[cellKey(neighbor(best, oppSide))]) return null;
return { cell: best, side: oppSide };
}
/** The far mouth of the warp whose near mouth is this edge, if any. */
function pairedWarpMouth(
board: AssembledBoard, cell: Cell, side: Side,
): { cell: Cell; side: Side } | null {
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
return w ? { cell: w.to.cell, side: w.to.side } : null;
}
/** The warp behind (cell, side), when that edge is a rim mouth — an edge
* whose far side is off the board but whose corridor wraps around. */
function rimWarpMouth(board: AssembledBoard, cell: Cell, side: Side): Warp | undefined {
return board.cells[cellKey(neighbor(cell, side))] ? undefined : findWarp(board, cell, side);
}
/**
* Break the maze's outer rim clean through at (cell, side): the default
* wraparound connects opposite edges, so the matching far rim wall opens
* with it and a new warp joins the two mouths. Null when the far side is
* not a bare wall (nothing to break through into).
*/
function breachRim(
state: GameState, events: GameEvent[], view: AssembledBoard, cell: Cell, side: Side,
): { far: { cell: Cell; side: Side }; farKey: string; farPrior: EdgeState | null } | null {
const far = oppositePerimeter(view, cell, side);
const farKey = far ? edgeKey(far.cell, far.side) : "";
if (!far || (boardView(state).edges[farKey] ?? "open") !== "wall") return null;
const farPrior = state.edgeOverrides[farKey] ?? null;
state.edgeOverrides[farKey] = "open";
state.board.warps.push(
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
);
events.push({ type: "warpOpened", a: { cell, side }, b: far });
return { far, farKey, farPrior };
}
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
// --- Attacks: damage ------------------------------------------------------
fireball: {
kind: "attack",
requiresLos: true,
baseDamage: () => 5,
onResolved: (ctx) => {
if (ctx.damageDealt <= 0) return;
// "Destroys all magical stones an opponent is CARRYING": a stone
// still hidden in the hand is a secret card, not a carried stone —
// only displayed stones burn.
const stones = ctx.defender.hand.filter((c) =>
isMagicStone(c.cardId) && ctx.defender.displayed.includes(c.instanceId));
if (stones.length === 0) return;
ctx.defender.hand = ctx.defender.hand.filter(
(c) => !stones.some((s) => s.instanceId === c.instanceId),
);
ctx.defender.displayed = ctx.defender.displayed.filter(
(id) => !stones.some((s) => s.instanceId === id),
);
ctx.state.discard.push(...stones);
ctx.events.push({ type: "stonesDestroyed", player: ctx.defender.id, cards: stones });
},
},
"lightning-blast": {
kind: "attack",
requiresLos: true,
baseDamage: (n) => n ?? 1,
onResolved: (ctx) => {
if (ctx.damageDealt <= 0 || !ctx.defender.alive) return;
ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
// Stunned mid-own-turn (the bolt came back): FAQ — "If this gets FULLY
// REFLECTED, you lose the rest of your turn and can't draw cards (in
// addition to damage)."
const st = ctx.state;
if (st.players[st.turn.activeIndex]!.id === ctx.defender.id) {
st.turn.actionsEnded = true;
st.turn.drawForbidden = true;
}
},
},
powerthrust: { kind: "attack", requiresLos: true, baseDamage: (n) => 2 + (n ?? 0) },
waterbolt: {
kind: "attack",
requiresLos: true,
baseDamage: (n, params) => params?.damage ?? n ?? 1,
onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock);
},
},
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
"power-drain": {
kind: "attack",
requiresLos: true,
baseDamage: (n) => n ?? 1,
onResolved: (ctx) => {
if (ctx.damageDealt <= 0 || !ctx.attacker.alive) return;
ctx.attacker.life += ctx.damageDealt;
ctx.events.push({
type: "lifeGained",
player: ctx.attacker.id,
amount: ctx.damageDealt,
source: "power drain",
lifeAfter: ctx.attacker.life,
});
},
},
"stone-dead": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0, // computed at resolution: number x stones carried
},
wizardblade: {
kind: "attack",
sameSquare: true,
keepInHand: true,
// "Does magical damage equal to the NUMBER card played. Does NO damage
// without a NUMBER card."
baseDamage: (n) => n ?? 0,
},
// --- Attacks: control -----------------------------------------------------
slow: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
"no-spell": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
medusa: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
"go-away": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1;
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
},
},
"teleport-opponent": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (state, cmd) => {
const cell = cmd.params?.cell;
if (!cell) return "teleport opponent needs a destination cell";
if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles
const from = ctx.defender.position;
ctx.defender.position = to;
ctx.events.push({
type: "teleported", player: ctx.defender.id, from, to,
by: ctx.attacker.id, cardId: "teleport-opponent",
});
},
},
swap: {
kind: "attack",
// "Swap places with any other character during your turn." No LOS printed.
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id) || isLockedInPlace(ctx.state, ctx.attacker.id)) return;
const a = ctx.attacker.position;
ctx.attacker.position = ctx.defender.position;
ctx.defender.position = a;
// "Counts as your movement."
ctx.state.turn.movementUsed = ctx.state.turn.movementAllowance;
ctx.events.push({
type: "positionsSwapped",
a: ctx.attacker.id, b: ctx.defender.id,
aTo: ctx.attacker.position, bTo: ctx.defender.position,
});
},
},
// --- Attacks: cards -------------------------------------------------------
"card-erasure": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the card to erase"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const wanted = ctx.stack.params!.cardId!;
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
if (idx === -1) {
ctx.events.push({ type: "cardErased", player: ctx.defender.id, cardId: wanted, found: false });
return;
}
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
ctx.state.discard.push(card!);
ctx.events.push({ type: "cardErased", player: ctx.defender.id, cardId: wanted, found: true });
},
},
"thought-steal": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.attacker.alive) return;
const stolen: CardInstance[] = [];
for (let i = 0; i < 2 && ctx.defender.hand.length > 0; i++) {
const [idx, rngNext] = nextInt(ctx.state.rng, ctx.defender.hand.length);
ctx.state.rng = rngNext;
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
stolen.push(card!);
}
ctx.attacker.hand.push(...stolen);
ctx.events.push({ type: "cardsStolen", from: ctx.defender.id, to: ctx.attacker.id, count: stolen.length });
ctx.events.push({ type: "cardsStolenPrivate", visibleTo: ctx.attacker.id, cards: stolen });
if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id;
},
},
telepath: {
kind: "attack",
baseDamage: () => 0, // "lets you see any one person's cards" — no LOS printed
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
ctx.events.push({ type: "handRevealed", player: ctx.defender.id, to: ctx.attacker.id });
ctx.events.push({
type: "handRevealedPrivate",
visibleTo: ctx.attacker.id,
player: ctx.defender.id,
cards: [...ctx.defender.hand],
});
},
},
// --- Counteractions -------------------------------------------------------
absorb: {
kind: "counter",
// "Has no effect on duration-based spells."
apply: (p) => { p.damage = Math.max(0, p.damage - 3); },
},
blunt: {
kind: "counter",
// "Works on point-based or duration-based spells, or physical damage."
apply: (p) => {
p.damage = Math.ceil(p.damage / 2);
p.duration = Math.ceil(p.duration / 2);
},
},
"full-shield": {
kind: "counter",
apply: (p) => {
if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; }
},
},
reflection: {
kind: "counter",
apply: (p) => {
if (p.kind !== "spell") return;
const half = Math.ceil(p.damage / 2);
p.reflectedDamage += half;
p.damage = half;
if (p.duration > 0) {
p.duration = Math.ceil(p.duration / 2);
p.splitDuration = true;
}
},
},
"full-reflection": {
kind: "counter",
apply: (p) => { if (p.kind === "spell") p.redirected = true; },
},
reverse: {
kind: "counter",
// "Instead of losing points in a magical attack, you gain them ... Any
// remaining effect of a spell still takes effect."
apply: (p) => { if (p.kind === "spell") p.reversed = true; },
},
// --- Neutrals: walls and doors -------------------------------------------
"create-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "create-wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
// A board-edge warp mouth counts as a wallable line: bricking over
// the opening is a classic play, and the far "space" is the tunnel.
const isWarpMouth = view.warps.some(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
if (!view.cells[cellKey(cell)] || (!view.cells[cellKey(neighbor(cell, side))] && !isWarpMouth)) {
return "walls must be created between two spaces on the board";
}
const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall line";
state.edgeOverrides[key] = "wall";
state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
// A warp is ONE tunnel: bricking a mouth walls both ends.
const pair = pairedWarpMouth(view, cell, side);
if (pair) {
const pkey = edgeKey(pair.cell, pair.side);
state.edgeOverrides[pkey] = "wall";
state.createdEdges[pkey] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: pair });
}
return null;
},
},
"destroy-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "destroy-wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open";
if (current === "open") return "there is no wall there";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall";
state.edgeOverrides[key] = "open";
delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
const pair = pairedWarpMouth(view, cell, side);
if (pair && state.createdEdges[edgeKey(pair.cell, pair.side)]) {
const pkey = edgeKey(pair.cell, pair.side);
state.edgeOverrides[pkey] = "open";
delete state.createdEdges[pkey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false });
}
// Breaching the maze's outer rim breaks through BOTH sides.
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
? breachRim(state, events, view, cell, side) : null;
if (rim) {
delete state.createdEdges[rim.farKey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: rim.far, wasDoor: false });
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(rim.far.cell)) {
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
}
}
for (const cr of [...state.creatures]) {
if (cellKey(cr.position) === cellKey(rim.far.cell)) {
damageCreature(state, events, cr, 4, "collapsing wall");
}
}
}
for (const c of [cell, neighbor(cell, side)]) {
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(c)) {
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
}
}
// "Anyone in either square" includes monsters — the expansion sheet
// says so in as many words.
for (const cr of [...state.creatures]) {
if (cellKey(cr.position) === cellKey(c)) {
damageCreature(state, events, cr, 4, "collapsing wall");
}
}
}
checkVictory(state, events);
return null;
},
},
"pick-lock": {
kind: "neutral",
// "Unlock any door (but the door will relock behind you). Can only use
// when adjacent to door. You may 'hold the door open' for others."
resolve: (state, events, caster, cmd) =>
unlockDoor(state, events, caster, cmd, "pick-lock", { requireAdjacent: true }),
},
"master-key": {
kind: "neutral",
keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
// Cast bare, it simply goes on display, and a DISPLAYED key turns in
// every lock its bearer walks through (see doMove) — no further casts.
// Cast at a door, it works that one lock like PICK LOCK and may hold
// the door open for others.
resolve: (state, events, caster, cmd) =>
cmd.target
? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true })
: null,
},
"remove-lock": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const found = doorTarget(state, cmd);
if (typeof found === "string") return found;
if (!isAdjacentToEdge(caster.position, found.cell, found.side)) {
return "you must be adjacent to the door";
}
const key = edgeKey(found.cell, found.side);
if (state.doorStates[key] === "jammed") return "the lock is jammed solid";
state.doorStates[key] = "removed";
events.push({ type: "lockRemoved", player: caster.id, edge: found });
return null;
},
},
"jam-lock": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const found = doorTarget(state, cmd);
if (typeof found === "string") return found;
if (!losToEdge(boardView(state), caster.position, found.cell, found.side)) {
return "no line of sight to the door";
}
const key = edgeKey(found.cell, found.side);
if (state.doorStates[key] === "removed") return "there is no lock left to jam";
state.doorStates[key] = "jammed";
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
events.push({ type: "doorJammed", player: caster.id, edge: found });
return null;
},
},
// --- Neutrals: movement ---------------------------------------------------
teleport: {
kind: "neutral",
// "Move up to four spaces (not diagonally), ignoring walls and objects.
// ... your movement ends after you play it."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell";
if (isLockedInPlace(state, caster.id)) return "you are locked in place";
const to = cmd.target.cell;
const view = boardView(state);
if (!view.cells[cellKey(to)]) return "destination is off the board";
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
if (wallIgnoringDistance(view, caster.position, to) > 4) {
return "teleport reaches at most four spaces";
}
// A teleport is a willing move: FEAR's bubble refuses it.
if (fearRepels(state, caster.id, caster.position, to)) {
return "an unnatural dread keeps you away";
}
const from = caster.position;
caster.position = to;
state.turn.movementUsed = state.turn.movementAllowance; // movement ends
events.push({ type: "teleported", player: caster.id, from, to, by: caster.id, cardId: "teleport" });
return null;
},
},
"pass-through-wall": {
kind: "neutral",
resolve: (_state, _events, caster) => {
caster.passWallCharges++;
return null;
},
},
"power-run": {
kind: "neutral",
// "Trade your life-points for extra movement, one point per space."
resolve: (state, events, caster, cmd) => {
const points = cmd.params?.points ?? 0;
if (!Number.isInteger(points) || points < 1) return "choose how many life points to trade";
if (points >= caster.life) return "that trade would kill you";
caster.life -= points;
state.turn.movementAllowance += points;
events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance });
return null;
},
},
// --- Neutrals: self-buffs -------------------------------------------------
speed: {
kind: "neutral",
resolve: (state, events, caster) => {
caster.extraTurns++;
events.push({ type: "extraTurnGranted", player: caster.id });
return null;
},
},
invisible: {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "invisible", caster.id, caster.id, magnitude.duration);
return null;
},
},
shrink: {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "shrink", caster.id, caster.id, magnitude.duration);
return null;
},
},
"mist-body": {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "mist-body", caster.id, caster.id, magnitude.duration);
return null;
},
},
// --- More attacks ---------------------------------------------------------
"lock-in-place": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
buddy: {
kind: "neutral",
// "Opponent will not attack you unless you attack first. This is
// permanent until you attack." Neutral, LOS per card.
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "player") return "buddy targets a player";
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null;
},
},
dagger: {
kind: "attack",
requiresLos: true,
physical: true,
keepInHand: false,
// "You may throw it. Does three points physical damage. ... Retrievable
// by anyone after it is thrown."
baseDamage: () => 3,
onResolved: (ctx) => { landThrownObject(ctx, "dagger"); },
},
"large-rock": {
kind: "attack",
requiresLos: true,
physical: true,
baseDamage: () => 2,
onResolved: (ctx) => { landThrownObject(ctx, "large-rock"); },
},
"drop-object": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the object to drop"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const wanted = ctx.stack.params!.cardId!;
if (wanted === "treasure") {
if (!ctx.defender.carriedTreasureId) return;
const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!;
t.carriedBy = null;
t.position = ctx.defender.position;
ctx.defender.carriedTreasureId = null;
ctx.events.push({
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position),
});
// A forced drop can land on a home square: bankings and the
// captured-treasures clause take effect at once, as they would
// for a willing drop.
checkVictory(ctx.state, ctx.events);
return;
}
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
if (idx === -1) return;
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
const key = cellKey(ctx.defender.position);
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card!];
ctx.events.push({
type: "objectDropped", player: ctx.defender.id, card: card!,
at: ctx.defender.position, forced: true,
});
},
},
// --- Terrain --------------------------------------------------------------
"fill-square-with-stone": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind: "stone", damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "stone" });
return null;
},
},
thornbush: {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind: "thornbush", damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "thornbush" });
return null;
},
},
"wall-of-fire": {
kind: "neutral",
// Neutral use: a burning barrier for [duration] turns. (Its counteraction
// use against WATERBOLT lives in doCounteract and the resolution pipe.)
resolve: (state, events, caster, cmd, magnitude) => {
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
const { cell, side } = cmd.target;
const view = boardView(state);
// A warp mouth at the maze's rim is a corridor too — the wraparound
// connects it to the opposite edge. The fire takes BOTH mouths, so
// the crossing burns no matter which rim the wizard steps in from.
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warp = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
return "the fire must span a corridor between two spaces";
}
const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
const farKey = warp ? edgeKey(warp.to.cell, warp.to.side) : null;
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
return "the far mouth of that warp is sealed";
}
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
state.edgeOverrides[k] = "firewall";
state.createdEdges[k] = true;
const fx: SustainedEffect = {
id: `fx-${state.nextEffectId++}`,
cardId: "wall-of-fire",
casterId: caster.id,
targetId: caster.id,
remainingTurns: Math.max(1, magnitude.duration),
data: {},
edge: k,
};
state.sustained.push(fx);
events.push({ type: "firewallCreated", caster: caster.id, edge: at, turns: fx.remainingTurns });
};
ignite(key, { cell, side });
if (warp && farKey) ignite(farKey, { cell: warp.to.cell, side: warp.to.side });
return null;
},
},
waterwall: {
kind: "neutral",
// "The moment you create it, it collapses, washing away any player within
// two spaces back two spaces (including the caster)."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
const { cell, side } = cmd.target;
const view = boardView(state);
// A rim warp mouth is a corridor too: the collapse washes
// both rims inward (waveFromEdge follows the tunnel).
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warpMouth = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the wave must span a corridor between two spaces";
}
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
// The two sides of the edge, and the push directions away from it.
waveFromEdge(state, events, cell, side, 2, "waterwall");
checkVictory(state, events);
return null;
},
},
"dispel-creation": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const view = boardView(state);
if (cmd.target?.kind === "edge") {
const key = edgeKey(cmd.target.cell, cmd.target.side);
if (state.illusionWalls[key]) {
if (!castSightEdge(state, caster, cmd, boardView(state), cmd.target.cell, cmd.target.side)) return "no line of sight";
delete state.illusionWalls[key];
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
return null;
}
if (!state.createdEdges[key]) return "that is not a created thing";
if (!castSightEdge(state, caster, cmd, view, cmd.target.cell, cmd.target.side)) return "no line of sight";
const was = view.edges[key];
delete state.edgeOverrides[key];
delete state.createdEdges[key];
state.sustained = state.sustained.filter((s) => s.edge !== key);
events.push({ type: "creationDispelled", caster: caster.id, what: was === "firewall" ? "wall of fire" : "created wall" });
return null;
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell);
if (creature) {
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null;
}
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null;
}
return "dispel targets a created wall, fire, stone, or bush";
},
},
drag: {
kind: "neutral",
// "Drags any moveable object within L.O.S. towards you ..." (and, per the
// rulebook's Objects section, players can be DRAGged too).
resolve: (state, events, caster, cmd) => {
if (cmd.target?.kind === "player") {
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself";
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position;
dragToward(state, target, caster.position);
events.push({ type: "objectDragged", caster: caster.id, what: target.id, from, to: target.position });
return null;
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) {
const card = objects[objects.length - 1]!;
objects.pop();
if (objects.length === 0) delete state.groundObjects[key];
const destKey = cellKey(caster.position);
state.groundObjects[destKey] = [...(state.groundObjects[destKey] ?? []), card];
events.push({ type: "objectDragged", caster: caster.id, what: card.cardId, from: cmd.target.cell, to: caster.position });
return null;
}
if (treasure) {
const from = treasure.position!;
treasure.position = { ...caster.position };
events.push({ type: "objectDragged", caster: caster.id, what: treasure.id, from, to: caster.position });
checkVictory(state, events);
return null;
}
return "nothing to drag there";
}
return "drag targets an object square or a player";
},
},
// --- Magic stones ---------------------------------------------------------
bloodstone: stoneEffect(),
// "Add 1 to any NUMBER card played." On the table the stone and the
// number land together; digitally the display may come a beat late, so
// it blesses a number already played for movement this turn.
powerstone: stoneEffect((state, events, caster) => {
if (activePlayer(state).id === caster.id && state.turn.numberPlayedForMovement) {
state.turn.movementAllowance += 1;
events.push({ type: "powerstoneBoost", player: caster.id, newAllowance: state.turn.movementAllowance });
}
}),
shadowstone: stoneEffect(),
soulstone: stoneEffect(),
speedstone: stoneEffect((state, _events, caster) => {
// "Your movement rate is increased by 1" — starting now, not next turn.
// A delta (not a recompute) so number cards already played stay counted.
// No bump while SLOW forces 1, or when this turn's movement is already
// forced to zero (pit struggle, sticky webs) — next turn recomputes.
const isActive = state.players[state.turn.activeIndex]?.id === caster.id;
const slowed = sustainedOn(state, caster.id, "slow").length > 0;
if (isActive && !slowed && state.turn.movementAllowance > 0) {
state.turn.movementAllowance += 1;
}
}),
shieldstone: stoneEffect(),
visionstone: stoneEffect(),
brainstone: stoneEffect((state, events, caster) => {
// "Draw two more cards, now."
const drawn: CardInstance[] = [];
for (let i = 0; i < 2; i++) {
const card = drawOne(state, events);
if (card) drawn.push(card);
}
caster.hand.push(...drawn);
events.push({ type: "cardsDrawn", player: caster.id, count: drawn.length });
events.push({ type: "cardsDrawnPrivate", visibleTo: caster.id, cards: drawn });
applySlowDeathOnDraw(state, events, caster, drawn.length);
}),
"slow-death": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
"around-the-corner": {
kind: "neutral",
// Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
},
ugly: {
kind: "neutral",
// "All opponents in L.O.S. retreat as far away as necessary to avoid
// L.O.S., along the shortest path available."
resolve: (state, events, caster) => {
for (const opp of state.players) {
if (!opp.alive || opp.id === caster.id) continue;
if (!gameLos(state, caster.position, opp.position, caster.id)) continue;
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
retreatFromSight(state, events, opp, caster.position);
}
return null;
},
},
"illusion-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
// A rim warp mouth takes the scrim too — the bluff of
// create-wall's brick-over. The illusion hangs on the mouth it was
// painted on; the far mouth shows nothing (nothing physical exists).
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
const warpMouth = rimWarpMouth(view, cell, side);
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the illusion must span two spaces on the board";
}
const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (state.illusionWalls[key]) return "an illusion already shimmers there";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
return null;
},
},
"rotate-sector": {
kind: "neutral",
// "Allows you to rotate any one sector 90 degrees."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "click a square in the sector to rotate";
const idx = sectorIndexAt(state.board, cmd.target.cell);
if (idx === -1) return "that is not on a sector";
const travel = rollJunctionAlterations(
state, events, caster.id, state.board.placements[idx]!.origin);
rotateSector(state, idx, cmd.params?.clockwise ?? true, travel);
events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true });
return null;
},
},
"relocate-sector": {
kind: "neutral",
// "Relocate (but not rotate) any one sector to any other area, so long as
// all sectors are still adjacent to at least one other."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "click the destination area";
const fromCell = cmd.params?.cell;
if (!fromCell) return "pick the sector to move first";
const idx = sectorIndexAt(state.board, fromCell);
if (idx === -1) return "that is not on a sector";
const dest: Cell = {
x: Math.floor(cmd.target.cell.x / 5) * 5,
y: Math.floor(cmd.target.cell.y / 5) * 5,
};
const fromOrigin = { ...state.board.placements[idx]!.origin };
const travel = rollJunctionAlterations(state, events, caster.id, fromOrigin);
const problem = relocateSector(state, idx, dest, travel);
if (problem) return problem;
const finalTo = { ...state.board.placements[idx]!.origin };
const shift = { x: finalTo.x - dest.x, y: finalTo.y - dest.y };
events.push({
type: "sectorRelocated", caster: caster.id, sectorIndex: idx,
from: fromOrigin, to: dest,
finalFrom: { x: fromOrigin.x + shift.x, y: fromOrigin.y + shift.y },
finalTo,
});
return null;
},
},
// --- Expansion #1: creatures ---------------------------------------------
troll: summonEffect("troll"),
skeleton: summonEffect("skeleton"),
wraith: summonEffect("wraith"),
"fire-imp": summonEffect("fire-imp"),
"democratic-monster": summonEffect("democratic-monster"),
shadow: summonEffect("shadow"),
"alter-ego": {
kind: "neutral",
// "Create a stationary double of yourself in the square you now occupy."
resolve: (state, events, caster) => {
if (creatureAt(state, caster.position)) return "a creature is already here";
spawnCreature(state, events, "alter-ego", caster.id, caster.position);
return null;
},
},
"mega-monster": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "creature") return "mega-monster targets a monster";
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!castSight(state, caster, cmd, creature.position)) return "no line of sight";
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
events.push({ type: "monsterBoosted", creatureId: creature.id, boost });
return null;
},
},
adrenaline: {
kind: "neutral",
// "Allows two attacks in one turn. Duration equals NUMBER card played."
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "adrenaline", caster.id, caster.id, magnitude.duration);
return null;
},
},
"mad-dash": {
kind: "neutral",
// "Doubles your movement (including NUMBER cards and other add-ons) for
// one turn. You cannot carry treasures while exerting yourself."
resolve: (state, events, caster) => {
if (caster.carriedTreasureId) return "you cannot mad-dash while carrying a treasure";
state.turn.movementAllowance *= 2;
events.push({ type: "lifeTraded", player: caster.id, points: 0, newAllowance: state.turn.movementAllowance });
return null;
},
},
lifesaver: {
kind: "neutral",
resolve: (state, events, caster) => {
if (state.players.filter((p) => p.alive).length <= 2) return "not applicable in a 2-player game";
attachSustained(state, events, "lifesaver", caster.id, caster.id, PERMANENT_TURNS);
return null;
},
},
// --- Expansion #1: magic wands -------------------------------------------
"blaster-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
// "does 3 points of magical damage per charge"
baseDamage: () => 3,
},
"sticky-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
// "reducing movement by 3 ... Lasts one turn. Any fire damage done to
// player in webs causes two extra points."
attachSustained(ctx.state, ctx.events, "sticky-web", ctx.attacker.id, ctx.defender.id, 1);
ctx.events.push({ type: "webbed", player: ctx.defender.id });
},
},
"shift-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
baseDamage: () => 0,
validate: (state, cmd) => {
const cell = cmd.params?.cell;
if (!cell) return "choose the adjacent space to shove them into";
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params!.cell!;
const from = ctx.defender.position;
const dist = Math.abs(to.x - from.x) + Math.abs(to.y - from.y);
if (dist !== 1) return; // must be adjacent to where they stand
// "he could be shoved through a stone wall" — walls do not stop it.
ctx.defender.position = to;
ctx.events.push({ type: "shoved", player: ctx.defender.id, from, to, by: ctx.attacker.id });
},
},
"warp-wand": {
kind: "neutral",
keepInHand: true,
// "makes 1 section (one space long) of wall disappear during your turn,
// reappearing at the end of your turn."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "aim the wand at a wall section";
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
const view = boardView(state);
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
state.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
// A rim wall warped open bores a temporary wraparound: the far rim
// wall vanishes with it and a warp runs until turn's end.
const rim = !view.cells[cellKey(neighbor(cell, side))] && !findWarp(view, cell, side)
? breachRim(state, events, view, cell, side) : null;
if (rim) {
state.tempWarpEdges.push({
key: rim.farKey, prior: rim.farPrior,
unwarp: { a: cell, aSide: side, b: rim.far.cell, bSide: rim.far.side },
});
events.push({ type: "wallWarpedOpen", player: caster.id, edge: rim.far });
}
return null;
},
},
"deja-vu": {
kind: "neutral",
// "Retrieve any one card from the discard pile (except a MAGIC WAND)."
resolve: (state, events, caster, cmd) => {
const wanted = cmd.params?.cardId;
if (!wanted) return "name the card to retrieve";
if ((WAND_CARD_IDS as readonly string[]).includes(wanted)) {
return "deja-vu cannot retrieve a magic wand";
}
for (let i = state.discard.length - 1; i >= 0; i--) {
if (state.discard[i]!.cardId === wanted) {
const [card] = state.discard.splice(i, 1);
caster.hand.push(card!);
events.push({ type: "cardRetrieved", player: caster.id, cardId: wanted });
if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id;
return null;
}
}
return "that card is not in the discard pile";
},
},
// --- Expansion #1: terrain -----------------------------------------------
"killer-ooze": terrainEffect("ooze"),
rosebush: terrainEffect("rosebush"),
"dust-cloud": terrainEffect("dust"),
"fill-square-with-slime": terrainEffect("slime"),
"create-pit": terrainEffect("pit"),
"handful-of-tacks": {
kind: "neutral",
// NEUTRAL / ADJACENT: scattered at your feet, not thrown across the maze.
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
const cell = cmd.target.cell;
if (Math.abs(cell.x - caster.position.x) + Math.abs(cell.y - caster.position.y) > 1) {
return "you must be adjacent to scatter tacks";
}
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(cell)] = { kind: "tacks", damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell, kind: "tacks" });
return null;
},
},
"create-door": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "create-door targets a wall";
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
const view = boardView(state);
// Cells on both sides: a rim door would open onto nothing
// traversable — the wraparound belongs to breaches, not doors.
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the door must stand between two spaces";
}
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
return "a door goes into a stone wall or an open corridor";
}
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "door";
state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
return null;
},
},
boobytrap: {
kind: "neutral",
// Four face-down tokens; only the caster knows which is real.
resolve: (state, events, caster, cmd) => {
const cells = cmd.params?.cells;
if (!cells || cells.length !== 4) return "place four tokens (the first is the real trap)";
const view = boardView(state);
for (const c of cells) {
if (!view.cells[cellKey(c)]) return "a token is off the board";
if (state.squareContents[cellKey(c)]?.kind === "stone") return "a token is inside solid stone";
}
const uniq = new Set(cells.map(cellKey));
if (uniq.size !== 4) return "the four tokens go on four different squares";
state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) });
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] });
events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
return null;
},
},
glue: {
kind: "neutral",
resolve: (state, events, caster, cmd, magnitude) => {
if (!cmd.target || cmd.target.kind !== "cell") return "glue targets an object's square";
const key = cellKey(cmd.target.cell);
const hasObject =
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2;
const fx: SustainedEffect = {
id: `fx-${state.nextEffectId++}`,
cardId: "glue", casterId: caster.id, targetId: caster.id,
remainingTurns: Math.max(1, turns), data: {}, edge: key,
};
state.sustained.push(fx);
events.push({ type: "objectsGlued", caster: caster.id, at: cmd.target.cell, turns: fx.remainingTurns });
return null;
},
},
safe: {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "safe targets a treasure or item";
const key = cellKey(cmd.target.cell);
if (state.squareContents[key]) return "that square is occupied";
const hasObject =
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null;
},
},
trader: {
kind: "neutral",
// Swap two floor items, both in LOS. Does not overcome GLUE.
resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell;
const bT = cmd.target;
if (!a || !bT || bT.kind !== "cell") return "pick the two item squares to swap";
const b = bT.cell;
const ka = cellKey(a), kb = cellKey(b);
if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
if (!castSight(state, caster, cmd, a) || !castSight(state, caster, cmd, b)) return "no line of sight";
const itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
const treasureB = state.treasures.find((t) => t.position && cellKey(t.position) === kb);
if (itemsA.length + (treasureA ? 1 : 0) === 0 || itemsB.length + (treasureB ? 1 : 0) === 0) {
return "both squares must hold an item";
}
if (itemsA.length > 0 || itemsB.length > 0) {
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsA];
else delete state.groundObjects[kb];
if (itemsB.length > 0) state.groundObjects[ka] = [...itemsB];
else delete state.groundObjects[ka];
}
if (treasureA) treasureA.position = { ...b };
if (treasureB) treasureB.position = { ...a };
events.push({ type: "itemsTraded", caster: caster.id, a, b });
checkVictory(state, events);
return null;
},
},
"stone-to-water": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const view = boardView(state);
if (cmd.target?.kind === "edge") {
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
// Doors are "small entryways in a stone wall": stone melts as stone.
const current = view.edges[key];
const meltable = current === "wall" || current === "door";
if (!meltable) return "that is not a stone wall";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "open";
delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } });
// A brick over a warp mouth melts at BOTH ends — the tunnel is one
// wall line.
const pair = pairedWarpMouth(view, cell, side);
const pairKey = pair ? edgeKey(pair.cell, pair.side) : "";
if (pair && state.createdEdges[pairKey]) {
state.edgeOverrides[pairKey] = "open";
delete state.createdEdges[pairKey];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: pair });
}
// Melting the maze's outer rim breaches it like DESTROY WALL does:
// the far rim wall washes out too and a new warp opens, so the
// wave below pours through the fresh tunnel onto both rims.
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
? breachRim(state, events, view, cell, side) : null;
if (rim) {
delete state.createdEdges[rim.farKey];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: rim.far });
}
// "Wall turns into a WATERWALL with a range and damage of 2."
waveFromEdge(state, events, cell, side, 2);
checkVictory(state, events);
return null;
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
waveFromCell(state, events, cmd.target.cell, 4);
checkVictory(state, events);
return null;
}
return "target a stone wall or a solid stone block";
},
},
// --- Expansion #1: fortune and misfortune --------------------------------
"gift-from-above": {
kind: "neutral",
// "Add three points to your total, now. You may go higher than fifteen."
resolve: (state, events, caster) => {
caster.life += 3;
events.push({ type: "lifeGained", player: caster.id, amount: 3, source: "gift from above", lifeAfter: caster.life });
return null;
},
},
"power-attack": {
kind: "neutral",
// Handled as a cast modifier (powerAttackPoints); casting it alone is a
// usage error.
resolve: () => "attach Power Attack to a damage spell (choose life points to burn)",
},
strength: {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "strength", caster.id, caster.id, magnitude.duration);
return null;
},
},
weakness: {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
sustains: true,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
// "Opponent drops any treasure carried."
if (ctx.defender.carriedTreasureId) {
const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!;
t.carriedBy = null;
t.position = ctx.defender.position;
ctx.defender.carriedTreasureId = null;
ctx.events.push({
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position),
});
}
},
},
"walking-dead": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
// "1/2 point of damage for every space moved. This spell is permanent."
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
disease: {
// Legacy (rev ≤6): cast AT an adjacent victim through the attack
// stack; ledgers from those games replay this path. Rev 7 games
// never reach it — doCast intercepts disease as a self-cast plague.
kind: "attack",
baseDamage: () => 0,
sameSquare: false,
validate: (state, cmd) => {
const target = state.players.find((p) => p.id === (cmd.target as { playerId?: PlayerId })?.playerId);
const caster = activePlayer(state);
if (!target) return null;
const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y);
return d <= 1 ? null : "disease spreads by touch — you must be adjacent";
},
sustains: true,
},
empathy: {
kind: "neutral",
// Counteraction card used proactively: while it lasts, attacks against
// you act against the attacker too.
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "empathy", caster.id, caster.id, magnitude.duration);
return null;
},
},
"force-field": {
kind: "counter",
// Stops the spell attack outright (daggers and blades slip through).
apply: (p) => {
if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; }
},
},
"mental-swap": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
const aHand = ctx.attacker.hand;
ctx.attacker.hand = ctx.defender.hand;
ctx.defender.hand = aHand;
const aDisp = ctx.attacker.displayed;
ctx.attacker.displayed = ctx.defender.displayed;
ctx.defender.displayed = aDisp;
ctx.events.push({ type: "handsSwapped", a: ctx.attacker.id, b: ctx.defender.id });
const check = (p: PlayerState) => {
if (p.hand.length > handLimit(p)) ctx.state.pendingDiscard = p.id;
};
check(ctx.attacker);
check(ctx.defender);
},
},
"mental-force": {
kind: "attack",
baseDamage: () => 0, // no LOS printed
validate: (state, cmd, caster, target) => {
const cell = cmd.params?.cell;
if (!cell) return "say where they go (within three moved spaces)";
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
// The victim WALKS those three spaces — a destination the maze's
// walls put out of reach is refused up front, not swallowed at
// resolution with the card already spent. (Rev 5; the victim may
// still slip out of range before it resolves — see onResolved.)
if ((state.config.deckRev ?? 1) >= 5 && target &&
walkingDistance(state, target.position, cell) > 3) {
return "the walls put that square beyond three moved spaces";
}
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) {
// The victim slipped beyond reach: the force strains and fails,
// and the table sees it fail rather than wondering.
ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to });
return;
}
const from = ctx.defender.position;
ctx.defender.position = to;
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
},
},
"butt-head": {
kind: "attack",
physical: true,
baseDamage: () => 0, // computed at resolution: distance rammed
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
if (d === 0) return;
ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
resolvedBlow(ctx, d, "goat ram", "physical");
},
},
"heave-ho": {
kind: "attack",
requiresLos: true,
physical: true,
baseDamage: () => 0,
validate: (state) => {
const caster = activePlayer(state);
return caster.carriedTreasureId ? null : "you have no treasure to throw";
},
onResolved: (ctx) => {
if (!ctx.attacker.carriedTreasureId) return;
const t = ctx.state.treasures.find((t) => t.id === ctx.attacker.carriedTreasureId)!;
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
t.carriedBy = null;
t.position = { ...ctx.defender.position };
ctx.attacker.carriedTreasureId = null;
ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d });
if (!ctx.fullyStopped && ctx.defender.alive && d > 0) {
resolvedBlow(ctx, d, "hurled treasure", "physical");
}
ctx.events.push({
type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id,
at: t.position!, onHomeOf: homeOwnerAt(ctx.state, t.position!),
});
checkVictory(ctx.state, ctx.events);
},
},
thief: {
kind: "attack",
sameSquare: true,
baseDamage: () => 0,
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name the item to steal"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const wanted = ctx.stack.params!.cardId!;
if (wanted === "treasure") return; // "The item may not be a treasure."
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted && cardDef(c.cardId).cardType === "object");
if (idx === -1) return;
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
ctx.attacker.hand.push(card!);
ctx.events.push({ type: "itemStolen", from: ctx.defender.id, to: ctx.attacker.id, cardId: wanted });
if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id;
},
},
chaos: {
kind: "attack",
baseDamage: () => 0,
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
// "FULL SHIELD removes a player from participation": every bystander
// gets a shield window before the pile forms.
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
const queue = order.filter((id) =>
id !== ctx.attacker.id && id !== ctx.defender.id &&
ctx.state.players.find((p) => p.id === id)!.alive);
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
finishChaosIfReady(ctx.state, ctx.events);
},
},
"illusionary-attack": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_s, cmd) => {
const chosen = cmd.params?.cardId;
if (!chosen) return "choose the attack spell to fake";
const fx = CARD_EFFECTS[chosen];
if (!fx || fx.kind !== "attack") return "that is not an attack spell";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
const chosen = ctx.stack.params!.cardId!;
const fx = CARD_EFFECTS[chosen] as AttackEffect;
const [roll, rngNext] = rollDie(ctx.state.rng);
ctx.state.rng = rngNext;
const believed = roll <= 2;
ctx.events.push({ type: "illusionBelieved", player: ctx.defender.id, cardId: chosen, believed });
if (!believed) return;
const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null);
if (dmg > 0) {
resolvedBlow(ctx, dmg, `illusionary ${chosen}`);
}
},
},
"swap-meet": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name your item and theirs (yours;theirs)"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
// "Swap any two carried items": every movable object trades —
// daggers, rocks, wands, stones — and a carried TREASURE is an item
// too, named by the "treasure" token.
const tradable = (id: string) => isMovableObject(id);
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
const side = (p: PlayerState, id: string): TradeSide | null => {
if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null;
const idx = p.hand.findIndex((c) => c.cardId === id && tradable(c.cardId));
return idx === -1 ? null : { kind: "card", idx };
};
const fizzle = () => ctx.events.push({ type: "swapFizzled", player: ctx.attacker.id });
const mine = side(ctx.attacker, mineId ?? "");
const theirs = side(ctx.defender, theirsId ?? "");
if (!mine || !theirs) return fizzle();
// A one-way treasure needs an open carry slot and an able back:
// "you are too weak to carry treasure" holds in a trade as at a grab.
const weak = (p: PlayerState) => sustainedOn(ctx.state, p.id, "weakness").length > 0;
if (mine.kind === "treasure" && theirs.kind !== "treasure" &&
(ctx.defender.carriedTreasureId != null || weak(ctx.defender))) return fizzle();
if (theirs.kind === "treasure" && mine.kind !== "treasure" &&
(ctx.attacker.carriedTreasureId != null || weak(ctx.attacker))) return fizzle();
const giveTreasure = (from: PlayerState, to: PlayerState) => {
const t = ctx.state.treasures.find((t) => t.id === from.carriedTreasureId)!;
from.carriedTreasureId = null;
t.carriedBy = to.id;
return t;
};
const takeCard = (p: PlayerState, s: Extract<TradeSide, { kind: "card" }>) => {
const [card] = p.hand.splice(s.idx, 1);
p.displayed = p.displayed.filter((id) => id !== card!.instanceId);
return card!;
};
if (mine.kind === "treasure" && theirs.kind === "treasure") {
const tA = giveTreasure(ctx.attacker, ctx.defender);
const tB = giveTreasure(ctx.defender, ctx.attacker);
ctx.defender.carriedTreasureId = tA.id;
ctx.attacker.carriedTreasureId = tB.id;
} else if (mine.kind === "treasure") {
const t = giveTreasure(ctx.attacker, ctx.defender);
ctx.defender.carriedTreasureId = t.id;
ctx.attacker.hand.push(takeCard(ctx.defender, theirs as Extract<TradeSide, { kind: "card" }>));
} else if (theirs.kind === "treasure") {
const t = giveTreasure(ctx.defender, ctx.attacker);
ctx.attacker.carriedTreasureId = t.id;
ctx.defender.hand.push(takeCard(ctx.attacker, mine));
} else {
const a = takeCard(ctx.attacker, mine);
const b = takeCard(ctx.defender, theirs);
ctx.attacker.hand.push(b);
ctx.defender.hand.push(a);
}
ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id });
},
},
"remove-curse": {
kind: "neutral",
// Counteraction used out of the stack: strip one duration spell.
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "player") return "choose whose curse to remove";
const wanted = cmd.params?.cardId;
if (!wanted) return "name the spell to remove";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target) return "no such player";
const idx = state.sustained.findIndex((fx) => fx.targetId === target.id && fx.cardId === wanted);
if (idx === -1) return "no such spell on them";
// "Has to hit to affect SHRINK and INVISIBLE."
if (wanted === "invisible" || wanted === "shrink") {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const needed = wanted === "invisible" ? 1 : 2;
if (roll > needed) {
events.push({ type: "attackMissed", attacker: caster.id, defender: target.id, attackCardId: "remove-curse", because: wanted as "invisible" | "shrink" });
return null;
}
}
const [fx] = state.sustained.splice(idx, 1);
if (fx!.cardId === "glue" && fx!.edge) delete state.gluedCells[fx!.edge];
events.push({ type: "curseRemoved", caster: caster.id, target: target.id, cardId: wanted });
return null;
},
},
"swarthmores-enchantment": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const wanted = cmd.params?.cardId;
if (!wanted) return "name the object to enchant";
// Find the instance: your hand, a target player's hand, or the floor.
let instance: CardInstance | undefined = caster.hand.find((c) => c.cardId === wanted);
if (!instance && cmd.target?.kind === "player") {
const t = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
instance = t?.hand.find((c) => c.cardId === wanted);
}
if (!instance) {
for (const objs of Object.values(state.groundObjects)) {
instance = objs.find((c) => c.cardId === wanted);
if (instance) break;
}
}
if (!instance) return "no such object in sight";
state.enchantedObjects[instance.instanceId] = true;
events.push({ type: "objectEnchanted", caster: caster.id, cardId: wanted });
return null;
},
},
ward: {
kind: "neutral",
// WARD is never cast from the hand — it springs automatically when your
// treasure is grabbed (see doPickUpTreasure).
resolve: () => "Ward waits in your hand and springs when your treasure is taken",
},
idiot: {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
// "Opponent heads straight for the nearest of his own treasures ... This
// lasts until opponent is on his own treasure."
sustains: false,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
// FAQ: "If you happen to already be carrying one of your treasures
// when you are hit with this spell, it has no effect on you."
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
if (carried?.owner === ctx.defender.id) return;
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
"big-man": {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "big-man", caster.id, caster.id, magnitude.duration);
return null;
},
},
fear: {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "fear", caster.id, caster.id, magnitude.duration);
return null;
},
},
"dimensional-warp": {
kind: "neutral",
// Two tokens anywhere (except home bases); stepping between them costs 1.
resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell;
const bT = cmd.target;
if (!a || !bT || bT.kind !== "cell") return "place the two warp tokens";
const b = bT.cell;
const view = boardView(state);
for (const c of [a, b]) {
if (!view.cells[cellKey(c)]) return "off the board";
if (view.homes.some((h) => cellKey(h) === cellKey(c))) return "not on a home base";
if (state.squareContents[cellKey(c)]?.kind === "stone") return "inside solid stone";
}
if (cellKey(a) === cellKey(b)) return "the tokens go on two different squares";
state.dimWarps.push({ a: { ...a }, b: { ...b } });
events.push({ type: "warpTokensPlaced", caster: caster.id, a, b });
return null;
},
},
redirection: {
kind: "neutral",
// "Signify their new directions by placing the A tokens on one set of
// exits & the B tokens on the other" — the two exits you choose become a
// pair, and their former partners pair with each other.
resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell;
const bT = cmd.target;
if (!a || !bT || bT.kind !== "cell") return "pick the two exits to connect";
const b = bT.cell;
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
if (!wa || !wb || wa === wb) return "pick two different outer exits";
if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect";
const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell));
const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell));
// The chosen exits join (A tokens); their old partners join (B tokens).
wa.to = { cell: { ...wb.from.cell }, side: wb.from.side };
wb.to = { cell: { ...wa.from.cell }, side: wa.from.side };
if (pa && pb) {
pa.to = { cell: { ...pb.from.cell }, side: pb.from.side };
pb.to = { cell: { ...pa.from.cell }, side: pa.from.side };
}
events.push({ type: "exitsRedirected", caster: caster.id });
return null;
},
},
"opportunity-fire": {
kind: "neutral",
resolve: () => "played out of turn — wait for another player's turn, then use it",
},
interrupt: {
kind: "neutral",
resolve: () => "played out of turn — use it during another player's turn",
},
"thumb-of-god": {
kind: "neutral",
// Digital redesign ("divine meteor", chosen by the owner): aim at a
// square; the die drifts 0-2 squares in a random direction, then every
// token in and around the landing square — objects, treasures, creatures,
// even wizards — is flung to a random nearby square. Walls mean nothing
// to falling cardboard. "There is no COUNTERACTION against this spell."
resolve: (state, events, caster, cmd) => {
const pre = attackPreconditions(state);
if (pre) return pre;
if (!cmd.target || cmd.target.kind !== "cell") return "aim the die at a square";
const aim = cmd.target.cell;
const view = boardView(state);
if (!view.cells[cellKey(aim)]) return "off the board";
if (!castSight(state, caster, cmd, aim)) return "no line of sight";
state.turn.attackUsed = true;
const clampToBoard = (c: Cell): Cell => {
if (view.cells[cellKey(c)]) return c;
// knocked off the board: settle at the nearest on-board cell
let best: Cell = aim;
let bestD = Infinity;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const d = Math.abs(x - c.x) + Math.abs(y - c.y);
if (d < bestD) { bestD = d; best = { x, y }; }
}
return best;
};
// Drift: 1 = dead on; 2-3 = one square off; 4 = two squares off.
let landed = aim;
{
const [d1, r1] = rollDie(state.rng);
state.rng = r1;
const drift = d1 === 1 ? 0 : d1 === 4 ? 2 : 1;
if (drift > 0) {
const [d2, r2] = rollDie(state.rng);
state.rng = r2;
const dir = SIDES[d2 - 1]!;
landed = clampToBoard({
x: aim.x + (dir === "E" ? drift : dir === "W" ? -drift : 0),
y: aim.y + (dir === "S" ? drift : dir === "N" ? -drift : 0),
});
}
}
events.push({ type: "thumbOfGod", caster: caster.id, aimedAt: aim, landedAt: landed });
const inBlast = (c: Cell) =>
Math.abs(c.x - landed.x) <= 1 && Math.abs(c.y - landed.y) <= 1;
const scatterTo = (from: Cell): Cell => {
const [d, rNext] = rollDie(state.rng);
state.rng = rNext;
const dir = SIDES[d - 1]!;
const [d2, rNext2] = rollDie(state.rng);
state.rng = rNext2;
const dist = d2 <= 2 ? 1 : 2;
return clampToBoard({
x: from.x + (dir === "E" ? dist : dir === "W" ? -dist : 0),
y: from.y + (dir === "S" ? dist : dir === "N" ? -dist : 0),
});
};
const safeCell = (c: Cell): Cell =>
state.squareContents[cellKey(c)]?.kind === "stone" ? landed : c;
for (const [key, objs] of Object.entries({ ...state.groundObjects })) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (!inBlast({ x, y })) continue;
delete state.groundObjects[key];
for (const o of objs) {
const to = safeCell(scatterTo({ x, y }));
state.groundObjects[cellKey(to)] = [...(state.groundObjects[cellKey(to)] ?? []), o];
events.push({ type: "tokenScattered", what: o.cardId, from: { x, y }, to });
}
}
for (const t of state.treasures) {
if (!t.position || !inBlast(t.position)) continue;
const from = t.position;
t.position = safeCell(scatterTo(from));
events.push({ type: "tokenScattered", what: t.id, from, to: t.position });
}
for (const c of state.creatures) {
if (!inBlast(c.position)) continue;
const from = c.position;
c.position = safeCell(scatterTo(from));
events.push({ type: "tokenScattered", what: c.kind, from, to: c.position });
}
for (const p of state.players) {
if (!p.alive || !inBlast(p.position)) continue;
if (isLockedInPlace(state, p.id)) continue;
const from = p.position;
p.position = safeCell(scatterTo(from));
events.push({ type: "tokenScattered", what: p.id, from, to: p.position });
}
checkVictory(state, events);
return null;
},
},
"swap-home-bases": {
kind: "neutral",
// "Swap your home base with any other player, as long as you both have an
// equal number of treasures on your home bases. You must be within L.O.S."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "player") return "choose whose home to take";
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!other || !other.alive) return "no such living player";
if (other.id === caster.id) return "that is already your home";
if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them";
const onHome = (home: Cell) =>
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
if (onHome(caster.home) !== onHome(other.home)) {
return "your home bases must hold an equal number of treasures";
}
const mine = caster.home;
caster.home = other.home;
other.home = mine;
events.push({ type: "homeBasesSwapped", a: caster.id, b: other.id, aHome: caster.home, bHome: other.home });
checkVictory(state, events);
return null;
},
},
"reuse-spell": {
kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but
// not the NUMBER card)."
resolve: (state, events, caster) => {
const lastId = state.lastSpellUsed[caster.id];
if (!lastId || lastId === "reuse-spell") return "no spell to retrieve";
// The most recent copy of that card in the discard pile is yours.
for (let i = state.discard.length - 1; i >= 0; i--) {
if (state.discard[i]!.cardId === lastId) {
const [card] = state.discard.splice(i, 1);
caster.hand.push(card!);
events.push({ type: "spellReused", player: caster.id, card: card! });
if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id;
delete state.lastSpellUsed[caster.id];
return null;
}
}
return "that spell is no longer in the discard pile";
},
},
};
/** Simple square-filling terrain creations (ooze, rosebush, dust, slime, pit). */
function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
return {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind, damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind });
return null;
},
};
}
/**
* How hard a wave still pushes someone it finds `dist` cells from its
* source: the force spent reaching them is gone, so a range-2 wave throws
* its adjacent victim 2 spaces but a victim at its far edge only 1 — and
* never converts spent force into crush damage.
*/
function waveForce(range: number, dist: number): number {
return range - dist;
}
/** A collapsing waterwall wave from an edge: wash players back `range`. */
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
// A warp mouth's "far side" is the opposite rim: the collapse washes both
// mouths inward. A plain edge washes its two adjacent cells apart.
const warp = rimWarpMouth(boardView(state), cell, side);
const pushes: { start: Cell; dir: Side }[] = warp
? [
{ start: cell, dir: opposite(side) },
{ start: warp.to.cell, dir: opposite(warp.to.side) },
]
: [
{ start: cell, dir: opposite(side) },
{ start: neighbor(cell, side), dir: side },
];
for (const { start, dir } of pushes) {
let probe = start;
for (let dist = 0; dist < range; dist++) {
const force = waveForce(range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, reason);
} else {
washBackCreature(state, events, c, dir, force);
}
}
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
delete state.squareContents[cellKey(probe)];
delete state.slimeTraps[cellKey(probe)];
events.push({ type: "slimeWashed", cell: probe });
}
probe = neighbor(probe, dir);
}
}
}
/** A wave bursting outward from a cell in all four directions. */
function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void {
for (const dir of SIDES) {
let probe = center;
for (let dist = 0; dist < range; dist++) {
probe = neighbor(probe, dir);
const force = waveForce(range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, "rushing water");
} else {
washBackCreature(state, events, c, dir, force);
}
}
}
}
}
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
return {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
// Summons are printed ATTACK cards: they respect attack rules and
// consume the turn's attack ("creating a monster counts as your
// attack ... on the turn he is created"), but open no counteraction
// window — nobody is attacked yet.
const pre = attackPreconditions(state);
if (pre) return pre;
if (!cmd.target || cmd.target.kind !== "cell") return "choose a square in sight for the creature";
const at = cmd.target.cell;
const view = boardView(state);
if (!view.cells[cellKey(at)]) return "off the board";
if (state.squareContents[cellKey(at)]) return "that square is blocked";
if (creatureAt(state, at)) return "a creature is already there";
if (!castSight(state, caster, cmd, at)) return "no line of sight";
state.turn.attackUsed = true;
spawnCreature(state, events, kind, caster.id, at);
return null;
},
};
}
/** A displayable stone: casting it turns it face-up; its power is passive. */
function stoneEffect(onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
return {
kind: "neutral",
keepInHand: true,
displayOnce: true,
resolve: (state, events, caster) => {
onDisplay?.(state, events, caster);
return null;
},
};
}
/** Thrown weapons land in the target's square, whatever the counters did. */
function landThrownObject(ctx: ResolutionContext, cardId: string): void {
const card = ctx.stack.attackCard!;
const key = cellKey(ctx.defender.position);
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card];
// The card was discarded on cast; move it from the discard to the floor.
const di = ctx.state.discard.findIndex((c) => c.instanceId === card.instanceId);
if (di !== -1) ctx.state.discard.splice(di, 1);
ctx.events.push({ type: "objectThrown", attacker: ctx.attacker.id, cardId, landedAt: ctx.defender.position });
}
/** Validate a cell target for square-filling creations. */
function emptySquareTarget(
state: GameState,
cmd: Extract<Command, { type: "cast" }>,
caster: PlayerState,
): Cell | string {
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
const cell = cmd.target.cell;
const key = cellKey(cell);
const view = boardView(state);
if (!view.cells[key]) return "off the board";
if (state.squareContents[key]) return "that square is occupied";
if (view.homes.some((h) => cellKey(h) === key)) return "you cannot create on a home base";
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
// "You can't create an object on a DIMENSIONAL WARP".
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
return cell;
}
/** WATERWALL family: push a player `range` spaces; 1 damage per blocked space. */
function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: Side, range: number): void {
if (isLockedInPlace(state, p.id)) return;
const view = boardView(state);
const from = p.position;
let moved = 0;
for (let i = 0; i < range; i++) {
const step = stepTarget(view, p.position, dir);
if (step.kind === "blocked") break;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
p.position = step.to;
moved++;
}
const blockedSpaces = range - moved;
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
if (blockedSpaces > 0) {
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null, "physical");
}
}
/** The wave carries monsters as it carries wizards: washed back, and
* crushed a point per space the maze refuses them. Fire imps never reach
* this — water destroys them outright. */
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
const view = boardView(state);
let moved = 0;
for (let i = 0; i < range; i++) {
const step = stepTarget(view, c.position, dir);
if (step.kind === "blocked") break;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
c.position = step.to;
moved++;
}
const blockedSpaces = range - moved;
if (blockedSpaces > 0) {
damageCreature(state, events, c, blockedSpaces, "waterwall crush");
}
}
// ---------------------------------------------------------------------------
// Creatures
const CREATURE_STATS: Record<CreatureState["kind"], { maxDamage: number; moves: number; wallPasses: number }> = {
troll: { maxDamage: 6, moves: 3, wallPasses: 0 },
skeleton: { maxDamage: 4, moves: 3, wallPasses: 0 },
wraith: { maxDamage: 4, moves: 3, wallPasses: 1 },
"fire-imp": { maxDamage: Infinity, moves: 0, wallPasses: 0 }, // only water kills it
"democratic-monster": { maxDamage: 5, moves: 3, wallPasses: 0 },
shadow: { maxDamage: 1, moves: 3, wallPasses: 0 }, // "any damage at all destroys it"
"alter-ego": { maxDamage: 1, moves: 0, wallPasses: 0 },
};
function creatureById(state: GameState, id: string): CreatureState | undefined {
return state.creatures.find((c) => c.id === id);
}
export function creatureAt(state: GameState, cell: Cell): CreatureState | undefined {
return state.creatures.find((c) => cellKey(c.position) === cellKey(cell));
}
function spawnCreature(
state: GameState,
events: GameEvent[],
kind: CreatureState["kind"],
controllerId: PlayerId,
at: Cell,
): CreatureState {
const stats = CREATURE_STATS[kind];
const creature: CreatureState = {
id: `creature-${state.nextCreatureId++}`,
kind,
controllerId,
position: at,
damage: 0,
maxDamage: stats.maxDamage,
movesPerTurn: stats.moves,
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
// "Cannot attack the turn they are created" is justCreated's job; the
// democratic monster's attackUsed budgets its ROUND, so spending it at
// birth would mute a mid-round summon until the round rolled over.
attackUsed: kind !== "democratic-monster",
justCreated: true,
wallPassesPerTurn: stats.wallPasses,
wallPassUsed: 0,
scorchedThisTurn: [],
};
state.creatures.push(creature);
events.push({ type: "creatureCreated", creatureId: creature.id, kind, controller: controllerId, at });
return creature;
}
function damageCreature(
state: GameState,
events: GameEvent[],
creature: CreatureState,
amount: number,
source: string,
): void {
creature.damage += amount;
events.push({ type: "creatureDamaged", creatureId: creature.id, kind: creature.kind, amount, source, damageTotal: creature.damage });
if (creature.damage >= creature.maxDamage) {
destroyCreature(state, events, creature, source);
}
}
function destroyCreature(state: GameState, events: GameEvent[], creature: CreatureState, by: string): void {
state.creatures = state.creatures.filter((c) => c.id !== creature.id);
events.push({ type: "creatureDestroyed", creatureId: creature.id, kind: creature.kind, by });
}
/** FIRE IMP: scorch any player in its LOS, once per player per game-turn. */
function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): void {
for (const imp of state.creatures.filter((c) => c.kind === "fire-imp")) {
for (const p of state.players) {
if (!p.alive) continue;
if (onlyPlayer && p.id !== onlyPlayer) continue;
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
if (imp.scorchedThisTurn.includes(p.id)) continue;
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
// firewall edges and terrain.
if (!gameLos(state, imp.position, p.position)) continue;
imp.scorchedThisTurn.push(p.id);
events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
applyDamage(state, events, p, 2, "fire imp", null);
}
}
checkVictory(state, events);
}
/** A creature arriving in a player's square touches: the wraith's chill
* and the democratic monster's claw open a counteraction window ("REFLECTIONs
* used on the wraith's touch will damage the wraith" — the card assumes
* exactly this window). True = a stack opened and the turn should return.
* "May attack only one player per round of turns" spends attackUsed. */
function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean {
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "wraith");
return true;
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return true;
}
}
return false;
}
/** A creature standing on a DIMENSIONAL WARP token steps through it like
* any walker: its commander spends one of its moves, solid stone on the
* far side refuses it, and a monster is no braver than a wizard about
* FEAR. (Monsters obey the same maze the wizards do.) */
function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
return err("that creature does not obey you");
}
if (creature.movesPerTurn === 0) return err("that creature cannot move");
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
const here = cellKey(creature.position);
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
if (!pair) return err("it is not standing on a warp token");
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that corridor");
}
const from = creature.position;
// FEAR holds monsters off too: "no player or monster".
if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast");
creature.position = { ...dest };
creature.movementUsed++;
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
creatureTouchOnArrival(state, events, creature);
return { ok: true, state, events };
}
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
// The DEMOCRATIC MONSTER is moved by every player; others obey their creator.
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
return err("that creature does not obey you");
}
if (creature.movesPerTurn === 0) return err("that creature cannot move");
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
const events: GameEvent[] = [];
const view = boardView(state);
const from = creature.position;
const target = stepTarget(view, creature.position, direction);
if (target.kind === "blocked") {
const dest = neighbor(creature.position, direction);
// An open doorway (lock removed, keyed, or held) is no obstacle —
// and costs a wraith nothing.
if (target.by === "door" && doorIsOpen(state, edgeKey(creature.position, direction)) &&
view.cells[cellKey(dest)] &&
state.squareContents[cellKey(dest)]?.kind !== "stone") {
creature.position = dest;
} else if (
// WRAITH: "can move ... through 1 wall or object per turn."
creature.wallPassesPerTurn > creature.wallPassUsed &&
view.cells[cellKey(dest)] &&
state.squareContents[cellKey(dest)]?.kind !== "stone"
) {
creature.wallPassUsed++;
creature.position = dest;
} else {
return err(`blocked by ${target.by}`);
}
} else {
const content = state.squareContents[cellKey(target.to)];
if (content?.kind === "stone") return err("that square is solid stone");
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that corridor");
}
creature.position = target.to;
}
// FEAR holds monsters off too: "no player or monster".
if (fearRepels(state, null, from, creature.position)) {
creature.position = from;
if (creature.wallPassUsed > 0) creature.wallPassUsed--;
return err("an unnatural dread stops the beast");
}
creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
checkVictory(state, events);
return { ok: true, state, events };
}
/** A spell stuck in slime "goes off only once" at whoever is in the gel. */
function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerState): void {
if (state.stack) return; // one thing at a time; the next visitor springs it
const key = cellKey(victim.position);
const traps = state.slimeTraps[key];
if (!traps || traps.length === 0) return;
const trap = traps.shift()!;
if (traps.length === 0) delete state.slimeTraps[key];
state.discard.push(trap.card);
events.push({ type: "slimeTrapSprung", cell: victim.position, cardId: trap.card.cardId, victim: victim.id });
state.stack = {
attackerId: trap.casterId,
defenderId: victim.id,
attackCard: trap.card,
numberValue: trap.numberValue,
amplifyFactor: trap.amplifyFactor,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: (CARD_EFFECTS[trap.card.cardId] as AttackEffect | undefined)?.physical ? "physical" : "spell",
counters: [],
waitingOn: victim.id,
trapped: true,
};
}
/** A creature's blow opens a counteraction window like any attack. */
function openCreatureStack(
state: GameState,
creature: CreatureState,
victim: PlayerState,
damage: number,
touch?: "wraith" | "claw",
): void {
state.stack = {
attackerId: creature.controllerId,
defenderId: victim.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: { damage },
kind: "physical",
counters: [],
waitingOn: victim.id,
creatureId: creature.id,
...(touch ? { creatureTouch: touch } : {}),
};
}
function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.round === 1) return err("no combat during the first round of turns");
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
if (creature.controllerId !== active.id) return err("that creature does not obey you");
if (creature.justCreated) return err("it cannot attack the turn it was created");
if (creature.attackUsed) return err("that creature has already attacked this turn");
if (creature.kind !== "troll" && creature.kind !== "skeleton" && creature.kind !== "shadow") {
return err("that creature attacks on its own, not on command");
}
const events: GameEvent[] = [];
const targetPlayer = state.players.find((p) => p.id === targetId && p.alive);
const targetCreature = creatureById(state, targetId);
if (!targetPlayer && !targetCreature) return err("no such target");
const targetPos = targetPlayer ? targetPlayer.position : targetCreature!.position;
if (cellKey(targetPos) !== cellKey(creature.position)) {
return err("the creature must share its target's square");
}
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
creature.attackUsed = true;
let amount: number;
let roll: number | null = null;
if (creature.kind === "troll") {
const [r, rngNext] = rollDie(state.rng);
state.rng = rngNext;
roll = r;
amount = r;
} else if (creature.kind === "skeleton") {
amount = 2;
} else {
amount = 1; // shadow punches like a wizard
}
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: targetId, dieRoll: roll });
if (targetPlayer) {
openCreatureStack(state, creature, targetPlayer, amount);
return { ok: true, state, events };
} else {
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
}
checkVictory(state, events);
return { ok: true, state, events };
}
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
const view = boardView(state);
const start = p.position;
const seen = new Set<string>([cellKey(start)]);
let frontier: Cell[] = [start];
const safeAt = (c: Cell) => !gameLos(state, horror, c);
for (let depth = 0; depth < 60 && frontier.length > 0; depth++) {
const safe = frontier.filter(safeAt);
if (safe.length > 0) {
// Multiple equally short refuges: the die decides.
let choice = safe[0]!;
if (safe.length > 1) {
const [i, rngNext] = nextInt(state.rng, safe.length);
state.rng = rngNext;
choice = safe[i]!;
}
const from = p.position;
p.position = choice;
events.push({ type: "retreatedInHorror", player: p.id, from, to: choice });
return;
}
const next: Cell[] = [];
for (const c of frontier) {
for (const side of SIDES) {
const step = stepTarget(view, c, side);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
if (seen.has(cellKey(step.to))) continue;
seen.add(cellKey(step.to));
next.push(step.to);
}
}
frontier = next;
}
// Nowhere to hide: they cower where they stand.
}
/** Which placement contains this cell? */
function sectorIndexAt(board: AssembledBoard, cell: Cell): number {
return board.placements.findIndex(
(p) => cell.x >= p.origin.x && cell.x < p.origin.x + 5 && cell.y >= p.origin.y && cell.y < p.origin.y + 5,
);
}
/** Remap every piece of coordinate-keyed state through cell/side transforms. */
function remapState(
state: GameState,
inSector: (c: Cell) => boolean,
mapCell: (c: Cell) => Cell,
mapSide: (s: Side) => Side,
travelKeys?: Set<string>,
): void {
const mapEdgeKey = (key: string): string => {
const { cell, side } = parseEdgeKey(key);
const other = neighbor(cell, side);
const inA = inSector(cell), inB = inSector(other);
if (inA && inB) return edgeKey(mapCell(cell), mapSide(side));
// A boundary edge stays put — unless the die said it travels: then it
// rides anchored to its in-sector cell.
if (travelKeys?.has(key) && (inA || inB)) {
const anchor = inA ? cell : other;
const anchorSide = inA ? side : opposite(side);
return edgeKey(mapCell(anchor), mapSide(anchorSide));
}
return key;
};
const remapRecord = <T,>(rec: Record<string, T>, mapKey: (k: string) => string): Record<string, T> =>
Object.fromEntries(Object.entries(rec).map(([k, v]) => [mapKey(k), v]));
const mapCellKey = (key: string): string => {
const [x, y] = key.split(",").map(Number) as [number, number];
const c = { x, y };
return inSector(c) ? cellKey(mapCell(c)) : key;
};
state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey);
state.wallDamage = remapRecord(state.wallDamage, mapEdgeKey);
state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey);
state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
for (const h of state.heldDoors) {
h.key = mapEdgeKey(h.key);
if (inSector(h.cell)) h.cell = mapCell(h.cell);
}
state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
}
for (const p of state.players) {
if (inSector(p.position)) p.position = mapCell(p.position);
if (inSector(p.home)) p.home = mapCell(p.home);
}
for (const t of state.treasures) {
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
}
// A moving sector carries everything standing on it.
for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position);
}
for (const t of state.boobytraps) {
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
t.realKey = mapCellKey(t.realKey);
}
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
state.openSafes = state.openSafes.map(mapCellKey);
for (const w of state.dimWarps) {
if (inSector(w.a)) w.a = mapCell(w.a);
if (inSector(w.b)) w.b = mapCell(w.b);
}
}
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
function rotateSector(state: GameState, index: number, clockwise: boolean, travelKeys?: Set<string>): void {
const placement = state.board.placements[index]!;
const { x: ox, y: oy } = placement.origin;
const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5;
const mapCell = (c: Cell): Cell => {
const lx = c.x - ox, ly = c.y - oy;
return clockwise
? { x: ox + (4 - ly), y: oy + lx }
: { x: ox + ly, y: oy + (4 - lx) };
};
const order: Side[] = ["N", "E", "S", "W"];
const mapSide = (s: Side): Side => order[(order.indexOf(s) + (clockwise ? 1 : 3)) % 4]!;
const newPlacements = state.board.placements.map((p, i) =>
i === index
? { ...p, rotation: (((p.rotation + (clockwise ? 90 : 270)) % 360) as 0 | 90 | 180 | 270) }
: p,
);
const oldWarps = state.board.warps;
const rebuilt = assembleBoard(newPlacements);
rebuilt.warps = oldWarps; // openings are centered: rotation never moves them
state.board = rebuilt;
remapState(state, inSector, mapCell, mapSide, travelKeys);
}
/** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */
function relocateSector(state: GameState, index: number, dest: Cell, travelKeys?: Set<string>): string | null {
const placements = state.board.placements;
const current = placements[index]!.origin;
if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
for (let i = 0; i < placements.length; i++) {
if (i === index) continue;
const o = placements[i]!.origin;
if (o.x === dest.x && o.y === dest.y) return "another sector is there";
}
// "All sectors still adjacent to at least one other."
const origins = placements.map((p, i) => (i === index ? dest : p.origin));
const adjacent = (a: Cell, b: Cell) =>
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
for (let i = 0; i < origins.length; i++) {
if (!origins.some((o, j) => j !== i && adjacent(origins[i]!, o))) {
return "every sector must stay adjacent to at least one other";
}
}
const { x: ox, y: oy } = current;
const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5;
const dx = dest.x - ox, dy = dest.y - oy;
const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy });
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
// Zero-anchor the maze after the move: negative landings shift back into
// positive coordinates, and vacating the origin corner pulls the maze snug
// instead of leaving a blank band.
const shift = {
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
};
const shifted = shift.x || shift.y
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
: newPlacements;
// "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(shifted);
remapState(state, inSector, mapCell, (s) => s, travelKeys);
if (shift.x || shift.y) {
remapState(state, () => true, (c) => ({ x: c.x + shift.x, y: c.y + shift.y }), (s) => s);
}
return null;
}
/** DRAG a player straight toward the caster, stopping at walls. */
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
const view = boardView(state);
for (let guard = 0; guard < 20; guard++) {
if (cellKey(target.position) === cellKey(dest)) return;
const dx = dest.x - target.position.x;
const dy = dest.y - target.position.y;
let dir: Side;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else dir = dy > 0 ? "S" : "N";
const step = stepTarget(view, target.position, dir);
if (step.kind === "blocked") return;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") return;
target.position = step.to;
}
}
// ---------------------------------------------------------------------------
// Effect helpers
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
const n = neighbor(cell, side);
return sightBetween(board, from, cell) || sightBetween(board, from, n);
}
function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean {
return cellKey(pos) === cellKey(cell) || cellKey(pos) === cellKey(neighbor(cell, side));
}
function doorTarget(
state: GameState,
cmd: Extract<Command, { type: "cast" }>,
): { cell: Cell; side: Side } | string {
if (!cmd.target || cmd.target.kind !== "edge") return "target a door";
const { cell, side } = cmd.target;
const current = boardView(state).edges[edgeKey(cell, side)] ?? "open";
if (current !== "door") return "that is not a door";
return { cell, side };
}
function unlockDoor(
state: GameState,
events: GameEvent[],
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
cardId: string,
opts: { requireAdjacent: boolean },
): string | null {
const found = doorTarget(state, cmd);
if (typeof found === "string") return found;
const key = edgeKey(found.cell, found.side);
if (state.doorStates[key] === "jammed") return "the lock is jammed solid";
if (state.doorStates[key] === "removed") return "that door has no lock";
if (opts.requireAdjacent && !isAdjacentToEdge(caster.position, found.cell, found.side)) {
return "you must be adjacent to the door";
}
if (!state.openDoorEdges.includes(key)) state.openDoorEdges.push(key);
events.push({ type: "doorUnlocked", player: caster.id, edge: found, withCardId: cardId });
if (cmd.params?.hold) {
if (!state.heldDoors.some((h) => h.key === key)) {
state.heldDoors.push({ key, by: caster.id, cell: found.cell, side: found.side });
}
events.push({ type: "doorHeld", player: caster.id, edge: found });
}
return null;
}
/** An open doorway: lock removed for good, keyed or picked this turn, or
* held by a standing wizard. */
function doorIsOpen(state: GameState, key: string): boolean {
return state.doorStates[key] === "removed" ||
state.openDoorEdges.includes(key) ||
state.heldDoors.some((h) => h.key === key);
}
/** A held door needs its holder: standing adjacent and alive. Anything that
* moves or fells them — a step, a shove, a teleport, a killing blow — lets
* the door swing shut. */
function sweepHeldDoors(state: GameState, events: GameEvent[]): void {
for (const h of [...state.heldDoors]) {
const holder = state.players.find((p) => p.id === h.by);
if (holder?.alive && isAdjacentToEdge(holder.position, h.cell, h.side)) continue;
state.heldDoors = state.heldDoors.filter((x) => x !== h);
events.push({ type: "doorReleased", edge: { cell: h.cell, side: h.side } });
}
}
function attachSustained(
state: GameState,
events: GameEvent[],
cardId: string,
casterId: PlayerId,
targetId: PlayerId,
turns: number,
): void {
const effect: SustainedEffect = {
id: `fx-${state.nextEffectId++}`,
cardId,
casterId,
targetId,
remainingTurns: Math.max(1, turns),
data: {},
};
state.sustained.push(effect);
events.push({
type: "spellSustained",
effectId: effect.id,
cardId,
caster: casterId,
target: targetId,
turns: effect.remainingTurns,
});
}
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
function walkingDistance(state: GameState, from: Cell, to: Cell): number {
if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state);
const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from];
while (queue.length > 0) {
const cur = queue.shift()!;
const d = seen.get(cellKey(cur))!;
if (d >= 6) break;
for (const side of SIDES) {
const step = stepTarget(view, cur, side);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
if (seen.has(cellKey(step.to))) continue;
seen.set(cellKey(step.to), d + 1);
if (cellKey(step.to) === cellKey(to)) return d + 1;
queue.push(step.to);
}
}
return seen.get(cellKey(to)) ?? Infinity;
}
/** BFS steps between cells ignoring walls (teleport distance). */
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from];
while (queue.length > 0) {
const cur = queue.shift()!;
const d = seen.get(cellKey(cur))!;
if (d >= 8) break; // teleport range is 4; stop early
for (const side of SIDES) {
let n = neighbor(cur, side);
if (!board.cells[cellKey(n)]) {
// The maze wraps for walkers; it wraps for teleporters too —
// a warp mouth is one step, same as any doorway.
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
);
if (!w) continue;
n = w.to.cell;
}
if (seen.has(cellKey(n))) continue;
seen.set(cellKey(n), d + 1);
if (cellKey(n) === cellKey(to)) return d + 1;
queue.push(n);
}
}
return seen.get(cellKey(to)) ?? Infinity;
}
// ---------------------------------------------------------------------------
// Setup
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
const n = config.playerIds.length;
let rng = createRng(config.seed);
const events: GameEvent[] = [];
const { board, rng: rng1 } = setupBoard(n, rng);
rng = rng1;
const [sectorOrder, rng2] = shuffle(rng, board.homes.map((_, i) => i).slice(0, n));
rng = rng2;
const players: PlayerState[] = config.playerIds.map((id, i) => ({
id,
sectorIndex: sectorOrder[i]!,
home: board.homes[sectorOrder[i]!]!,
position: board.homes[sectorOrder[i]!]!,
life: STARTING_LIFE,
alive: true,
hand: [],
displayed: [],
carriedTreasureId: null,
lostTurns: 0,
extraTurns: 0,
passWallCharges: 0,
fallenInOoze: false,
inPit: false,
}));
const treasures: TreasureState[] = players.flatMap((p, i) =>
board.treasureSpaces[p.sectorIndex]!.map((cell, j) => ({
id: `treasure-${i}-${j}`,
owner: p.id,
position: cell,
carriedBy: null,
})),
);
let fullDeck = buildDeck(config.sets);
if (n === 2) {
fullDeck = fullDeck.filter((c) => c.cardId !== "lifesaver");
}
const [deckShuffled, rng3] = shuffle(rng, fullDeck);
rng = rng3;
const deck = [...deckShuffled];
const discard: CardInstance[] = [];
for (const p of players) {
while (p.hand.length < HAND_LIMIT) {
const card = deck.shift();
if (!card) throw new Error("deck exhausted during deal");
if (isTrap(card.cardId) || card.cardId === "gift-from-below") {
// "Discard without any damage taken if this is dealt on the first turn."
discard.push(card);
events.push({ type: "trapRedrawnDuringDeal", player: p.id });
} else {
p.hand.push(card);
}
}
events.push({ type: "cardsDealt", player: p.id, count: p.hand.length });
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
}
const dieRolls: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p.id, []]));
let contenders = players.map((_, i) => i);
let firstIndex = contenders[0]!;
while (contenders.length > 1) {
const rolls = new Map<number, number>();
for (const i of contenders) {
const [roll, next] = rollDie(rng);
rng = next;
rolls.set(i, roll);
dieRolls[players[i]!.id]!.push(roll);
}
const high = Math.max(...rolls.values());
contenders = contenders.filter((i) => rolls.get(i) === high);
firstIndex = contenders[0]!;
}
const state: GameState = {
config,
phase: "playing",
board,
edgeOverrides: {},
wallDamage: {},
slimeTraps: {},
wardPending: null,
chaosPending: null,
doorStates: {},
openDoorEdges: [],
heldDoors: [],
createdEdges: {},
squareContents: {},
groundObjects: {},
lastSpellUsed: {},
illusionWalls: {},
creatures: [],
nextCreatureId: 1,
wandCharges: {},
tempWarpEdges: [],
boobytraps: [],
gluedCells: {},
openSafes: [],
enchantedObjects: {},
dimWarps: [],
outOfTurnWindow: null,
ambushes: [],
nextAmbushId: 1,
players,
treasures,
sustained: [],
deck,
discard,
turn: {
round: 1,
firstIndex,
activeIndex: firstIndex,
movementAllowance: BASE_MOVEMENT,
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
wandsUsed: [],
movementAddUsed: false,
attackForbidden: false,
actionsEnded: false,
},
stack: null,
rng,
winner: null,
winReason: null,
pendingDiscard: null,
nextEffectId: 1,
};
events.unshift({
type: "gameStarted",
players: config.playerIds,
firstPlayer: players[firstIndex]!.id,
dieRolls,
placements: board.placements,
homes: board.homes,
});
events.push({ type: "turnStarted", player: players[firstIndex]!.id, round: 1 });
return { state, events };
}
// ---------------------------------------------------------------------------
// Command application
export function applyCommand(state: GameState, playerId: PlayerId, command: Command): CommandResult {
const result = applyCommandInner(state, playerId, command);
if (result.ok) sweepHeldDoors(result.state, result.events);
return result;
}
function applyCommandInner(state: GameState, playerId: PlayerId, command: Command): CommandResult {
if (state.phase !== "playing") return err("game is over");
// WARD's moment: the grab hangs while the treasure's owner decides.
if (state.wardPending) {
if (playerId !== state.wardPending.ownerId) return err("waiting on the treasure's owner");
if (command.type !== "wardChoice") return err("play your Ward or let them go");
return doWardChoice(state, command.play);
}
if (state.pendingDiscard) {
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
if (command.type !== "discard") return err("you must discard down to the hand limit first");
return doDiscard(state, playerId, command.instanceIds);
}
if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass");
}
if (state.chaosPending) {
const head = state.chaosPending.queue[0];
if (playerId !== head) return err("waiting for another player to face the chaos");
if (command.type === "counteract") {
const st = clone(state);
const p = st.players.find((q) => q.id === playerId)!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
if (!card) return err("card not in hand");
if (card.cardId !== "full-shield") return err("only FULL SHIELD keeps your hand out of the chaos");
takeFromHand(p, command.instanceId);
st.discard.push(card);
st.chaosPending!.excluded.push(playerId);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [{ type: "chaosShielded", player: playerId }];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
if (command.type === "pass") {
const st = clone(state);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
return err("chaos is coming — shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
if (state.outOfTurnWindow) {
if (playerId !== state.outOfTurnWindow.playerId) {
return err("an interruption is being resolved");
}
if (command.type === "pass") {
const st = clone(state);
st.outOfTurnWindow = null;
return { ok: true, state: st, events: [] };
}
if (command.type !== "cast" && command.type !== "punch") {
return err("use your interruption (cast or punch) or pass");
}
const st = clone(state);
const idx = st.players.findIndex((p) => p.id === playerId);
const saved = { ...st.turn };
st.turn = {
...st.turn,
activeIndex: idx,
attackUsed: false,
secondAttackUsed: false,
attackForbidden: false,
actionsEnded: false,
};
const kind = st.outOfTurnWindow!.kind;
// OPPORTUNITY FIRE permits an attack; INTERRUPT any one spell.
if (kind === "opportunity-fire" && command.type === "cast") {
const p = st.players[idx]!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
const fx = card ? CARD_EFFECTS[card.cardId] : undefined;
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
}
if (kind === "interrupt" && command.type === "punch") {
return err("interrupt lets you cast a spell, not brawl");
}
st.outOfTurnWindow = null;
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
if (!result.ok) return result; // the window stays open in `state`
const out = result.state;
out.turn = {
...saved,
round: out.turn.round,
};
return { ok: true, state: out, events: result.events };
}
if (activePlayer(state).id !== playerId) {
// Playing INTERRUPT or OPPORTUNITY FIRE out of turn opens a window.
if (command.type === "cast" && !state.stack) {
const p = state.players.find((q) => q.id === playerId && q.alive);
const card = p?.hand.find((c) => c.instanceId === command.instanceId);
if (p && card && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
if (state.turn.round === 1) return err("no combat during the first round of turns");
const castBlock = castingBlocked(state, playerId);
if (castBlock) return err(castBlock);
const st = clone(state);
const pp = st.players.find((q) => q.id === playerId)!;
const taken = takeFromHand(pp, command.instanceId)!;
st.discard.push(taken);
st.outOfTurnWindow = { playerId, kind: card.cardId as "interrupt" | "opportunity-fire" };
st.lastSpellUsed[playerId] = card.cardId;
return {
ok: true,
state: st,
events: [{ type: "outOfTurnWindow", player: playerId, kind: st.outOfTurnWindow!.kind }],
};
}
}
return err("not your turn");
}
switch (command.type) {
case "move": return doMove(state, command.direction, command.over === true);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "tearTreasure": return doTearTreasure(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side);
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
case "armWard": return doArmWard();
case "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command);
case "cancelAmbush": return doCancelAmbush(state, command.ambushId);
case "counteract": return err("nothing to counteract");
case "pass": return err("nothing to pass on");
case "pickUpTreasure": return doPickUpTreasure(state, command.treasureId);
case "pickUpObject": return doPickUpObject(state, command.instanceId);
case "dropObject": return doDropObject(state, command.instanceId);
case "dropTreasure": return doDropTreasure(state);
case "discard": return doDiscard(state, playerId, command.instanceIds);
case "endTurn": return doEndTurn(state, command.draw);
}
}
function err(error: string): CommandResult {
return { ok: false, error };
}
export function activePlayer(state: GameState): PlayerState {
return state.players[state.turn.activeIndex]!;
}
function clone(state: GameState): GameState {
return structuredClone(state);
}
/** Everything an eliminated wizard leaves behind goes with them.
* "If you die, any monster controlled by you immediately disappears." */
function sweepEliminated(state: GameState, playerId: PlayerId): void {
state.sustained = state.sustained.filter((s) => s.targetId !== playerId && s.casterId !== playerId);
state.creatures = state.creatures.filter((c) => c.controllerId !== playerId);
for (const a of state.ambushes.filter((a) => a.ownerId === playerId)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== playerId);
}
function requireActionsAvailable(state: GameState): string | null {
if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object";
return null;
}
function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
const idx = p.hand.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return null;
const card = p.hand.splice(idx, 1)[0]!;
p.displayed = p.displayed.filter((id) => id !== instanceId);
return card;
}
// --- Movement ---------------------------------------------------------------
/**
* FEAR's measure — FAQ: "as a Wizard walks (if he could walk through
* walls), not diagonally." Orthogonal steps, walls ignored — and the maze
* wraps, so stepping off one rim continues from the opposite one.
* Exported so the client draws the aura by the same yardstick.
*/
export function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
const dx = Math.abs(a.x - b.x);
const dy = Math.abs(a.y - b.y);
return Math.min(dx, board.width - dx) + Math.min(dy, board.height - dy);
}
/**
* FEAR: "no player or monster will move to within 3 spaces of you." True
* for every WILLING move — walking, warp steps, self-teleports, commanded
* monsters. Forced movement such as knockback or a wave is not willing
* and passes.
*/
function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean {
const board = boardView(state);
for (const other of state.players) {
if (!other.alive || other.id === moverId) continue;
if (sustainedOn(state, other.id, "fear").length === 0) continue;
const d = dreadDistance(board, other.position, to);
const dBefore = dreadDistance(board, other.position, from);
if (d <= 3 && d < dBefore) {
// Approaching from outside the dread is never willing.
if (dBefore > 3) return true;
// Already caged inside it: the maze's corners may force a step
// that closes the crow-flies distance — permitted only when the
// step walks the shortest way OUT of the bubble.
const eFrom = escapeSteps(state, board, other.position, from);
const eTo = escapeSteps(state, board, other.position, to);
if (!(eTo < eFrom)) return true;
}
}
return false;
}
/**
* Walking steps (walls and shut doors respected, warp mouths crossed,
* fire braved) from `start` to the nearest cell beyond a dread bubble
* centered on `dreadFrom`. Infinity when no way out exists.
*/
function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number {
if (dreadDistance(board, dreadFrom, start) > 3) return 0;
const passable = (cell: Cell, side: Side): Cell | null => {
const key = edgeKey(cell, side);
const e = board.edges[key] ?? "open";
if (e === "wall") return null;
if (e === "door" &&
!state.openDoorEdges.includes(key) &&
!state.heldDoors.some((h) => h.key === key)) return null;
let n = neighbor(cell, side);
if (!board.cells[cellKey(n)]) {
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
if (!w) return null;
n = w.to.cell;
}
if (state.squareContents[cellKey(n)]?.kind === "stone") return null;
return n;
};
const seen = new Set<string>([cellKey(start)]);
const queue: [Cell, number][] = [[start, 0]];
while (queue.length > 0) {
const [cur, d] = queue.shift()!;
for (const side of SIDES) {
const n = passable(cur, side);
if (!n || seen.has(cellKey(n))) continue;
if (dreadDistance(board, dreadFrom, n) > 3) return d + 1;
seen.add(cellKey(n));
queue.push([n, d + 1]);
}
}
return Infinity;
}
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
const mover = activePlayer(prev);
if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa");
if (isLockedInPlace(prev, mover.id)) return err("you are locked in place");
const state = clone(prev);
const p = activePlayer(state);
const events: GameEvent[] = [];
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement."
if (isBlinded(state, p)) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
}
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
{
const key = edgeKey(p.position, direction);
const shimmer = state.illusionWalls[key];
if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("the wall shimmers oddly — test your eyes before you can pass");
}
if (shimmer && illusionBelief(state, p.id, key) === "believes") {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked by wall");
}
}
const view = boardView(state);
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
// KILLER OOZE". The giant strides the hazard square without
// entering it — one point charged here, then the normal step below lands
// him beyond it with every usual arrival effect (and the second point).
if (over) {
const big = sustainedOn(state, p.id, "big-man").length > 0;
if (!big) return err("only a giant steps over hazards");
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
const hop1 = stepTarget(view, p.position, direction);
if (hop1.kind !== "step") return err("nothing to step over that way");
const hazard = state.squareContents[cellKey(hop1.to)]?.kind;
if (hazard !== "pit" && hazard !== "tacks" && hazard !== "ooze") {
return err("you may step over a pit, tacks, or killer ooze");
}
state.turn.movementUsed++;
p.position = hop1.to;
}
const target = stepTarget(view, p.position, direction);
const misted = isMisted(state, p.id);
const from = p.position;
let via: "step" | "warp" | "passWall";
let crossedFirewall = false;
if (target.kind === "blocked") {
const key = edgeKey(p.position, direction);
const edge = view.edges[key] ?? "open";
const dest = neighbor(p.position, direction);
if (!view.cells[cellKey(dest)]) {
// A fire on a warp mouth: the wraparound corridor still runs —
// through flame.
const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined;
if (!warp) {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked");
}
p.position = { ...warp.to.cell };
via = "warp";
crossedFirewall = true;
} else if (edge === "door" &&
(doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) {
// The displayed MASTER KEY turns in every lock it meets: the walker
// passes without another cast, and the door RELOCKS BEHIND THEM at
// once — "door relocks behind you" grants no lingering opening, so
// bystanders see only a shut door (the bearer's own threshold peek
// rides the key itself). A JAMmed LOCK still refuses it. To hold a
// door open, cast the key at it as ever.
if (!doorIsOpen(state, key)) {
events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" });
}
p.position = dest;
via = "step";
// "The door will relock behind you": stepping through an unlocked
// door shuts it at the walker's back unless a hand holds it open.
// (Rev 4; earlier games kept their turn-long openings.)
if ((state.config.deckRev ?? 1) >= 4 &&
state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) {
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
events.push({ type: "doorsRelocked", count: 1 });
}
} else if (edge === "firewall") {
// "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY.
p.position = dest;
via = "step";
crossedFirewall = true;
} else if (misted && edge === "door") {
// MIST-BODY: "pass through anything but solid stone or stone walls" —
// doors yes, the maze's stone walls no.
p.position = dest;
via = "passWall";
} else if (edge === "wall" && p.passWallCharges > 0) {
p.passWallCharges--;
p.position = dest;
via = "passWall";
} else if (isBlinded(state, p)) {
return blindBump(state, events, p, direction);
} else {
return err(`blocked by ${target.by}`);
}
} else {
p.position = target.to;
via = target.kind;
}
// Square contents at the destination.
let content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone");
// BIG MAN: nobody enters his square.
for (const other of state.players) {
if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) &&
sustainedOn(state, other.id, "big-man").length > 0) {
p.position = from;
return err("a giant fills that corridor");
}
}
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
// square) down the corridor ahead of you as you move". No
// room to shove them onward means no way forward for the giant either.
if (via === "step" && sustainedOn(state, p.id, "big-man").length > 0) {
const here = cellKey(p.position);
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
if (pushPlayers.length > 0 || pushCreatures.length > 0) {
if (pushPlayers.some((o) => isLockedInPlace(state, o.id))) {
p.position = from;
return err("someone there is stuck fast and cannot be pushed");
}
const shove = stepTarget(view, p.position, direction);
const shoveOk =
shove.kind === "step" &&
state.squareContents[cellKey(shove.to)]?.kind !== "stone" &&
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) &&
sustainedOn(state, o.id, "big-man").length > 0);
if (!shoveOk) {
p.position = from;
return err("no room to push them onward");
}
for (const o of pushPlayers) {
const oFrom = o.position;
o.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
}
for (const c of pushCreatures) {
const cFrom = c.position;
c.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
}
}
}
// FEAR: no one moves within 3 spaces of the fearsome one.
if (fearRepels(state, p.id, from, p.position)) {
p.position = from;
return err("an unnatural dread keeps you away");
}
// CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you
// fall in (2 damage, movement over); otherwise you sail across to the far
// side (if there is open floor there).
if (content?.kind === "pit" && !misted && !p.inPit) {
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
if (roll === 1) {
p.inPit = true;
events.push({ type: "fellInPit", player: p.id, at: p.position });
applyDamage(state, events, p, 2, "pit fall", null, "physical");
state.turn.movementUsed = state.turn.movementAllowance;
checkVictory(state, events);
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
return { ok: true, state, events };
}
const beyond = neighbor(p.position, direction);
const beyondOk =
view.cells[cellKey(beyond)] &&
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
state.squareContents[cellKey(beyond)]?.kind !== "stone";
if (beyondOk) {
const pitCell = p.position;
p.position = beyond;
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond });
content = state.squareContents[cellKey(p.position)];
} else {
// Nowhere to land: teeter back where you started.
p.position = from;
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
}
state.turn.movementUsed++;
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
// WALKING DEAD: 1/2 point per space moved (a full point every two steps).
for (const fx of sustainedOn(state, p.id, "walking-dead")) {
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
if (fx.data.halfSteps % 2 === 0) {
applyDamage(state, events, p, 1, "walking dead", null);
checkVictory(state, events);
}
}
// DISEASE: the carrier infects every other player in a square they enter.
if (p.alive && sustainedOn(state, p.id, "disease").length > 0) {
for (const other of state.players) {
if (!other.alive || other.id === p.id) continue;
if (cellKey(other.position) !== cellKey(p.position)) continue;
applyDamage(state, events, other, 3, "disease", null, "physical");
}
checkVictory(state, events);
}
// Rev 7: sharing a plague square cuts both ways — walking INTO a
// carrier's square catches the disease's bite just the same.
if ((state.config.deckRev ?? 1) >= 7 && p.alive) {
for (const other of state.players) {
if (!other.alive || other.id === p.id) continue;
if (cellKey(other.position) !== cellKey(p.position)) continue;
if (sustainedOn(state, other.id, "disease").length === 0) continue;
applyDamage(state, events, p, 3, "disease", null, "physical");
checkVictory(state, events);
break;
}
}
if (crossedFirewall) {
events.push({ type: "firewallBurned", player: p.id });
const webbed = sustainedOn(state, p.id, "sticky-web").length > 0;
applyDamage(state, events, p, webbed ? 6 : 4, "wall of fire", null);
checkVictory(state, events);
}
// FIRE IMP: scorches the moment a player enters its line of sight.
impCheck(state, events, p.id);
// THORNBUSH: "his turn ends, he loses his following turn, and he takes one
// point of physical damage from thorns." Mist drifts through unharmed.
if (content?.kind === "thornbush" && p.alive && !misted) {
events.push({ type: "enteredThornbush", player: p.id, at: p.position });
applyDamage(state, events, p, 1, "thorns", null, "physical");
p.lostTurns++;
state.turn.actionsEnded = true;
checkVictory(state, events);
}
// ROSEBUSH: 3 points passing through (no turn loss).
if (content?.kind === "rosebush" && p.alive && !misted) {
applyDamage(state, events, p, 3, "rosebush thorns", null, "physical");
checkVictory(state, events);
}
// HANDFUL OF TACKS: 3 points crossing them.
if (content?.kind === "tacks" && p.alive && !misted) {
events.push({ type: "steppedOnTacks", player: p.id, at: p.position });
applyDamage(state, events, p, 3, "tacks", null, "physical");
checkVictory(state, events);
}
// KILLER OOZE: 1 point on entry; roll 1-2 to slip — drop treasure, 2 more
// points, and movement is over.
if (content?.kind === "ooze" && p.alive && !misted) {
applyDamage(state, events, p, 1, "acidic ooze", null, "physical");
if (p.alive) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
if (roll <= 2) {
p.fallenInOoze = true;
events.push({ type: "slippedInOoze", player: p.id, at: p.position });
if (p.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
t.carriedBy = null;
t.position = p.position;
p.carriedTreasureId = null;
events.push({ type: "treasureDropped", player: p.id, treasureId: t.id, at: p.position, onHomeOf: homeOwnerAt(state, p.position) });
}
applyDamage(state, events, p, 2, "ooze fall", null, "physical");
state.turn.movementUsed = state.turn.movementAllowance;
}
}
checkVictory(state, events);
}
// FILL SQUARE WITH SLIME: entering ends your turn's actions — and any
// spell stuck in the gel goes off at the visitor.
if (content?.kind === "slime" && p.alive && !misted) {
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
state.turn.actionsEnded = true;
springSlimeTrap(state, events, p);
}
// IDIOT lifts when the victim reaches their own treasure.
if (sustainedOn(state, p.id, "idiot").length > 0) {
const onOwn = state.treasures.some(
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
);
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
if (onOwn || allCarried) {
for (const fx of sustainedOn(state, p.id, "idiot")) {
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
}
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
}
}
// Armed ambushes may spring on this step.
checkAmbushes(state, events, p, { movedFrom: from });
// BOOBYTRAP: the real token detonates under anyone but its caster; a
// blank token is flipped by the step that finds it and leaves the board
// alone — each decoy dies its own death.
for (const trap of [...state.boobytraps]) {
if (trap.casterId === p.id) continue;
const here = cellKey(p.position);
if (here === trap.realKey) {
state.boobytraps = state.boobytraps.filter((t) => t !== trap);
events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
applyDamage(state, events, p, 4, "boobytrap", null, "physical");
checkVictory(state, events);
} else if (trap.cells.some((c) => cellKey(c) === here)) {
trap.cells = trap.cells.filter((c) => cellKey(c) !== here);
events.push({ type: "boobytrapBlank", player: p.id, at: p.position });
}
}
return { ok: true, state, events };
}
function doWarpStep(prev: GameState): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
const state = clone(prev);
const p = activePlayer(state);
if (isLockedInPlace(state, p.id)) return err("you are locked in place");
const here = cellKey(p.position);
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
if (!pair) return err("you are not standing on a warp token");
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
const from = p.position;
if (fearRepels(state, p.id, from, dest)) {
return err("an unnatural dread keeps you away");
}
p.position = { ...dest };
state.turn.movementUsed++;
return { ok: true, state, events: [{ type: "warpStepped", player: p.id, from, to: p.position }] };
}
function doPlayNumberForMovement(prev: GameState, instanceId: string, addInstanceId?: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const mover = activePlayer(prev);
if (sustainedOn(prev, mover.id, "slow").length > 0) {
return err("you are slowed — no number cards for movement");
}
// "You may add two NUMBER cards together for any single action" — an ADD
// permits one extra movement number this turn.
if (prev.turn.numberPlayedForMovement) {
if (!addInstanceId) return err("only one number card may boost movement per turn (ADD permits a second)");
if (prev.turn.movementAddUsed) return err("ADD already joined two numbers to your movement");
}
const state = clone(prev);
const p = activePlayer(state);
if (state.turn.numberPlayedForMovement && addInstanceId) {
const addCard = p.hand.find((c) => c.instanceId === addInstanceId);
if (!addCard || addCard.cardId !== "add") return err("ADD card not in hand");
takeFromHand(p, addInstanceId);
state.discard.push(addCard);
state.turn.movementAddUsed = true;
}
const card = takeFromHand(p, instanceId);
if (!card) return err("card not in hand");
if (!isNumberCard(card.cardId)) return err("not a number card");
const value = numberValue(card.cardId) + (displays(p, "powerstone") ? 1 : 0);
state.discard.push(card);
state.turn.movementAllowance += value;
state.turn.numberPlayedForMovement = true;
return {
ok: true,
state,
events: [{
type: "numberPlayedForMovement",
player: p.id,
card,
value,
newAllowance: state.turn.movementAllowance,
}],
};
}
// --- Attacks and the casting stack ------------------------------------------
function attackPreconditions(state: GameState): string | null {
const blocked = requireActionsAvailable(state);
if (blocked) return blocked;
if (state.turn.round === 1) return "no combat during the first round of turns";
if (state.turn.attackUsed) {
// ADRENALINE: "Allows two attacks in one turn."
const active = activePlayer(state);
if (sustainedOn(state, active.id, "adrenaline").length > 0 && !state.turn.secondAttackUsed) {
return null;
}
return "you may attack only once per turn";
}
if (state.turn.attackForbidden) return "you are slowed — no attack this turn";
return null;
}
/**
* IDIOT's FAQ carves out spells that "help you in your goal" of reaching the
* treasure — barrier-removal, passage, and movement boosts. Attacks and
* everything aimed at other players stay forbidden.
*/
const IDIOT_AIDS = new Set([
"destroy-wall", "pass-through-wall", "teleport", "power-run", "mad-dash",
"speed", "mist-body", "stone-to-water", "create-door",
]);
function castingBlocked(
state: GameState, playerId: PlayerId,
opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: string },
): string | null {
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
// needed)" — and per the FAQ, spells that aid the march to the treasure.
if (sustainedOn(state, playerId, "idiot").length > 0 &&
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
// DROP OBJECT that shakes YOUR OWN treasure out of a thief's arms
// serves the march — while carried, that treasure cannot even be
// stood upon, so without this the curse can be unsatisfiable.
const freesOwnGold =
opts?.cardId === "drop-object" && opts.pickedCardId === "treasure" &&
opts.targetPlayerId != null &&
state.treasures.some((t) => t.owner === playerId && t.carriedBy === opts.targetPlayerId);
if (!freesOwnGold) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
}
return null;
}
/**
* IDIOT forbids handling items and attacking players, not just casting:
* "it does not allow you to attack players or pick up other items".
* Dropping is the sharp edge — an idiot could otherwise capture a stolen
* treasure on their own home, or drop their own treasure underfoot for an
* instant cure.
*/
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
if (sustainedOn(state, playerId, "idiot").length > 0) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
return null;
}
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
if (sustainedOn(state, target.id, "big-man").length > 0 &&
cellKey(attacker.position) === cellKey(target.position)) {
return "he fills the corridor — there is no room to swing";
}
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
// BUDDY: "Opponent will not attack you unless you attack first."
const buddy = state.sustained.find(
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
);
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
return null;
}
/**
* "A wall takes 20 points of damage to destroy; a door takes 15." Damage
* accumulates across turns and players; at the threshold the edge opens.
*/
function damageWall(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
cell: Cell,
side: Side,
amount: number,
source: string,
): string | null {
const view = boardView(state);
const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open";
if (current !== "wall" && current !== "door") {
return "only walls, doors, and thornbushes can be attacked";
}
const needed = current === "door" ? 15 : 20;
const total = (state.wallDamage[key] ?? 0) + amount;
events.push({ type: "wallDamaged", player: attacker.id, edge: { cell, side }, amount, total, needed, source });
if (total >= needed) {
delete state.wallDamage[key];
state.edgeOverrides[key] = "open";
delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "wallDestroyed", caster: attacker.id, edge: { cell, side }, wasDoor: current === "door" });
} else {
state.wallDamage[key] = total;
}
return null;
}
/** The edge must border the wizard's own square. */
function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
}
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
function doWardChoice(prev: GameState, play: boolean): CommandResult {
const state = clone(prev);
const wp = state.wardPending!;
state.wardPending = null;
const events: GameEvent[] = [];
if (play) {
const owner = state.players.find((q) => q.id === wp.ownerId)!;
const taker = state.players.find((q) => q.id === wp.takerId)!;
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
if (wardIdx === -1) return err("the Ward is no longer in your hand");
const [card] = owner.hand.splice(wardIdx, 1);
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
applyDamage(state, events, taker, 3, "warded treasure", null);
checkVictory(state, events);
}
return { ok: true, state, events };
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** Compat stub for stale clients: the Ward has no arming step. */
function doArmWard(): CommandResult {
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
}
/** Player ids in seat order, starting after `fromId`. */
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
const ids = state.players.map((p) => p.id);
const at = ids.indexOf(fromId);
return [...ids.slice(at + 1), ...ids.slice(0, at + 1)];
}
/** "Everyone tosses them in a pile" — except those FULL SHIELD sat out. */
function scrambleHands(state: GameState, events: GameEvent[], casterId: PlayerId, excluded: PlayerId[]): void {
const players = state.players.filter((p) => p.alive && !excluded.includes(p.id));
const counts = players.map((p) => p.hand.length);
const pile = players.flatMap((p) => p.hand.splice(0));
for (const p of players) p.displayed = [];
const [shuffled, rngNext] = shuffle(state.rng, pile);
state.rng = rngNext;
let i = 0;
players.forEach((p, pi) => {
p.hand = shuffled.slice(i, i + counts[pi]!);
i += counts[pi]!;
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
});
events.push({ type: "handsScrambled", caster: casterId });
}
function finishChaosIfReady(state: GameState, events: GameEvent[]): void {
const pending = state.chaosPending;
if (!pending || pending.queue.length > 0) return;
state.chaosPending = null;
scrambleHands(state, events, pending.casterId, pending.excluded);
}
/**
* TEST AN ILLUSION: a free act on your turn. Standing beside
* the shimmer or seeing it, you roll your 50% doubt — the wall then either
* hardens (for you) or dissolves (for you). Nobody else's eyes are moved by
* your verdict, or by anyone strolling through it.
*/
function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
const key = edgeKey(cell, side);
const wall = state.illusionWalls[key];
if (!wall) return err("there is no illusion there");
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = [];
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
if (!isAdjacentToEdge(p.position, cell, side) &&
!losToEdge(board, p.position, cell, side)) {
return err("you cannot see that wall from here");
}
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const result = roll <= 2 ? "seesThrough" : "believes";
state.illusionWalls[key]!.belief[p.id] = result;
events.push({ type: "illusionTested", player: p.id, edge: key, result });
return { ok: true, state, events };
}
/**
* STRENGTH's grip: "physically tear a treasure out of the grasp of another
* player if you occupy the same space (this would be an attack). The other
* player must roll a 1 on the D4 to retain the treasure." The struggle is
* bodily — the defender's D4 is the defense, not a counteraction window.
* FAQ: tearing is not picking up an object (the turn's actions continue),
* and a wizard whose arms are full — or too weak to carry — tears it loose
* onto the floor instead.
*/
function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (pre) return err(pre);
const state = clone(prev);
const attacker = activePlayer(state);
if (sustainedOn(state, attacker.id, "strength").length === 0) {
return err("only STRENGTH lets you tear a treasure from a wizard's grasp");
}
if (targetId === attacker.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === targetId);
if (!target || !target.alive) return err("no such living player");
if (cellKey(target.position) !== cellKey(attacker.position)) {
return err("you must be in the same square to tear it away");
}
if (!target.carriedTreasureId) return err("they carry no treasure");
const bushOrMist = attackBlockedByStatus(state, attacker, target);
if (bushOrMist) return err(bushOrMist);
state.sustained = state.sustained.filter(
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
);
const events: GameEvent[] = [];
// BLIND: same convention as a punch — the grab finds its grip on a 1.
if (isBlinded(state, attacker)) {
const roll = rollD4(state, events, attacker.id, "a blind grab — only a 1 finds the grip");
if (roll !== 1) {
state.turn.attackUsed = true;
events.push({
type: "attackMisdirected", attacker: attacker.id, intended: target.id,
rolledDirection: SIDES[roll - 1]!, newTarget: null,
});
return { ok: true, state, events };
}
}
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
// Rev 6: "this would be an attack" — the grab opens a counteraction
// window like any punch; the clutch roll waits for the counters.
if ((state.config.deckRev ?? 1) >= 6) {
state.stack = {
attackerId: attacker.id,
defenderId: target.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: "physical",
counters: [],
waitingOn: target.id,
tearTreasure: true,
};
events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id });
return { ok: true, state, events };
}
executeTear(state, events, attacker, target);
return { ok: true, state, events };
}
/** The clutch roll and the wrestling itself, once nothing stands in the way. */
function executeTear(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
target: PlayerState,
): void {
const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it");
if (roll === 1) {
events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id });
return;
}
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
target.carriedTreasureId = null;
const laden = attacker.carriedTreasureId != null ||
sustainedOn(state, attacker.id, "weakness").length > 0;
if (laden) {
t.carriedBy = null;
t.position = { ...attacker.position };
events.push({ type: "treasureTorn", attacker: attacker.id, defender: target.id, treasureId: t.id, toFloor: true });
events.push({
type: "treasureDropped", player: attacker.id, treasureId: t.id,
at: attacker.position, onHomeOf: homeOwnerAt(state, attacker.position),
});
} else {
t.carriedBy = attacker.id;
t.position = null;
attacker.carriedTreasureId = t.id;
events.push({ type: "treasureTorn", attacker: attacker.id, defender: target.id, treasureId: t.id, toFloor: false });
}
// WARD guards the treasure against ANY seizure: its owner's window opens.
const owner = state.players.find((q) => q.id === t.owner);
if (owner && owner.alive && owner.id !== attacker.id) {
if (owner.hand.some((c) => c.cardId === "ward")) {
state.wardPending = { ownerId: owner.id, takerId: attacker.id };
events.push({ type: "wardWindow", owner: owner.id, victim: attacker.id });
}
}
checkVictory(state, events);
}
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
const pre = attackPreconditions(prev);
if (pre) return err(pre);
const state = clone(prev);
const attacker = activePlayer(state);
if (!touchesEdge(attacker.position, cell, side)) {
return err("you must stand beside the wall to punch it");
}
const events: GameEvent[] = [];
const problem = damageWall(state, events, attacker, cell, side, 1, "punch");
if (problem) return err(problem);
state.turn.attackUsed = true;
return { ok: true, state, events };
}
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (pre) return err(pre);
const state = clone(prev);
const attacker = activePlayer(state);
if (targetId === attacker.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === targetId);
if (!target || !target.alive) return err("no such living player");
if (cellKey(target.position) !== cellKey(attacker.position)) {
return err("you must be in the same square to punch");
}
const bushOrMist = attackBlockedByStatus(state, attacker, target);
if (bushOrMist) return err(bushOrMist);
state.sustained = state.sustained.filter(
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
);
// BLIND: "...engage in combat..." — a blinded brawler flails on a die
// roll; the swing connects only on a 1 (same convention as INVISIBLE).
const preRoll: GameEvent[] = [];
if (isBlinded(state, attacker)) {
const roll = rollD4(state, preRoll, attacker.id, "a blind swing — only a 1 connects");
if (roll !== 1) {
state.turn.attackUsed = true;
return {
ok: true,
state,
events: [...preRoll, {
type: "attackMisdirected", attacker: attacker.id, intended: target.id,
rolledDirection: SIDES[roll - 1]!, newTarget: null,
}],
};
}
}
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.stack = {
attackerId: attacker.id,
defenderId: target.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: "physical",
counters: [],
waitingOn: target.id,
};
return {
ok: true,
state,
events: [...preRoll, { type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }],
};
}
interface CastConsumables {
numbers: CardInstance[];
amplifies: CardInstance[];
add: CardInstance | null;
extend: CardInstance | null;
aroundCorner: CardInstance | null;
powerAttack: CardInstance | null;
powerAttackPoints: number;
magnitude: Magnitude;
}
/** Validate and gather the number/modifier cards attached to a cast. */
function gatherModifiers(
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
): CastConsumables | string {
const numberIds = [...(cmd.numberInstanceIds ?? [])];
if (cmd.numberInstanceId && !numberIds.includes(cmd.numberInstanceId)) {
numberIds.push(cmd.numberInstanceId);
}
const find = (id: string) => caster.hand.find((c) => c.instanceId === id);
const numbers: CardInstance[] = [];
for (const id of numberIds) {
const c = find(id);
if (!c) return "number card not in hand";
if (!isNumberCard(c.cardId)) return "that is not a number card";
numbers.push(c);
}
let add: CardInstance | null = null;
if (cmd.addInstanceId) {
const c = find(cmd.addInstanceId);
if (!c || c.cardId !== "add") return "ADD card not in hand";
add = c;
}
// "Only one NUMBER card can be played per action" — ADD permits two.
if (numbers.length > (add ? 2 : 1)) {
return add ? "ADD permits at most two number cards" : "only one number card per action (use ADD for two)";
}
const amplifies: CardInstance[] = [];
for (const id of cmd.amplifyInstanceIds ?? []) {
const c = find(id);
if (!c || c.cardId !== "amplify") return "AMPLIFY card not in hand";
amplifies.push(c);
}
if (amplifies.length > 2) return "at most two AMPLIFY cards may be combined";
let extend: CardInstance | null = null;
if (cmd.extendInstanceId) {
const c = find(cmd.extendInstanceId);
if (!c || c.cardId !== "extend") return "EXTEND card not in hand";
extend = c;
}
let aroundCorner: CardInstance | null = null;
if (cmd.aroundCornerInstanceId) {
const c = find(cmd.aroundCornerInstanceId);
if (!c || c.cardId !== "around-the-corner") return "AROUND THE CORNER card not in hand";
aroundCorner = c;
}
let powerAttack: CardInstance | null = null;
let powerAttackPoints = 0;
if (cmd.powerAttackInstanceId) {
const c = find(cmd.powerAttackInstanceId);
if (!c || c.cardId !== "power-attack") return "POWER ATTACK card not in hand";
const pts = cmd.powerAttackPoints ?? 0;
if (!Number.isInteger(pts) || pts < 1) return "choose how many life points to burn";
if (pts >= caster.life) return "that would kill you";
powerAttack = c;
powerAttackPoints = pts;
}
// POWERSTONE: "Add 1 to any NUMBER card played."
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
const sum = numbers.length > 0
? numbers.reduce((t, c) => t + numberValue(c.cardId), 0) + stoneBonus
: null;
const amp = 2 ** amplifies.length;
const ext = extend ? 2 : 1;
return {
numbers,
amplifies,
add,
extend,
aroundCorner,
powerAttack,
powerAttackPoints,
magnitude: {
numberValue: sum,
power: (sum ?? 1) * amp,
duration: (sum ?? 1) * amp * ext,
},
};
}
function consumeCast(
state: GameState,
caster: PlayerState,
card: CardInstance,
mods: CastConsumables,
keepInHand: boolean,
): void {
// (Wand cards manage their own lifetime via charges — callers pass
// keepInHand=true for them and never discard here.)
if (keepInHand) {
if (caster.hand.some((c) => c.instanceId === card.instanceId) &&
!caster.displayed.includes(card.instanceId)) {
caster.displayed.push(card.instanceId);
}
} else {
takeFromHand(caster, card.instanceId);
state.discard.push(card);
}
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner, mods.powerAttack]) {
if (!c) continue;
takeFromHand(caster, c.instanceId);
state.discard.push(c);
}
}
function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const caster = activePlayer(state);
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId);
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") {
return err(`${def.name} is a counteraction — play it in response to an attack`);
}
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
}
if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) {
return err("the key is already on display");
}
// Magic wands: charged on first use by the number card(s) played; one
// charge per use, one use per turn; discarded when the last charge goes.
const WANDS = new Set<string>(WAND_CARD_IDS);
const isWand = WANDS.has(inHand.cardId);
if (isWand && state.turn.wandsUsed.includes(inHand.instanceId)) {
return err("any wand operates a maximum of once per turn");
}
// "Some cards involve physical actions, like picking locks, removing
// locks, jamming locks, and throwing daggers. These are not spells."
// (PICK LOCK's 6e face: "This is not a spell.")
const NOT_SPELLS = new Set([
"pick-lock", "master-key", "jam-lock", "remove-lock", "dagger", "large-rock",
]);
// "This isolation of the wand from you allows you to use it even with
// NO SPELL cast on you."
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
if (isSpell) {
const castBlock = castingBlocked(state, caster.id, {
cardId: inHand.cardId,
targetPlayerId: cmd.target?.kind === "player" ? cmd.target.playerId : undefined,
pickedCardId: cmd.params?.cardId,
});
if (castBlock) return err(castBlock);
} else if (sustainedOn(state, caster.id, "medusa").length > 0) {
return err("you are paralyzed by Medusa");
}
// Thrown objects are attacks on players — not among IDIOT's permitted aids.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const ib = idiotBlocked(state, caster.id);
if (ib) return err(ib);
}
const mods = gatherModifiers(caster, cmd);
if (typeof mods === "string") return err(mods);
/** Set charges on first use, spend one, discard the wand when empty. */
const spendWandCharge = (st: GameState, wielder: PlayerState, events: GameEvent[]): string | null => {
if (!isWand) return null;
let charges = st.wandCharges[inHand.instanceId];
if (charges === undefined) {
// "ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is
// only 1" — a bare first use lights a single charge.
charges = mods.magnitude.power; // 1 without a number; AMPLIFY and ADD work here
st.wandCharges[inHand.instanceId] = charges;
if (!wielder.displayed.includes(inHand.instanceId)) wielder.displayed.push(inHand.instanceId);
events.push({ type: "wandCharged", player: wielder.id, card: inHand, charges });
}
charges -= 1;
st.turn.wandsUsed.push(inHand.instanceId);
if (charges <= 0) {
delete st.wandCharges[inHand.instanceId];
takeFromHand(wielder, inHand.instanceId);
st.discard.push(inHand);
events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: 0 });
events.push({ type: "wandExhausted", player: wielder.id, card: inHand });
} else {
st.wandCharges[inHand.instanceId] = charges;
events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: charges });
}
return null;
};
// DISEASE (rev 7): "You're the carrier!" — a self-cast plague on fear's
// pattern, no target and no counteraction window ("REFLECTIONs have no
// effect against this"). Older games cast it at a victim through the
// stack; their ledgers replay the legacy attack path below.
if (inHand.cardId === "disease" && (state.config.deckRev ?? 1) >= 7) {
consumeCast(state, caster, inHand, mods, false);
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events };
}
if (effect.kind === "attack") {
const pre = attackPreconditions(state);
if (pre) return err(pre);
// Attacks may target a creature: damage applies directly (monsters play
// no counteractions).
if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
return err("you must be in the same square");
}
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position)
: casterLos(state, caster, caster.position, creature.position))) {
return err("no line of sight to the creature");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position,
}];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
destroyCreature(state, events, creature, "waterbolt");
return { ok: true, state, events };
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (creature.kind === "fire-imp") {
events.push({ type: "creatureDamaged", creatureId: creature.id, kind: creature.kind, amount: 0, source: inHand.cardId, damageTotal: creature.damage });
} else if (dmg > 0) {
damageCreature(state, events, creature, dmg, inHand.cardId);
}
return { ok: true, state, events };
}
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
// washes the slime away instead.
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell;
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell)
: casterLos(state, caster, caster.position, cell))) {
return err("no line of sight to the slime");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events2: GameEvent[] = [...wandEvents];
if (inHand.cardId === "waterbolt" && mods.magnitude.power >= 5) {
delete state.squareContents[cellKey(cell)];
delete state.slimeTraps[cellKey(cell)];
events2.push({ type: "slimeWashed", cell });
return { ok: true, state, events: events2 };
}
// The card leaves the discard and lives in the gel.
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) state.discard.splice(di, 1);
const key = cellKey(cell);
state.slimeTraps[key] = [...(state.slimeTraps[key] ?? []), {
card: inHand, casterId: caster.id,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
}];
events2.push({ type: "spellTrapped", caster: caster.id, cell, cardId: inHand.cardId });
// "affect anyone in the slime": a current occupant springs it at once.
const occupant = state.players.find((p) => p.alive && p.id !== caster.id && cellKey(p.position) === key);
if (occupant) springSlimeTrap(state, events2, occupant);
return { ok: true, state, events: events2 };
}
// "Any attack against an inanimate object counts as your one attack for
// the turn." A wall or door soaks the spell's damage; no counteractions.
if (cmd.target?.kind === "edge") {
const { cell, side } = cmd.target;
const view = boardView(state);
const current = view.edges[edgeKey(cell, side)] ?? "open";
if (current !== "wall" && current !== "door") {
return err("there is no wall or door on that edge to attack");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall");
}
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
return err("no line of sight to the wall");
}
const dmg =
effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length) +
mods.powerAttackPoints;
if (dmg <= 0) return err("that spell cannot harm stonework");
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
if (mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell,
}];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem);
// Thrown weapons clatter to the floor at the foot of the wall.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) {
const [card] = state.discard.splice(di, 1);
state.groundObjects[cellKey(cell)] = [...(state.groundObjects[cellKey(cell)] ?? []), card!];
events2.push({ type: "objectThrown", attacker: caster.id, cardId: inHand.cardId, landedAt: cell });
}
}
return { ok: true, state, events: events2 };
}
// CHAOS aims at no one: cast bare, it sweeps every other living
// wizard into the pile, each offered their FULL SHIELD sit-out in
// turn order. (Full shields only exclude; nothing in the set cancels
// a zero-point storm, so the queue is the whole defense.) The older
// player-targeted form is still accepted below, so stored games
// replay unchanged.
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
consumeCast(state, caster, inHand, mods, false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
state.chaosPending = { casterId: caster.id, excluded: [], queue };
finishChaosIfReady(state, events);
return { ok: true, state, events };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player");
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
return err("you must be in the same square");
}
const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock);
const preEvents: GameEvent[] = [];
if (effect.requiresLos) {
const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position)
: casterLos(state, caster, caster.position, target.position);
if (!sighted) return err("no line of sight to the target");
}
// Attacking someone breaks any BUDDY pact you swore to them.
state.sustained = state.sustained.filter(
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
);
if (effect.validate) {
const problem = effect.validate(state, cmd, caster, target);
if (problem) return err(problem);
}
if (inHand.cardId === "waterbolt") {
const total = mods.magnitude.power;
const d = cmd.params?.damage ?? total;
const k = cmd.params?.knockback ?? 0;
if (d < 0 || k < 0 || d + k !== total) {
return err(`waterbolt damage + knockback must total ${total}`);
}
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
if (mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
}
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
// go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates.
if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) {
const dx = target.position.x - caster.position.x;
const dy = target.position.y - caster.position.y;
const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const rolled = SIDES[roll - 1]!;
if (rolled !== intended) {
const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x;
const py = p.position.y - caster.position.y;
const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position);
});
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const missEvents: GameEvent[] = [...wandEvents, ...preEvents, {
type: "attackMisdirected", attacker: caster.id, intended: target.id,
rolledDirection: rolled, newTarget: along?.id ?? null,
}];
if (!along) return { ok: true, state, events: missEvents }; // dissipates
state.stack = {
attackerId: caster.id,
defenderId: along.id,
attackCard: inHand,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
extendFactor: mods.extend ? 2 : 1,
powerAttackPoints: mods.powerAttackPoints,
params: cmd.params ?? null,
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: along.id,
};
missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position,
});
return { ok: true, state, events: missEvents };
}
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.stack = {
attackerId: caster.id,
defenderId: target.id,
attackCard: inHand,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
extendFactor: mods.extend ? 2 : 1,
powerAttackPoints: mods.powerAttackPoints,
params: cmd.params ?? null,
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
};
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents];
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
events.push({
type: "spellCast",
caster: caster.id,
card: inHand,
cardId: inHand.cardId,
numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue,
amplifies: mods.amplifies.length,
from: caster.position,
target: target.id,
targetCell: target.position,
});
if (effect.keepInHand) {
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
}
return { ok: true, state, events };
}
// Neutral: validate on a preview clone before consuming any cards.
const events: GameEvent[] = [];
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
events.push({
type: "spellCast",
caster: caster.id,
card: inHand,
cardId: inHand.cardId,
numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue,
from: caster.position,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
});
const preview = clone(state);
const problem = (effect as NeutralEffect).resolve(
preview, [], activePlayer(preview), cmd, mods.magnitude,
);
if (problem) return err(problem);
if (isWand) {
const werr = spendWandCharge(state, caster, events);
if (werr) return err(werr);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (effect.keepInHand) {
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
}
if (cardDef(inHand.cardId).cardType !== "object" && inHand.cardId !== "reuse-spell") {
state.lastSpellUsed[caster.id] = inHand.cardId;
}
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
if (result) return err(result); // non-null here means resolve and preview disagree
return { ok: true, state, events };
}
/** Arm an ambush: commit Interrupt/Opportunity Fire + an attack + a trigger. */
function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush" }>): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const owner = activePlayer(state);
const via = owner.hand.find((c) => c.instanceId === cmd.instanceId);
if (!via) return err("card not in hand");
if (via.cardId !== "interrupt" && via.cardId !== "opportunity-fire") {
return err("only Interrupt or Opportunity Fire can spring an ambush");
}
const spell = owner.hand.find((c) => c.instanceId === cmd.spellInstanceId);
if (!spell) return err("the attack to commit is not in your hand");
const fx = CARD_EFFECTS[spell.cardId];
if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush");
if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush");
const numbers: CardInstance[] = [];
for (const id of cmd.numberInstanceIds ?? []) {
const c = owner.hand.find((x) => x.instanceId === id);
if (!c || !isNumberCard(c.cardId)) return err("number card not in hand");
numbers.push(c);
}
if (numbers.length > 1) return err("one number card per action");
// Cell-needing spells commit their destination when the trap is laid.
const needsCell = spell.cardId === "teleport-opponent" || spell.cardId === "mental-force";
if (needsCell) {
if (!cmd.cell) return err("choose where the ambush will send them");
const v = boardView(state);
if (!v.cells[cellKey(cmd.cell)]) return err("that destination is off the board");
if (state.squareContents[cellKey(cmd.cell)]?.kind === "stone") return err("that destination is solid stone");
}
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
return err("choose a trigger: line of sight, close approach, or treasure");
}
// Commit the cards out of the hand; they return if the ambush is cancelled.
takeFromHand(owner, via.instanceId);
takeFromHand(owner, spell.instanceId);
for (const n of numbers) takeFromHand(owner, n.instanceId);
const ambush: AmbushState = {
id: `ambush-${state.nextAmbushId++}`,
ownerId: owner.id,
via,
trigger: cmd.trigger,
spell,
numbers,
...(cmd.cell ? { cell: { ...cmd.cell } } : {}),
};
state.ambushes.push(ambush);
return {
ok: true,
state,
events: [{
type: "ambushSet", visibleTo: owner.id, ambushId: ambush.id,
via: via.cardId, spell: spell.cardId, trigger: cmd.trigger,
}],
};
}
function doCancelAmbush(prev: GameState, ambushId: string): CommandResult {
const state = clone(prev);
const owner = activePlayer(state);
const idx = state.ambushes.findIndex((a) => a.id === ambushId && a.ownerId === owner.id);
if (idx === -1) return err("no such ambush of yours");
const [ambush] = state.ambushes.splice(idx, 1);
const p = state.players.find((q) => q.id === owner.id)!;
p.hand.push(ambush!.via, ambush!.spell, ...ambush!.numbers);
if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id;
return {
ok: true,
state,
events: [{ type: "ambushCancelled", visibleTo: owner.id, ambushId }],
};
}
/**
* After an actor moves (or grabs a treasure), armed ambushes may spring: the
* committed attack fires at the triggering wizard through the normal
* counteraction stack. Fires at most one ambush per check.
*/
function checkAmbushes(
state: GameState,
events: GameEvent[],
actor: PlayerState,
context: { movedFrom?: Cell; pickedUpTreasure?: boolean },
): void {
if (state.stack || state.phase !== "playing") return;
if (state.turn.round === 1) return; // no combat during the first round
for (const ambush of [...state.ambushes]) {
if (ambush.ownerId === actor.id) continue;
const owner = state.players.find((p) => p.id === ambush.ownerId);
if (!owner || !owner.alive || !actor.alive) continue;
if (attackBlockedByStatus(state, owner, actor)) continue;
let sprung = false;
if (ambush.trigger.kind === "treasure") {
sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) {
if (ambush.trigger.kind === "los") {
const before = ambushLos(state, owner, owner.position, context.movedFrom);
const now = ambushLos(state, owner, owner.position, actor.position);
sprung = now && !before;
} else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) =>
Math.abs(owner.position.x - c.x) + Math.abs(owner.position.y - c.y);
sprung = dist(actor.position) <= 1 && dist(context.movedFrom) > 1;
}
}
if (!sprung) continue;
// The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue;
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
const numberTotal = ambush.numbers.length > 0
? ambush.numbers.reduce((t, c) => t + (cardDef(c.cardId).value ?? 0), 0)
: null;
events.push({
type: "ambushSprung", owner: owner.id, victim: actor.id,
via: ambush.via.cardId, spellCardId: ambush.spell.cardId, trigger: ambush.trigger,
});
state.stack = {
attackerId: owner.id,
defenderId: actor.id,
attackCard: ambush.spell,
numberValue: numberTotal,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: ambush.cell ? { cell: { ...ambush.cell } } : null,
kind: fx.physical ? "physical" : "spell",
counters: [],
waitingOn: actor.id,
};
events.push({
type: "spellCast", caster: owner.id, card: ambush.spell, cardId: ambush.spell.cardId,
numberCards: ambush.numbers, numberValue: numberTotal,
from: owner.position, target: actor.id, targetCell: actor.position,
});
return; // one ambush per check; others may spring on later steps
}
}
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster". */
function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void {
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind);
if (dmg > 0 && ctx.attacker.alive &&
sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) {
applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind);
}
}
/** A NUMBER card riding a counteraction sets the raised spell's duration. */
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
const num = (numberInstanceIds ?? [])
.map((id) => player.hand.find((c) => c.instanceId === id))
.find((c): c is CardInstance => c != null && isNumberCard(c.cardId));
if (!num) return 1;
takeFromHand(player, num.instanceId);
state.discard.push(num);
return numberValue(num.cardId);
}
function doCounteract(
prev: GameState, playerId: PlayerId, instanceId: string,
params?: { cell?: Cell; cardId?: string }, numberInstanceIds?: string[],
): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!;
const card = player.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
const def = cardDef(card.cardId);
// "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA);
// NO SPELL blocks all spells too. IDIOT does not — counteractions are the
// one thing its victim is expressly allowed.
const castBlock = castingBlocked(state, playerId, { counteraction: true });
if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS.
if (stack.attackCard?.cardId === "chaos" &&
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
return err("REFLECTIONS have no effect against CHAOS");
}
if (playerId === stack.defenderId) {
if (card.cardId === "absorb-spell") {
if (stack.kind !== "spell") return err("absorb spell only works against spells");
if (stack.attackCard && (WAND_CARD_IDS as readonly string[]).includes(stack.attackCard.cardId)) {
return err("Absorb Spell has no effect on magic wands");
}
takeFromHand(player, instanceId);
state.discard.push(card);
const attackCard = stack.attackCard!;
const di = state.discard.findIndex((c) => c.instanceId === attackCard.instanceId);
if (di !== -1) {
state.discard.splice(di, 1);
} else {
// Displayed attack (WIZARDBLADE): take it from the attacker's hand.
const attacker = state.players.find((p) => p.id === stack.attackerId)!;
takeFromHand(attacker, attackCard.instanceId);
}
player.hand.push(attackCard);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
{ type: "attackAbsorbedIntoHand", player: playerId, attackCard },
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
];
state.stack = null;
if (player.hand.length > handLimit(player)) state.pendingDiscard = player.id;
return { ok: true, state, events };
}
// TELEPORT: "If you use this spell as a Counteraction, the attack has no
// chance of hitting you" (official FAQ). The escape happens at resolution,
// so an ANTI-ANTI can still pin your boots to the floor.
if (card.cardId === "teleport") {
const to = params?.cell;
if (!to) return err("teleport needs a destination cell");
if (isLockedInPlace(state, playerId)) return err("you are locked in place");
const view = boardView(state);
if (!view.cells[cellKey(to)]) return err("destination is off the board");
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
if (wallIgnoringDistance(view, player.position, to) > 4) {
return err("teleport reaches at most four spaces");
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false, cell: to });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
// INVISIBLE (corner: NEUTRAL/COUNTERACTION): vanish as the bolt flies.
// The spell truly goes up — duration from an attached NUMBER card — and
// the attacker's 1-in-4 hit roll happens at resolution.
if (card.cardId === "invisible") {
const duration = counterDuration(state, player, numberInstanceIds);
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
];
attachSustained(state, events, "invisible", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return { ok: true, state, events };
}
// EMPATHY (corner: COUNTERACTION): "Any attack done in any form against
// you acts against both you and the caster" — raised as the blow lands,
// lingering for the NUMBER card's duration after.
if (card.cardId === "empathy") {
const duration = counterDuration(state, player, numberInstanceIds);
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
];
attachSustained(state, events, "empathy", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return { ok: true, state, events };
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
// WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image.
if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") {
const stops = card.cardId === "wall-of-fire" ? "waterbolt" : "fireball";
if (stack.attackCard?.cardId !== stops) {
return err(`as a counteraction, ${cardDef(card.cardId).name} only stops a ${cardDef(stops).name}`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stops }],
};
}
// SHIELDSTONE: "use a NUMBER card as a counteraction against point- or
// duration-based spells, reducing effects by [its] value."
if (isNumberCard(card.cardId)) {
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction";
if (!isCounter || !(card.cardId in CARD_EFFECTS) || CARD_EFFECTS[card.cardId]!.kind !== "counter") {
return err(`${def.name} cannot counteract (or is not implemented yet)`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({
player: playerId, card, nullified: false,
// "FULL REFLECTION lets the other player decide which objects, if
// any, will be swapped" — the reflector's choice rides the counter.
...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}),
});
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
if (playerId === stack.attackerId) {
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
// ABSORB SPELL "will work on other COUNTERACTIONS" — but only ones used
// against YOU: "will work on FULL REFLECTION or REFLECTION, but not FULL
// SHIELD, as that is not used against you" (official FAQ).
if (card.cardId === "absorb-spell") {
if (!targetCounter) return err("no counteraction to absorb");
const id = targetCounter.card.cardId;
if (id !== "reflection" && id !== "full-reflection") {
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
// The absorbed reflection leaves the discard for the absorber's hand.
const di = state.discard.findIndex((c) => c.instanceId === targetCounter.card.instanceId);
if (di !== -1) {
state.discard.splice(di, 1);
player.hand.push(targetCounter.card);
if (player.hand.length > handLimit(player)) state.pendingDiscard = player.id;
}
stack.waitingOn = stack.defenderId;
return {
ok: true,
state,
events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }],
};
}
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
if (!targetCounter) return err("no counteraction to nullify");
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
// (the card face).
if (targetCounter.card.cardId === "invisible" ||
targetCounter.card.cardId === "teleport") {
return err("ANTI-ANTI does not work against escape");
}
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
const events: GameEvent[] = [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }];
if (targetCounter.card.cardId === "empathy") {
// The raised link dies with its counteraction.
const fx = state.sustained.filter((f) => f.cardId === "empathy" && f.targetId === stack.defenderId).pop();
if (fx) {
state.sustained = state.sustained.filter((f) => f !== fx);
events.push({ type: "spellExpired", effectId: fx.id, cardId: "empathy", target: stack.defenderId });
}
}
stack.waitingOn = stack.defenderId;
return { ok: true, state, events };
}
return err("you are not part of this exchange");
}
/** Counters that leave the defender nothing worth adding: the spell is dead,
* escaped, thrown back whole, or drunk as a tonic. */
const TOTAL_STOP_COUNTERS = new Set([
"full-shield", "force-field", "teleport", "full-reflection", "reverse", "waterwall",
]);
function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const events: GameEvent[] = [];
// A self-stack (a democratic monster touching its own creator) has one
// party wearing both hats: their pass RESOLVES — bouncing it "to the
// defender" would hand the exchange back to themselves forever.
const selfStack = stack.attackerId === stack.defenderId;
if (playerId === stack.attackerId && !selfStack) {
// The attacker declining to answer bounces the exchange back so the
// defender may stack further counters — but not past a total stop:
// re-prompting then reads as "your shield failed" and goads the table
// into burning cards against a spell that is already dead.
const totalStop =
stack.counters.some((c) =>
!c.nullified &&
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
c.card.cardId === "wall-of-fire") &&
(stack.kind === "spell" || c.card.cardId === "teleport"));
if (!totalStop) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events };
}
}
resolveStack(state, events);
checkVictory(state, events);
return { ok: true, state, events };
}
function resolveStack(state: GameState, events: GameEvent[]): void {
const stack = state.stack!;
state.stack = null;
const attacker = state.players.find((p) => p.id === stack.attackerId)!;
const defender = state.players.find((p) => p.id === stack.defenderId)!;
const attackId = stack.attackCard?.cardId ?? null;
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
// Creature blows roll the same dice — the cards carve out no exception
// for monsters. A reflected spell returns to its sender unerringly — no
// fresh hit rolls.
const missRolls = !stack.reflectedBase;
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
if (roll !== 1) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
return;
}
}
if (missRolls && sustainedOn(state, defender.id, "shrink").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
if (roll > 2) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
return;
}
}
let base = effect
? effect.baseDamage(stack.numberValue, stack.params)
: stack.creatureId
? (stack.params?.damage ?? 1) // a creature's blow
: stack.tearTreasure
? 0 // the tear wrestles; it does not bruise
: 1; // a punch
// Webs burn: "Any fire damage done to player in webs causes two extra points."
if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) {
base += 2;
}
// STONE DEAD: number x the stones the defender carries — and "carrying"
// means displayed, as with fireball's burning: a hidden stone is a
// secret card, and counting it would broadcast the secret in the
// damage total.
if (attackId === "stone-dead") {
const inPlay = defender.hand.filter((c) =>
isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId));
base = (stack.numberValue ?? 1) * inPlay.length;
}
base *= stack.amplifyFactor;
base += stack.powerAttackPoints;
// SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper.
if (stack.attackCard && state.enchantedObjects[stack.attackCard.instanceId]) {
base += 1;
}
// STRENGTH: "Doubles all physical damage you do to others."
if (stack.kind === "physical" && sustainedOn(state, attacker.id, "strength").length > 0) {
base *= 2;
}
// WEAKNESS: "takes two times normal damage from any point-type spells or
// physical attacks" (Strength and Weakness cancel each other).
{
const weak = sustainedOn(state, defender.id, "weakness").length;
const strong = sustainedOn(state, defender.id, "strength").length;
if (weak > 0 && strong === 0) base *= 2;
}
let baseDuration = effect?.sustains
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
: 0;
// A reflected blow arrives pre-priced: the original exchange already
// applied numbers, amplifies, and surviving counters.
if (stack.reflectedBase) {
base = stack.reflectedBase.damage;
baseDuration = stack.reflectedBase.duration;
}
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
const dealsDamage = base > 0;
const pipe: DamagePipeline = {
damage: base,
duration: baseDuration,
reflectedDamage: 0,
splitDuration: false,
redirected: false,
fullyStopped: false,
reversed: false,
kind: stack.kind,
};
if (attackId === "chaos") {
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
if (shielded) {
stack.defenderShielded = true;
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
}
}
for (const counter of stack.counters) {
if (counter.nullified) continue;
if (isNumberCard(counter.card.cardId)) {
// SHIELDSTONE number counter: reduce point AND duration effects.
const v = numberValue(counter.card.cardId);
pipe.damage = Math.max(0, pipe.damage - v);
pipe.duration = Math.max(0, pipe.duration - v);
continue;
}
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
// Fire meets water, whichever was thrown first: entirely stopped.
pipe.damage = 0;
pipe.fullyStopped = true;
continue;
}
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
continue; // their work is the sustained effect, not the damage pipe
}
if (counter.card.cardId === "teleport") {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
continue;
}
if (stack.creatureId &&
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
// "REFLECTIONs used on the wraith's touch will damage the wraith."
if (counter.card.cardId === "reflection") {
const half = Math.ceil(pipe.damage / 2);
pipe.reflectedDamage += half;
pipe.damage = half;
} else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
}
continue;
}
const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe);
}
// A surviving teleport counter whisks the defender away before anything lands.
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
if (escape) {
const from = defender.position;
defender.position = { ...escape.cell! };
events.push({ type: "teleported", player: defender.id, from, to: defender.position, by: defender.id, cardId: "teleport" });
}
let damageDealt = 0;
const attackingCreature = stack.creatureId
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) {
// SWAP MEET turned: "FULL REFLECTION lets the other player decide which
// objects, if any, will be swapped". The reflector's
// choice rode their counter — absent, or "none", nothing trades.
if (attackId === "swap-meet") {
const fr = stack.counters.find((c) => !c.nullified && c.card.cardId === "full-reflection");
const chosen = fr?.cardId;
if (chosen && chosen !== "none" && effect?.onResolved && !attackingCreature) {
effect.onResolved({
state, events,
attacker: defender, defender: attacker,
damageDealt: 0, fullyStopped: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
});
}
events.push({
type: "attackResolved",
attacker: attacker.id,
defender: defender.id,
attackCardId: attackId,
damageDealt: 0,
reflectedDamage: 0,
fullyStopped: false,
redirected: true,
});
return;
}
// The returned spell is a fresh attack on its own caster — who gets a
// defender's counteraction window against it.
if (!stack.trapped && !attackingCreature &&
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
state.stack = {
attackerId: defender.id,
defenderId: attacker.id,
attackCard: stack.attackCard,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: stack.params,
kind: "spell",
counters: [],
waitingOn: attacker.id,
reflectedBase: { damage: pipe.damage, duration: pipe.duration },
};
events.push({
type: "attackResolved",
attacker: attacker.id,
defender: defender.id,
attackCardId: attackId,
damageDealt: 0,
reflectedDamage: pipe.damage,
fullyStopped: false,
redirected: true,
});
return;
}
if (pipe.damage > 0) {
if (stack.trapped) {
// "Counteractions against 'trapped' attacks do not affect the caster."
} else if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
}
}
if (effect?.sustains && pipe.duration > 0) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
}
} else {
if (pipe.reversed && pipe.damage > 0 && pipe.kind === "spell") {
defender.life += pipe.damage;
events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life });
damageDealt = pipe.damage; // secondary effects still take effect
} else if (pipe.damage > 0) {
const source = stack.reflectedBase
? `${attackId} (reflected)`
: attackId ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
damageDealt = pipe.damage;
}
// "he takes 2 points of damage and loses a random card" — the theft is a
// secondary effect, stopped only when all the damage is.
if (stack.creatureTouch === "wraith" && damageDealt > 0 && defender.alive && defender.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, defender.hand.length);
state.rng = rngNext;
const [card] = defender.hand.splice(idx, 1);
defender.displayed = defender.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0 && !stack.trapped) {
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
}
}
// EMPATHY: "Any attack done in any form against you acts against both
// you and the caster of the spell."
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
applyDamage(state, events, attacker, damageDealt, `${attackId ?? "punch"} (empathy)`, defender.id, pipe.kind);
}
// SHADOWSTONE: physical damage you deal feeds your life total.
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
attacker.life += damageDealt;
events.push({ type: "lifeGained", player: attacker.id, amount: damageDealt, source: "shadowstone", lifeAfter: attacker.life });
}
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
if (pipe.splitDuration) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
}
}
}
// The tear's wrestling waits for the counters: only an unstopped grab,
// still in arm's reach of a still-laden defender, gets its clutch roll.
if (stack.tearTreasure && !pipe.fullyStopped && !pipe.redirected &&
attacker.alive && defender.alive &&
cellKey(attacker.position) === cellKey(defender.position) &&
defender.carriedTreasureId != null) {
executeTear(state, events, attacker, defender);
}
events.push({
type: "attackResolved",
attacker: attacker.id,
defender: defender.id,
attackCardId: attackId,
damageDealt,
reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage,
fullyStopped: pipe.fullyStopped || (dealsDamage && damageDealt === 0 && !pipe.redirected && !effect?.sustains),
redirected: pipe.redirected,
});
if (effect?.onResolved && !pipe.redirected) {
effect.onResolved({
state,
events,
attacker,
defender,
damageDealt,
fullyStopped: pipe.fullyStopped,
duration: pipe.duration,
stack,
});
}
}
/**
* GO AWAY's rout: "must move away a number of spaces equal
* to the NUMBER card played, in as straight a line as possible. Roll D4
* for random direction, if necessary." The victim keeps moving away,
* holding their line while it is open, bending around walls when it is
* not, the die choosing among equal options. Cornered ends it early.
*/
function goAwayRout(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
defender: PlayerState,
squares: number,
): void {
if (isLockedInPlace(state, defender.id)) return;
const from = defender.position;
const dist = (c: Cell) =>
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y);
let heading: Side | null = null;
let moved = 0;
for (let i = 0; i < squares; i++) {
const view = boardView(state);
const here = dist(defender.position);
const options: { dir: Side; to: Cell; gain: number }[] = [];
for (const dir of SIDES) {
const step = stepTarget(view, defender.position, dir);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
const gain = dist(step.to) - here;
if (gain > 0) options.push({ dir, to: step.to, gain });
}
// No way to gain ground: sidestep once if possible, else cornered.
if (options.length === 0) {
for (const dir of SIDES) {
const step = stepTarget(view, defender.position, dir);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
if (dist(step.to) === here) options.push({ dir, to: step.to, gain: 0 });
}
if (options.length === 0) break;
}
// As straight a line as possible: hold the heading when it is open.
let pick: { dir: Side; to: Cell; gain: number } | undefined =
heading ? options.find((o) => o.dir === heading) : undefined;
if (!pick) {
if (options.length === 1) {
pick = options[0]!;
} else {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
pick = options[(roll - 1) % options.length]!;
}
}
heading = pick.dir;
defender.position = pick.to;
moved++;
}
if (moved > 0) {
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
}
}
function knockBack(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
defender: PlayerState,
squares: number,
): void {
const dx = defender.position.x - attacker.position.x;
const dy = defender.position.y - attacker.position.y;
let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N";
if (!dir) {
// Same square (e.g. GO AWAY point blank): random direction, per the die's
// "random direction" use.
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
dir = SIDES[roll - 1]!;
}
const from = defender.position;
if (isLockedInPlace(state, defender.id)) return;
let moved = 0;
const view = boardView(state);
for (let i = 0; i < squares; i++) {
const step = stepTarget(view, defender.position, dir);
if (step.kind === "blocked") break;
const content = state.squareContents[cellKey(step.to)];
if (content?.kind === "stone") break;
defender.position = step.to;
moved++;
if (content?.kind === "thornbush") break; // tangled in the thorns
}
if (moved > 0) {
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
}
}
function applyDamage(
state: GameState,
events: GameEvent[],
target: PlayerState,
amount: number,
source: string,
attackerId: PlayerId | null,
damageKind: "spell" | "physical" = "spell",
): void {
// MEDUSA: "opponent is also immune to any damage."
if (sustainedOn(state, target.id, "medusa").length > 0) {
events.push({ type: "damageImmune", player: target.id, source, because: "medusa" });
return;
}
// BLOODSTONE: "Lowers all damage done you by one point per attack."
const soaks: { what: "bloodstone" | "soulstone"; amount: number }[] = [];
if (displays(target, "bloodstone")) {
amount = Math.max(0, amount - 1);
if (amount === 0) {
events.push({ type: "damageImmune", player: target.id, source, because: "bloodstone" });
return;
}
soaks.push({ what: "bloodstone", amount: 1 });
}
// SOULSTONE: "Last three points ... can only be lost to physical damage."
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) {
const clamped = Math.min(amount, target.life - 3);
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
amount = clamped;
}
target.life -= amount;
events.push({
type: "damaged", player: target.id, amount, source, lifeAfter: target.life,
...(soaks.length > 0 ? { soaks } : {}),
});
if (target.life > 0) return;
target.alive = false;
target.finalHand = [...target.hand];
events.push({ type: "died", player: target.id, killedBy: attackerId });
events.push({ type: "playerEliminated", player: target.id, reason: "killed" });
sweepEliminated(state, target.id);
if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
t.carriedBy = null;
t.position = target.position;
target.carriedTreasureId = null;
events.push({
type: "treasureDropped",
player: target.id,
treasureId: t.id,
at: target.position,
onHomeOf: homeOwnerAt(state, target.position),
});
}
const killer = attackerId ? state.players.find((p) => p.id === attackerId) : undefined;
if (killer && killer.alive && target.hand.length > 0) {
const taken = target.hand.splice(0);
target.displayed = [];
killer.hand.push(...taken);
events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length });
events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken });
if (killer.hand.length > handLimit(killer)) state.pendingDiscard = killer.id;
} else if (target.hand.length > 0) {
state.discard.push(...target.hand.splice(0));
target.displayed = [];
}
}
function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
const p = state.players.find((p) => cellKey(p.home) === cellKey(cell));
return p ? p.id : null;
}
// --- Treasures ---------------------------------------------------------------
function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
const p = activePlayer(state);
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
if (sustainedOn(state, p.id, "weakness").length > 0) return err("you are too weak to carry treasure");
const here = cellKey(p.position);
if (state.gluedCells[here]) return err("it is glued fast to the floor");
const safe = state.squareContents[here]?.kind === "safe";
if (safe && state.squareContents[here]!.createdBy !== p.id && !state.openSafes.includes(here)) {
return err("it is locked inside a safe");
}
// Two treasures can share a square; an id names the one wanted.
const t = state.treasures.find(
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy &&
(treasureId == null || t.id === treasureId),
);
if (!t) return err(treasureId ? "that treasure is not here for the taking" : "no treasure here");
t.carriedBy = p.id;
t.position = null;
p.carriedTreasureId = t.id;
state.turn.actionsEnded = true;
const events: GameEvent[] = [
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
];
// WARD: "you may play at that time (out of turn) this card on him" —
// literally: the grab hangs while the owner decides.
const owner = state.players.find((q) => q.id === t.owner);
if (owner && owner.alive && owner.id !== p.id) {
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
if (holdsWard) {
state.wardPending = { ownerId: owner.id, takerId: p.id };
events.push({ type: "wardWindow", owner: owner.id, victim: p.id });
}
}
checkAmbushes(state, events, p, { pickedUpTreasure: true });
return { ok: true, state, events };
}
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
const p = activePlayer(state);
const key = cellKey(p.position);
if (state.gluedCells[key]) return err("it is glued fast to the floor");
if (state.squareContents[key]?.kind === "safe" &&
state.squareContents[key]!.createdBy !== p.id && !state.openSafes.includes(key)) {
return err("it is locked inside a safe");
}
const here = state.groundObjects[key] ?? [];
const idx = here.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return err("that object is not here");
const [card] = here.splice(idx, 1);
if (here.length === 0) delete state.groundObjects[key];
p.hand.push(card!);
// "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT."
state.turn.actionsEnded = true;
if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id;
return {
ok: true,
state,
events: [{ type: "objectPickedUp", player: p.id, card: card!, at: p.position }],
};
}
/**
* A physical, droppable object is anything the card face marks as one —
* the object-typed stones, and attack/neutral cards bearing the OBJECT
* subtype (dagger, rock, wizardblade — "this is most certainly an object",
* per its FAQ — the wands, the master key, the handful of tacks). Wand
* charges are keyed by instance, so they travel with a traded wand.
*/
export function isMovableObject(cardId: string): boolean {
const def = cardDef(cardId);
return def.cardType === "object" || (def.subtypes?.includes("object") ?? false);
}
function doDropObject(prev: GameState, instanceId: string): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
const card = p.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
if (!isMovableObject(card.cardId)) return err("only objects can be dropped");
takeFromHand(p, instanceId);
const key = cellKey(p.position);
state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card];
return {
ok: true,
state,
events: [{ type: "objectDropped", player: p.id, card, at: p.position, forced: false }],
};
}
function doDropTreasure(prev: GameState): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
if (!p.carriedTreasureId) return err("you are not carrying a treasure");
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
t.carriedBy = null;
t.position = p.position;
p.carriedTreasureId = null;
const events: GameEvent[] = [{
type: "treasureDropped",
player: p.id,
treasureId: t.id,
at: p.position,
onHomeOf: homeOwnerAt(state, p.position),
}];
checkVictory(state, events);
return { ok: true, state, events };
}
function checkVictory(state: GameState, events: GameEvent[]): void {
if (state.phase !== "playing") return;
for (const p of state.players) {
if (!p.alive) continue;
const mine = state.treasures.filter((t) => t.owner === p.id);
const lost = mine.every((t) => {
if (!t.position) return false;
const owner = homeOwnerAt(state, t.position);
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
});
if (lost) {
p.alive = false;
p.finalHand = [...p.hand];
state.discard.push(...p.hand.splice(0));
p.displayed = [];
events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" });
// A treasure in the fallen wizard's arms lands where they stood, as
// it does when a wizard is killed.
if (p.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
t.carriedBy = null;
t.position = p.position;
p.carriedTreasureId = null;
events.push({
type: "treasureDropped",
player: p.id,
treasureId: t.id,
at: p.position,
onHomeOf: homeOwnerAt(state, p.position),
});
}
sweepEliminated(state, p.id);
}
}
for (const p of state.players) {
if (!p.alive) continue;
const stolenAtHome = state.treasures.filter(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home),
);
if (stolenAtHome.length >= 2) {
state.phase = "finished";
state.winner = p.id;
state.winReason = "treasures";
events.push({ type: "gameWon", player: p.id, reason: "treasures" });
return;
}
}
const alive = state.players.filter((p) => p.alive);
if (alive.length === 1) {
state.phase = "finished";
state.winner = alive[0]!.id;
state.winReason = "lastStanding";
events.push({ type: "gameWon", player: alive[0]!.id, reason: "lastStanding" });
}
}
// --- Hand management and turn flow -------------------------------------------
function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): CommandResult {
const state = clone(prev);
const p = state.players.find((p) => p.id === playerId)!;
const cards: CardInstance[] = [];
for (const id of instanceIds) {
const card = takeFromHand(p, id);
if (!card) return err(`card not in hand: ${id}`);
cards.push(card);
}
state.discard.push(...cards);
if (state.pendingDiscard === playerId && p.hand.length <= handLimit(p)) {
state.pendingDiscard = null;
}
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
}
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
const stacks = sustainedOn(state, p.id, "slow-death").length;
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return;
for (let i = 0; i < cardsDrawn * stacks; i++) {
if (!p.alive) break;
applyDamage(state, events, p, 1, "slow death", null);
}
checkVictory(state, events);
}
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
if (state.deck.length === 0) {
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
state.rng = rngNext;
state.deck = reshuffled;
state.discard = [];
events.push({ type: "deckReshuffled", size: state.deck.length });
if (state.deck.length === 0) return null;
}
return state.deck.shift()!;
}
/** Sustained-effect upkeep + turn flags when `player` begins a turn. */
function beginTurnFor(state: GameState, events: GameEvent[], index: number): void {
const player = state.players[index]!;
// Creatures refresh at each game-turn boundary. Democratic monsters refresh
// movement for EVERY player's turn; others for their controller's.
for (const c of state.creatures) {
c.justCreated = false;
c.scorchedThisTurn = [];
if (c.kind === "democratic-monster") {
c.movementUsed = 0;
} else if (c.controllerId === player.id) {
c.movementUsed = 0;
c.wallPassUsed = 0;
c.attackUsed = false;
}
}
// SHADOW upkeep: 1 life per turn, even during lost turns (handled where
// turns are skipped too).
const shadows = state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id).length;
for (let i = 0; i < shadows; i++) {
player.life -= 1;
events.push({ type: "shadowUpkeep", player: player.id, lifeAfter: player.life });
if (player.life <= 0) {
applyDamage(state, events, player, 0, "shadow upkeep", null); // triggers death path at <=0
}
}
// Duration spells expire at the start of their CASTER's turns.
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.casterId === player.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
// KILLER OOZE / PIT: struggle rolls before you may move this turn.
let struggleImmobilized = false;
if (player.fallenInOoze) {
const roll = rollD4(state, events, player.id, "struggling in the ooze — 1-2 breaks free");
const stood = roll <= 2;
events.push({ type: "struggledInOoze", player: player.id, stood });
if (stood) player.fallenInOoze = false;
else struggleImmobilized = true;
}
if (player.inPit) {
const roll = rollD4(state, events, player.id, "climbing from the pit — 1-2 gets out");
const out = roll <= 2;
events.push({ type: "climbedFromPit", player: player.id, success: out });
if (out) player.inPit = false;
else struggleImmobilized = true;
}
// Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK
// forces 2; SPEEDSTONE adds 1 otherwise.
let allowance = BASE_MOVEMENT;
if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2);
if (displays(player, "speedstone")) allowance += 1;
// STICKY WAND webs: "reducing movement by 3 (enemy can still use NUMBER
// cards for additional movement)."
allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length);
const slows = sustainedOn(state, player.id, "slow");
if (slows.length > 0) allowance = 1;
if (struggleImmobilized) allowance = 0;
// SLOW: "his attacks [reduce] to every other turn, starting on his next
// turn" — forbidden on the 1st, 3rd, ... slowed turns.
let attackForbidden = false;
for (const s of slows) {
s.data.turnCount = (s.data.turnCount ?? 0) + 1;
if (s.data.turnCount % 2 === 1) attackForbidden = true;
}
state.turn = {
round: state.turn.round,
firstIndex: state.turn.firstIndex,
activeIndex: index,
movementAllowance: allowance,
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
wandsUsed: [],
movementAddUsed: false,
attackForbidden,
actionsEnded: false,
};
}
/** Roll the D4 in the open: the pips land in the chronicle. */
function rollD4(state: GameState, events: GameEvent[], player: PlayerId | null, purpose: string): number {
const [roll, next] = rollDie(state.rng);
state.rng = next;
events.push({ type: "dieRolled", player, roll, purpose });
return roll;
}
/** An effect's turns ran out: announce it and clean up what it built. */
function expireEffect(state: GameState, events: GameEvent[], s: SustainedEffect): void {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
}
function doEndTurn(prev: GameState, draw: number): CommandResult {
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
const state = clone(prev);
const p = activePlayer(state);
const events: GameEvent[] = [];
const room = handLimit(p) - p.hand.length;
const count = state.turn.drawForbidden ? 0 : Math.min(draw, room);
if (count > 0) {
const drawn: CardInstance[] = [];
let toDraw = count;
while (toDraw > 0) {
const card = drawOne(state, events);
if (!card) break;
if (isTrap(card.cardId)) {
state.discard.push(card);
p.lostTurns++;
events.push({ type: "trapSprung", player: p.id, cardId: card.cardId });
continue;
}
if (card.cardId === "gift-from-below") {
// "You lose 3 points to magical damage, now ... then discard and redraw."
state.discard.push(card);
events.push({ type: "trapSprung", player: p.id, cardId: card.cardId });
applyDamage(state, events, p, 3, "gift from below", null);
checkVictory(state, events);
if (!p.alive) break;
continue;
}
drawn.push(card);
toDraw--;
}
p.hand.push(...drawn);
events.push({ type: "cardsDrawn", player: p.id, count: drawn.length });
events.push({ type: "cardsDrawnPrivate", visibleTo: p.id, cards: drawn });
applySlowDeathOnDraw(state, events, p, drawn.length);
}
// TROLL: "at the end of each turn of its creator, it gets back one point."
for (const c of state.creatures.filter((c) => c.kind === "troll" && c.controllerId === p.id)) {
if (c.damage > 0) {
c.damage -= 1;
events.push({ type: "trollRegenerated", creatureId: c.id });
}
}
// WARP WAND: opened walls reappear at the end of the turn.
if (state.tempWarpEdges.length > 0) {
for (const t of state.tempWarpEdges) {
if (t.prior === null) delete state.edgeOverrides[t.key];
else state.edgeOverrides[t.key] = t.prior;
if (t.unwarp) {
const { a, aSide, b, bSide } = t.unwarp;
state.board.warps = state.board.warps.filter((w) =>
!(cellKey(w.from.cell) === cellKey(a) && w.from.side === aSide) &&
!(cellKey(w.from.cell) === cellKey(b) && w.from.side === bSide));
}
}
events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length });
state.tempWarpEdges = [];
}
// Doors unlocked this turn relock ("the door will relock behind you").
if (state.openDoorEdges.length > 0) {
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
state.openDoorEdges = [];
}
state.openSafes = [];
events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) {
p.extraTurns--;
beginTurnFor(state, events, state.turn.activeIndex);
// "If you have SPEED on you while under the influence of a duration
// spell, it uses up a turn of that duration" — recipient-counted (FAQ).
// Self-cast durations already burned in beginTurnFor.
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events };
}
const n = state.players.length;
let next = state.turn.activeIndex;
for (;;) {
next = (next + 1) % n;
if (next === state.turn.firstIndex) {
state.turn.round++;
// The democratic monster's one claw per round refreshes at the TRUE
// round boundary — even when the first-rolled wizard is dead or
// skipped and never begins a turn.
for (const c of state.creatures) {
if (c.kind === "democratic-monster") c.attackUsed = false;
}
}
const candidate = state.players[next]!;
if (!candidate.alive) continue;
if (candidate.lostTurns > 0) {
candidate.lostTurns--;
events.push({ type: "turnSkipped", player: candidate.id, reason: "lostTurn" });
continue;
}
break;
}
beginTurnFor(state, events, next);
events.push({ type: "turnStarted", player: state.players[next]!.id, round: state.turn.round });
// FIRE IMP: "...or if in L.O.S. at the start of a player's turn."
impCheck(state, events, state.players[next]!.id);
return { ok: true, state, events };
}