Files
wizwar6e/packages/engine/src/game.ts
T
Eric WagonerandClaude Fable 5 ed9585aa59 Five fidelity gaps closed: teleport escapes, slime traps, the Big Man
moves like a giant, and the boards are diagram-verified

TELEPORT as a counteraction (official FAQ: "the attack has no chance
of hitting you"): the defender names an escape square within four
spaces, the escape resolves before anything lands, and an ANTI-ANTI
pins their boots to the floor. Additive — no revision gate needed.

FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime
sticks in the gel (leaving the discard pile), springs once at whoever
is inside or next enters, and counteractions against the trapped
blast cannot touch its caster — reflections vanish into the ooze. A
five-point waterbolt washes the slime and its cargo away, and the
waterwall waves clear slime from their path.

BIG MAN, under rules rev 5, finally moves like the card says: he
pushes players and monsters down the corridor ahead of him (stuck or
unpushable occupants block his advance), steps over a pit, tacks, or
killer ooze for two movement points without ever entering the square
(click two cells beyond the hazard), and monsters may not enter his
square. All gated so stored games replay under their own rules.

The boards were never wrong — the rulebook's Set-Up Diagram photo
confirms every pairing the code already had: 2p crossed, 3p stair
with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four
corner arcs. The stale TODOs are gone, replaced by tests pinning each
letter pair, and relocation's "only opposite board edges connect"
(which discards the aisle warp) is pinned too. Wall of Fire vs
Waterbolt turned out to be implemented and tested all along — its
TODO comment was the only thing wrong.

Also: hotseat saves now request durable storage (iOS evicts
unprotected origins under pressure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:17:37 -04:00

5401 lines
221 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,
hasLineOfSight,
sightBetween,
neighbor,
stepTarget,
} 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[];
}
/** 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;
/** 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;
}
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 }[];
waitingOn: PlayerId;
/** 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;
/** 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;
}
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;
}
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 = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack. Rev 5:
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square.
*/
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 }[]>;
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
wardArmed: PlayerId[];
/** 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[];
/** 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 }[];
/** 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;
}
/** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return sightBetween(boardView(state), from, to, losBlockers(state));
}
/** 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? Rolls the 50% chance
* lazily the first time it matters ("when they gain L.O.S. to it").
*/
function illusionBelief(
state: GameState,
events: GameEvent[],
playerId: PlayerId,
key: string,
): "believes" | "seesThrough" {
const wall = state.illusionWalls[key]!;
if (wall.createdBy === playerId) return "seesThrough";
const known = wall.belief[playerId];
if (known) return known;
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const result = roll <= 2 ? "seesThrough" : "believes";
wall.belief[playerId] = result;
events.push({ type: "illusionTested", player: playerId, edge: key, result });
return result;
}
/** The board as one player perceives it: believed illusions become walls. */
function perceivedBoard(
state: GameState,
events: GameEvent[],
viewerId: PlayerId,
sightLine?: { from: Cell; to: Cell },
): 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) {
const known = state.illusionWalls[key]!.belief[viewerId];
const isCreator = state.illusionWalls[key]!.createdBy === viewerId;
if (isCreator || known === "seesThrough") continue;
if (known === "believes") {
edges[key] = "wall";
continue;
}
// Untested: only roll if this sight line would actually cross it.
if (sightLine) {
const test = { ...view, edges: { [key]: "wall" as const } };
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
if (crossesIt) {
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
}
} else {
edges[key] = "wall"; // no sight context: treat as real until tested
}
}
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,
events: GameEvent[] = [],
): boolean {
const board = perceivedBoard(state, events, caster.id, { from, to });
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
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;
}
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
if (casterLos(state, caster, from, to, events)) 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, events) && casterLos(state, caster, mid, to, events)) {
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 }
| { 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 }
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" }
| { 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: "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: "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: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: 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: "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 }
| { 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: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: 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: "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: "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 }
| { 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: "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: "punchWall"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { 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[] }
| { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string; params?: { cell?: Cell } }
| { type: "pass" }
| { type: "pickUpTreasure" }
| { 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" }>) => 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";
}
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
// --- Attacks: damage ------------------------------------------------------
fireball: {
kind: "attack",
requiresLos: true,
baseDamage: () => 5,
onResolved: (ctx) => {
if (ctx.damageDealt <= 0) return;
const stones = ctx.defender.hand.filter((c) => isMagicStone(c.cardId));
if (stones.length === 0) return;
ctx.defender.hand = ctx.defender.hand.filter((c) => !isMagicStone(c.cardId));
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 });
},
},
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;
knockBack(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!;
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);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
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 (!losToEdge(view, caster.position, 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 } });
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 (!losToEdge(view, caster.position, 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" });
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");
}
}
}
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."
resolve: (state, events, caster, cmd) =>
unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }),
},
"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";
}
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 (!gameLos(state, caster.position, 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: null,
});
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);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
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";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "firewall";
state.createdEdges[key] = 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: key,
};
state.sustained.push(fx);
events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns });
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);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the wave must span a corridor between two spaces";
}
if (!losToEdge(view, caster.position, 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 (!losToEdge(boardView(state), caster.position, 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 (!losToEdge(view, caster.position, 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 (!gameLos(state, caster.position, 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 (!gameLos(state, caster.position, 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 (!gameLos(state, caster.position, 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 (!gameLos(state, caster.position, 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(),
powerstone: stoneEffect(),
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 an attack via aroundCornerInstanceId.
resolve: () => "attach Around The Corner to an attack 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)) 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);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
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 (!losToEdge(view, caster.position, 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";
rotateSector(state, idx, cmd.params?.clockwise ?? true);
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 problem = relocateSector(state, idx, dest);
if (problem) return problem;
events.push({
type: "sectorRelocated", caster: caster.id, sectorIndex: idx,
from: fromOrigin, to: dest,
});
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 (!gameLos(state, caster.position, creature.position)) return "no line of sight";
const boost = cmd.params?.cardId === "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 (!losToEdge(view, caster.position, 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 } });
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);
if ((view.edges[key] ?? "open") === "open") {
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the door must stand between two spaces";
}
} else if (view.edges[key] !== "wall") {
return "a door goes into a stone wall or an open corridor";
}
if (!losToEdge(view, caster.position, 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 (!gameLos(state, caster.position, 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 (!gameLos(state, caster.position, 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 (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, 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);
if (view.edges[key] !== "wall") return "that is not a stone wall";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "open";
delete state.createdEdges[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null });
// "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 (!gameLos(state, caster.position, 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: {
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) => {
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";
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 (walkingDistance(ctx.state, ctx.defender.position, to) > 3) 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 });
applyDamage(ctx.state, ctx.events, ctx.defender, d, "goat ram", ctx.attacker.id, "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) {
applyDamage(ctx.state, ctx.events, ctx.defender, d, "hurled treasure", ctx.attacker.id, "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": under rules rev 3
// every bystander gets a shield window before the pile forms; earlier
// revisions scramble at once so stored games replay unchanged.
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
if ((ctx.state.config.deckRev ?? 1) >= 3) {
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);
return;
}
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
},
},
"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) {
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, `illusionary ${chosen}`, ctx.attacker.id);
}
},
},
"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(";");
const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object");
const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object");
if (mine === -1 || theirs === -1) return;
const [a] = ctx.attacker.hand.splice(mine, 1);
const [b] = ctx.defender.hand.splice(theirs, 1);
ctx.attacker.hand.push(b!);
ctx.defender.hand.push(a!);
ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId);
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"
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",
// "Swap two external sector exits" — their wraparound destinations trade.
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 swap";
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";
// Swap destinations and fix the reciprocal warps to match.
const destA = { ...wa.to };
const destB = { ...wb.to };
wa.to = destB;
wb.to = destA;
for (const w of state.board.warps) {
if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side };
if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.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 (!casterLos(state, caster, caster.position, aim, events)) 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)) 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: "positionsSwapped", a: caster.id, b: other.id, aTo: caster.home, bTo: 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;
},
};
}
/** 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 {
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
const pushes: { start: Cell; dir: Side }[] = [
{ start: cell, dir: away(side) },
{ start: neighbor(cell, side), dir: side },
];
for (const { start, dir } of pushes) {
let probe = start;
for (let dist = 0; dist < range; dist++) {
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
}
for (const c of [...state.creatures]) {
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
destroyCreature(state, events, c, reason);
}
}
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);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
}
for (const c of [...state.creatures]) {
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
destroyCreature(state, events, c, "rushing water");
}
}
}
}
}
/** 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 (!casterLos(state, caster, caster.position, at, events)) 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";
if (!gameLos(state, caster.position, 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");
}
}
// ---------------------------------------------------------------------------
// 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)
attackUsed: true, // "cannot attack the turn they are created"
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);
}
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") {
// WRAITH: "can move ... through 1 wall or object per turn."
const dest = neighbor(creature.position, direction);
if (
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.config.deckRev ?? 1) >= 5 &&
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;
}
creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square.
const rev4 = (state.config.deckRev ?? 1) >= 4;
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 });
if (rev4) {
// The victim may counteract ("REFLECTIONs used on the wraith's touch
// will damage the wraith" — the card assumes exactly this window).
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "wraith's touch", null);
if (p.alive && p.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, p.hand.length);
state.rng = rngNext;
const [card] = p.hand.splice(idx, 1);
p.displayed = p.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: p.id, cards: [card!] });
}
}
if (creature.kind === "democratic-monster" && !creature.attackUsed) {
creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
if (rev4) {
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "clawing monster", null);
}
}
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,
};
}
/** Rules rev 4: 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) {
if ((state.config.deckRev ?? 1) >= 4) {
openCreatureStack(state, creature, targetPlayer, amount);
return { ok: true, state, events };
}
applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical");
} 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,
): void {
const mapEdgeKey = (key: string): string => {
const { cell, side } = parseEdgeKey(key);
const other = neighbor(cell, side);
if (!inSector(cell) || !inSector(other)) return key; // boundary: leave
return edgeKey(mapCell(cell), mapSide(side));
};
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);
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);
}
}
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
function rotateSector(state: GameState, index: number, clockwise: boolean): 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);
}
/** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */
function relocateSector(state: GameState, index: number, dest: Cell): 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";
if (dest.x < 0 || dest.y < 0) return "the sector cannot go 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));
// "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(newPlacements);
remapState(state, inSector, mapCell, (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 });
return null;
}
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). */
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) {
const n = neighbor(cur, side);
if (!board.cells[cellKey(n)] || 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[] } {
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 ((config.deckRev ?? 1) >= 2 && 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: {},
wardArmed: [],
chaosPending: null,
doorStates: {},
openDoorEdges: [],
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 {
if (state.phase !== "playing") return err("game is over");
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);
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 "punchWall": return doPunchWall(state, command.cell, command.side);
case "armWard": return doArmWard(state, command.armed);
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
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);
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);
}
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 ---------------------------------------------------------------
/** 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[] = [];
// IDIOT: every move heads for the nearest of their own treasures.
if (sustainedOn(state, p.id, "idiot").length > 0) {
const steered = idiotSteer(state, p);
if (steered) direction = steered;
}
// 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)) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
direction = SIDES[roll - 1]!;
}
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
{
const key = edgeKey(p.position, direction);
if (state.illusionWalls[key] &&
illusionBelief(state, events, 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" (rules rev 5). 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 = (state.config.deckRev ?? 1) >= 5 && 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)]) {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
return err("blocked");
}
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
p.position = dest;
via = "step";
} 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" (rules rev 5). No
// room to shove them onward means no way forward for the giant either.
if ((state.config.deckRev ?? 1) >= 5 && 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.
for (const other of state.players) {
if (other.id === p.id || !other.alive) continue;
if (sustainedOn(state, other.id, "fear").length === 0) continue;
const d = Math.abs(other.position.x - p.position.x) + Math.abs(other.position.y - p.position.y);
const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y);
if (d <= 3 && d < dBefore) {
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, rngNext] = rollDie(state.rng);
state.rng = rngNext;
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);
}
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.
for (const trap of [...state.boobytraps]) {
if (trap.casterId === p.id) continue;
if (cellKey(p.position) === 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);
}
}
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;
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;
}
function castingBlocked(state: GameState, playerId: PlayerId): 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";
if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)";
return null;
}
/** IDIOT: the victim's moves are steered toward their nearest own treasure. */
function idiotSteer(state: GameState, p: PlayerState): Side | null {
const targets = state.treasures.filter((t) => t.owner === p.id && t.position);
if (targets.length === 0) return null;
const view = boardView(state);
let best: { side: Side; dist: number } | null = null;
for (const side of SIDES) {
const step = stepTarget(view, p.position, side);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
for (const t of targets) {
const d = walkingDistance(state, step.to, t.position!);
if (best === null || d < best.dist) best = { side, dist: d };
}
}
return best?.side ?? 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));
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
function doArmWard(prev: GameState, armed: boolean): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
const already = state.wardArmed.includes(p.id);
if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set");
state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id);
return {
ok: true,
state,
events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }],
};
}
/** 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);
}
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);
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).
if (isBlinded(state, attacker)) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
if (roll !== 1) {
state.turn.attackUsed = true;
return {
ok: true,
state,
events: [{
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: [{ 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`);
}
// 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);
if (castBlock) return err(castBlock);
} else if (sustainedOn(state, caster.id, "medusa").length > 0) {
return err("you are paralyzed by Medusa");
}
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) {
if (mods.numbers.length === 0) return "a wand's first use needs a number card to set its charges";
charges = mods.magnitude.power; // AMPLIFY and ADD both 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;
};
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 && !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 && !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("only walls, doors, and thornbushes can be attacked");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall");
}
if (effect.requiresLos && !losToEdge(view, caster.position, 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 };
}
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, preEvents)
: casterLos(state, caster, caster.position, target.position, preEvents);
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);
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,
};
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,
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[] = [{
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");
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,
};
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 = gameLos(state, owner.position, context.movedFrom);
const now = gameLos(state, 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 && !gameLos(state, 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: 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
}
}
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): 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.
const castBlock = castingBlocked(state, playerId);
if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 &&
(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" }],
};
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
if (card.cardId === "wall-of-fire") {
if (stack.attackCard?.cardId !== "waterbolt") {
return err("as a counteraction, Wall of Fire only stops a Waterbolt");
}
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: "waterbolt" }],
};
}
// 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 });
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) {
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction");
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
if (!targetCounter) return err("no counteraction to nullify");
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
stack.waitingOn = stack.defenderId;
return {
ok: true,
state,
events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }],
};
}
return err("you are not part of this exchange");
}
function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev);
const stack = state.stack!;
if (playerId === stack.attackerId) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events: [] };
}
const events: GameEvent[] = [];
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 (50% miss). "If a spell misses, it dissipates harmlessly." A
// creature sharing the square has nothing to aim: no miss rolls.
if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
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 (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
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
: 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.
if (attackId === "stone-dead") {
base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).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;
}
const baseDuration = effect?.sustains
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
: 0;
// 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" && (state.config.deckRev ?? 1) >= 3) {
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") {
// The wave meets the fire: the waterbolt is entirely stopped.
pipe.damage = 0;
pipe.fullyStopped = true;
continue;
}
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) {
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 = 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);
}
}
}
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,
});
}
}
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."
if (displays(target, "bloodstone")) {
amount = Math.max(0, amount - 1);
if (amount === 0) return;
}
// SOULSTONE: "Last three points ... can only be lost to physical damage."
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) {
amount = Math.min(amount, target.life - 3);
}
target.life -= amount;
events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life });
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" });
state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id);
// "If you die, any monster controlled by you immediately disappears."
state.creatures = state.creatures.filter((c) => c.controllerId !== target.id);
for (const a of state.ambushes.filter((a) => a.ownerId === target.id)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== 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): CommandResult {
const blocked = requireActionsAvailable(prev);
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");
}
const t = state.treasures.find(
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy,
);
if (!t) return err("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" — the
// choice is made ahead of time by arming it (rev 3); earlier revisions
// spring automatically so stored games replay unchanged.
const owner = state.players.find((q) => q.id === t.owner);
const wardSet = (state.config.deckRev ?? 1) >= 3 ? state.wardArmed.includes(owner?.id ?? "") : true;
if (owner && owner.alive && owner.id !== p.id && wardSet) {
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
if (wardIdx !== -1) {
const [card] = owner.hand.splice(wardIdx, 1);
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
state.wardArmed = state.wardArmed.filter((id) => id !== owner.id);
events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
applyDamage(state, events, p, 3, "warded treasure", null);
checkVictory(state, events);
}
}
checkAmbushes(state, events, p, { pickedUpTreasure: true });
return { ok: true, state, events };
}
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
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 }],
};
}
/**
* Physical, droppable objects beyond the object-type stones. Rulebook:
* "Movable objects include magic stones, treasure chests, the dagger, the
* large rock, and the wizardblade" — plus the expansion wands, which the
* rules expect to change hands ("its remaining charges go with it";
* charges are keyed by instance, so they travel automatically).
*/
const MOVABLE_OBJECT_CARD_IDS = new Set([
"dagger", "large-rock", "wizardblade", ...WAND_CARD_IDS,
]);
export function isMovableObject(cardId: string): boolean {
return cardDef(cardId).cardType === "object" || MOVABLE_OBJECT_CARD_IDS.has(cardId);
}
function doDropObject(prev: GameState, instanceId: string): CommandResult {
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 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" });
}
}
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;
// its single attack refreshes per ROUND: on the first player's turn
if (index === state.turn.firstIndex) c.attackUsed = false;
} 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) {
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];
}
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, rngNext] = rollDie(state.rng);
state.rng = rngNext;
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, rngNext] = rollDie(state.rng);
state.rng = rngNext;
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,
};
}
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 = 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 });
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 });
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;
}
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);
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++;
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 };
}