Files
wizwar6e/packages/engine/src/game.ts
T
Eric WagonerandClaude Fable 5 e57307efbe Rev 17: the wave carries monsters as it carries wizards
Stone to Water's wave (and every waterwall collapse) washed wizards
back and crushed them against the maze — while a skeleton in the
same water stood dry. The wave only knew creatures well enough to
drown fire imps. From rev 17 it carries them all: washed back along
the surge, one point of crush per space the maze refuses, fire imps
still destroyed outright. Earlier ledgers keep their dry monsters
and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:07:34 -04:00

5722 lines
237 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;
/** Whether a sustained duration means "until used/removed", not a count. */
export function isPermanentDuration(turns: number): boolean {
return turns >= PERMANENT_TURNS / 2;
}
/** Which square contents block line of sight. */
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
stone: true, thornbush: true, rosebush: true, dust: true, slime: true,
ooze: false, tacks: false, pit: false, safe: false,
};
export interface TurnState {
round: number;
firstIndex: number;
activeIndex: number;
movementAllowance: number;
movementUsed: number;
numberPlayedForMovement: boolean;
attackUsed: boolean;
/** ADRENALINE's second attack, once spent. */
secondAttackUsed: boolean;
/** Wand instances already used this turn ("maximum of once per turn"). */
wandsUsed: string[];
/** ADD spent to allow a second movement number card this turn. */
movementAddUsed: boolean;
/** SLOW: "his attacks [reduce] to every other turn". */
attackForbidden: boolean;
actionsEnded: boolean;
}
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;
/** MEGA-MONSTER: which stat doubles. */
boost?: "life" | "movement";
/** PICK LOCK / MASTER KEY: hold the door open for others. */
hold?: 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. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" — the card face).
* Rev 7: no behavioral change — every board opening carries warp sight
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." — the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
*/
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[];
/** Doors physically HELD open ("You may 'hold the door open' for others"
* — MASTER KEY, PICK LOCK): unlocked past end of turn, until the holder
* steps out of adjacency or falls. */
heldDoors: { key: string; by: PlayerId; cell: Cell; side: Side }[];
/** Walls/firewalls conjured during play (dispellable), by edge key. */
createdEdges: Record<string, true>;
/** Square-filling creations, by cell key. */
squareContents: Record<string, SquareContent>;
/** Object cards lying on the floor, by cell key. */
groundObjects: Record<string, CardInstance[]>;
/** The last spell card each player used (for REUSE SPELL). */
lastSpellUsed: Record<PlayerId, string>;
/** ILLUSION WALLs by edge key: real only for those who believe. */
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
creatures: CreatureState[];
nextCreatureId: number;
/** Remaining charges per wand card instance (set on first use). */
wandCharges: Record<string, number>;
/** WARP WAND: walls opened for this turn only, with their prior state. */
tempWarpEdges: { key: string; prior: EdgeState | null }[];
/** BOOBYTRAP: four face-down tokens, one real (its cell key is secret). */
boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[];
/** GLUE: object cells that cannot be picked up from, by cell key. */
gluedCells: Record<string, true>;
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
openSafes: string[];
/** SWARTHMORE'S ENCHANTMENT: enchanted object instances (+1 magical). */
enchantedObjects: Record<string, true>;
/** DIMENSIONAL WARP token pairs. */
dimWarps: { a: Cell; b: Cell }[];
/** INTERRUPT / OPPORTUNITY FIRE: one out-of-turn action window. */
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** Armed ambushes: an Interrupt/Opportunity Fire committed with an attack
* and a trigger, springing automatically — the async form of "in the
* moment" interruption. Hidden from everyone but the owner. */
ambushes: AmbushState[];
nextAmbushId: number;
players: PlayerState[];
treasures: TreasureState[];
sustained: SustainedEffect[];
deck: CardInstance[];
discard: CardInstance[];
turn: TurnState;
stack: CastStack | null;
rng: RngState;
winner: PlayerId | null;
winReason: "treasures" | "lastStanding" | null;
pendingDiscard: PlayerId | null;
/** Monotonic counter for sustained-effect ids. */
nextEffectId: number;
}
/** The board with dynamic wall changes applied — use for movement and LOS. */
export function boardView(state: GameState): AssembledBoard {
if (Object.keys(state.edgeOverrides).length === 0) return state.board;
return { ...state.board, edges: { ...state.board.edges, ...state.edgeOverrides } };
}
export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: string): SustainedEffect[] {
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
}
/** Square-filling sight blockers: stone, bushes — and the BIG MAN, whom no spell passes. */
export function losBlockers(state: GameState): Record<string, true> {
const blockers: Record<string, true> = {};
for (const [key, content] of Object.entries(state.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
}
for (const p of state.players) {
if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true;
}
return blockers;
}
/** A held-open door is an open doorway to the eye (rules rev 15): REMOVE
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
* the table holds doors open precisely to cast back through them. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if ((state.config.deckRev ?? 1) < 15 || state.heldDoors.length === 0) return board;
const edges = { ...board.edges };
for (const h of state.heldDoors) delete edges[h.key];
return { ...board, edges };
}
/** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return sightBetween(openHeldDoors(state, 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 = openHeldDoors(state, 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: "homeBasesSwapped"; a: PlayerId; b: PlayerId; aHome: Cell; bHome: Cell }
| { type: "positionsSwapped"; a: PlayerId; b: PlayerId; aTo: Cell; bTo: Cell }
| { type: "cardErased"; player: PlayerId; cardId: string | null; found: boolean }
| { type: "cardsStolen"; from: PlayerId; to: PlayerId; count: number }
| { type: "cardsStolenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
| { type: "handRevealed"; player: PlayerId; to: PlayerId }
| { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number }
| { type: "firewallExpired"; edge: string }
| { type: "firewallBurned"; player: PlayerId }
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: SquareContent["kind"] }
| { type: "creationDispelled"; caster: PlayerId; what: string }
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
| { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean }
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side }
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
| { type: "tableTalk"; player: PlayerId; text: string }
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "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; edge?: { cell: Cell; side: Side } }
| { type: "handsSwapped"; a: PlayerId; b: PlayerId }
| { type: "handsScrambled"; caster: PlayerId }
| { type: "rammed"; attacker: PlayerId; target: PlayerId; distance: number }
| { type: "treasureThrown"; attacker: PlayerId; at: Cell; distance: number }
| { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean }
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
| { type: "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: "doorHeld"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "doorReleased"; edge: { cell: Cell; side: Side } }
| { type: "doorsRelocked"; count: number }
| { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "lockRemoved"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "cardDisplayed"; player: PlayerId; card: CardInstance }
| { type: "extraTurnGranted"; player: PlayerId }
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
| { type: "trapSprung"; player: PlayerId }
| { 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 }; numberInstanceIds?: string[] }
| { type: "pass" }
| { type: "pickUpTreasure"; treasureId?: string }
| { type: "pickUpObject"; instanceId: string }
| { type: "dropObject"; instanceId: string }
| { type: "dropTreasure" }
| { type: "discard"; instanceIds: string[] }
| { type: "endTurn"; draw: number };
export type CommandResult =
| { ok: true; state: GameState; events: GameEvent[] }
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Card effects registry
type AttackEffect = {
kind: "attack";
requiresLos?: boolean;
/** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */
physical?: boolean;
/** Attacker must share the target's square (WIZARDBLADE). */
sameSquare?: boolean;
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
/** A duration spell: attach a sustained effect on resolution. */
sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => 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?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
events.push({ type: "monsterBoosted", creatureId: creature.id, boost });
return null;
},
},
adrenaline: {
kind: "neutral",
// "Allows two attacks in one turn. Duration equals NUMBER card played."
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "adrenaline", caster.id, caster.id, magnitude.duration);
return null;
},
},
"mad-dash": {
kind: "neutral",
// "Doubles your movement (including NUMBER cards and other add-ons) for
// one turn. You cannot carry treasures while exerting yourself."
resolve: (state, events, caster) => {
if (caster.carriedTreasureId) return "you cannot mad-dash while carrying a treasure";
state.turn.movementAllowance *= 2;
events.push({ type: "lifeTraded", player: caster.id, points: 0, newAllowance: state.turn.movementAllowance });
return null;
},
},
lifesaver: {
kind: "neutral",
resolve: (state, events, caster) => {
if (state.players.filter((p) => p.alive).length <= 2) return "not applicable in a 2-player game";
attachSustained(state, events, "lifesaver", caster.id, caster.id, PERMANENT_TURNS);
return null;
},
},
// --- Expansion #1: magic wands -------------------------------------------
"blaster-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
// "does 3 points of magical damage per charge"
baseDamage: () => 3,
},
"sticky-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
baseDamage: () => 0,
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
// "reducing movement by 3 ... Lasts one turn. Any fire damage done to
// player in webs causes two extra points."
attachSustained(ctx.state, ctx.events, "sticky-web", ctx.attacker.id, ctx.defender.id, 1);
ctx.events.push({ type: "webbed", player: ctx.defender.id });
},
},
"shift-wand": {
kind: "attack",
requiresLos: true,
keepInHand: true,
baseDamage: () => 0,
validate: (state, cmd) => {
const cell = cmd.params?.cell;
if (!cell) return "choose the adjacent space to shove them into";
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params!.cell!;
const from = ctx.defender.position;
const dist = Math.abs(to.x - from.x) + Math.abs(to.y - from.y);
if (dist !== 1) return; // must be adjacent to where they stand
// "he could be shoved through a stone wall" — walls do not stop it.
ctx.defender.position = to;
ctx.events.push({ type: "shoved", player: ctx.defender.id, from, to, by: ctx.attacker.id });
},
},
"warp-wand": {
kind: "neutral",
keepInHand: true,
// "makes 1 section (one space long) of wall disappear during your turn,
// reappearing at the end of your turn."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "aim the wand at a wall section";
const { cell, side } = cmd.target;
const key = edgeKey(cell, side);
const view = boardView(state);
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
if (!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, edge: { cell, side } });
// "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 });
resolvedBlow(ctx, d, "goat ram", "physical");
},
},
"heave-ho": {
kind: "attack",
requiresLos: true,
physical: true,
baseDamage: () => 0,
validate: (state) => {
const caster = activePlayer(state);
return caster.carriedTreasureId ? null : "you have no treasure to throw";
},
onResolved: (ctx) => {
if (!ctx.attacker.carriedTreasureId) return;
const t = ctx.state.treasures.find((t) => t.id === ctx.attacker.carriedTreasureId)!;
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
t.carriedBy = null;
t.position = { ...ctx.defender.position };
ctx.attacker.carriedTreasureId = null;
ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d });
if (!ctx.fullyStopped && ctx.defender.alive && d > 0) {
resolvedBlow(ctx, d, "hurled treasure", "physical");
}
ctx.events.push({
type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id,
at: t.position!, onHomeOf: homeOwnerAt(ctx.state, t.position!),
});
checkVictory(ctx.state, ctx.events);
},
},
thief: {
kind: "attack",
sameSquare: true,
baseDamage: () => 0,
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name the item to steal"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const wanted = ctx.stack.params!.cardId!;
if (wanted === "treasure") return; // "The item may not be a treasure."
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted && cardDef(c.cardId).cardType === "object");
if (idx === -1) return;
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
ctx.attacker.hand.push(card!);
ctx.events.push({ type: "itemStolen", from: ctx.defender.id, to: ctx.attacker.id, cardId: wanted });
if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id;
},
},
chaos: {
kind: "attack",
baseDamage: () => 0,
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
// "FULL SHIELD removes a player from participation": 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) {
resolvedBlow(ctx, dmg, `illusionary ${chosen}`);
}
},
},
"swap-meet": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name your item and theirs (yours;theirs)"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
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",
// "Signify their new directions by placing the A tokens on one set of
// exits & the B tokens on the other" — the two exits you choose become a
// pair (rules rev 9), and their former partners pair with each other.
// Earlier revisions swapped the two exits' destinations instead.
resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell;
const bT = cmd.target;
if (!a || !bT || bT.kind !== "cell") return "pick the two exits to connect";
const b = bT.cell;
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
if (!wa || !wb || wa === wb) return "pick two different outer exits";
if ((state.config.deckRev ?? 1) >= 9) {
if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect";
const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell));
const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell));
// The chosen exits join (A tokens); their old partners join (B tokens).
wa.to = { cell: { ...wb.from.cell }, side: wb.from.side };
wb.to = { cell: { ...wa.from.cell }, side: wa.from.side };
if (pa && pb) {
pa.to = { cell: { ...pb.from.cell }, side: pb.from.side };
pb.to = { cell: { ...pa.from.cell }, side: pa.from.side };
}
} else {
// Legacy: 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: "homeBasesSwapped", a: caster.id, b: other.id, aHome: caster.home, bHome: other.home });
checkVictory(state, events);
return null;
},
},
"reuse-spell": {
kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but
// not the NUMBER card)."
resolve: (state, events, caster) => {
const lastId = state.lastSpellUsed[caster.id];
if (!lastId || lastId === "reuse-spell") return "no spell to retrieve";
// The most recent copy of that card in the discard pile is yours.
for (let i = state.discard.length - 1; i >= 0; i--) {
if (state.discard[i]!.cardId === lastId) {
const [card] = state.discard.splice(i, 1);
caster.hand.push(card!);
events.push({ type: "spellReused", player: caster.id, card: card! });
if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id;
delete state.lastSpellUsed[caster.id];
return null;
}
}
return "that spell is no longer in the discard pile";
},
},
};
/** Simple square-filling terrain creations (ooze, rosebush, dust, slime, pit). */
function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
return {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind, damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind });
return null;
},
};
}
/** 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 (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, reason);
} else if ((state.config.deckRev ?? 1) >= 17) {
washBackCreature(state, events, c, dir, range);
}
}
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 (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, "rushing water");
} else if ((state.config.deckRev ?? 1) >= 17) {
washBackCreature(state, events, c, dir, range);
}
}
}
}
}
/** 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";
// "You can't create an object on a DIMENSIONAL WARP" (rules rev 8).
if ((state.config.deckRev ?? 1) >= 8 &&
state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
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");
}
}
/** The wave carries monsters as it carries wizards (rules rev 17): washed
* back, and crushed a point per space the maze refuses them. Fire imps
* never reach this — water destroys them outright. */
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
const view = boardView(state);
let moved = 0;
for (let i = 0; i < range; i++) {
const step = stepTarget(view, c.position, dir);
if (step.kind === "blocked") break;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
c.position = step.to;
moved++;
}
const blockedSpaces = range - moved;
if (blockedSpaces > 0) {
damageCreature(state, events, c, blockedSpaces, "waterwall crush");
}
}
// ---------------------------------------------------------------------------
// Creatures
const CREATURE_STATS: Record<CreatureState["kind"], { maxDamage: number; moves: number; wallPasses: number }> = {
troll: { maxDamage: 6, moves: 3, wallPasses: 0 },
skeleton: { maxDamage: 4, moves: 3, wallPasses: 0 },
wraith: { maxDamage: 4, moves: 3, wallPasses: 1 },
"fire-imp": { maxDamage: Infinity, moves: 0, wallPasses: 0 }, // only water kills it
"democratic-monster": { maxDamage: 5, moves: 3, wallPasses: 0 },
shadow: { maxDamage: 1, moves: 3, wallPasses: 0 }, // "any damage at all destroys it"
"alter-ego": { maxDamage: 1, moves: 0, wallPasses: 0 },
};
function creatureById(state: GameState, id: string): CreatureState | undefined {
return state.creatures.find((c) => c.id === id);
}
export function creatureAt(state: GameState, cell: Cell): CreatureState | undefined {
return state.creatures.find((c) => cellKey(c.position) === cellKey(cell));
}
function spawnCreature(
state: GameState,
events: GameEvent[],
kind: CreatureState["kind"],
controllerId: PlayerId,
at: Cell,
): CreatureState {
const stats = CREATURE_STATS[kind];
const creature: CreatureState = {
id: `creature-${state.nextCreatureId++}`,
kind,
controllerId,
position: at,
damage: 0,
maxDamage: stats.maxDamage,
movesPerTurn: stats.moves,
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
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") {
const dest = neighbor(creature.position, direction);
// An open doorway (lock removed, keyed, or held) is no obstacle —
// and costs a wraith nothing.
if (target.by === "door" && doorIsOpen(state, edgeKey(creature.position, direction)) &&
view.cells[cellKey(dest)] &&
state.squareContents[cellKey(dest)]?.kind !== "stone") {
creature.position = dest;
} else if (
// WRAITH: "can move ... through 1 wall or object per turn."
creature.wallPassesPerTurn > creature.wallPassUsed &&
view.cells[cellKey(dest)] &&
state.squareContents[cellKey(dest)]?.kind !== "stone"
) {
creature.wallPassUsed++;
creature.position = dest;
} else {
return err(`blocked by ${target.by}`);
}
} else {
const content = state.squareContents[cellKey(target.to)];
if (content?.kind === "stone") return err("that square is solid stone");
if ((state.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);
for (const h of state.heldDoors) {
h.key = mapEdgeKey(h.key);
if (inSector(h.cell)) h.cell = mapCell(h.cell);
}
state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
}
for (const p of state.players) {
if (inSector(p.position)) p.position = mapCell(p.position);
if (inSector(p.home)) p.home = mapCell(p.home);
}
for (const t of state.treasures) {
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
}
// A moving sector carries everything standing on it. Earlier revisions left
// creatures, traps, glue, safes, and warp tokens at their old coordinates;
// their stored games must replay with that flaw intact (rules rev 11).
if ((state.config.deckRev ?? 1) >= 11) {
for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position);
}
for (const t of state.boobytraps) {
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
t.realKey = mapCellKey(t.realKey);
}
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
state.openSafes = state.openSafes.map(mapCellKey);
for (const w of state.dimWarps) {
if (inSector(w.a)) w.a = mapCell(w.a);
if (inSector(w.b)) w.b = mapCell(w.b);
}
}
}
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
function rotateSector(state: GameState, index: number, clockwise: boolean): 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";
// The grid has no wall at zero: a sector may land beyond the old origin and
// the whole maze renormalizes back into positive coordinates (rules rev 11).
if ((state.config.deckRev ?? 1) < 11 && (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));
// Zero-anchor the maze after the move: negative landings shift back into
// positive coordinates, and vacating the origin corner pulls the maze snug
// instead of leaving a blank band (rules rev 11; older games never shift).
const shift = (state.config.deckRev ?? 1) >= 11
? {
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
}
: { x: 0, y: 0 };
const shifted = shift.x || shift.y
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
: newPlacements;
// "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(shifted);
remapState(state, inSector, mapCell, (s) => s);
if (shift.x || shift.y) {
remapState(state, () => true, (c) => ({ x: c.x + shift.x, y: c.y + shift.y }), (s) => s);
}
return null;
}
/** DRAG a player straight toward the caster, stopping at walls. */
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
const view = boardView(state);
for (let guard = 0; guard < 20; guard++) {
if (cellKey(target.position) === cellKey(dest)) return;
const dx = dest.x - target.position.x;
const dy = dest.y - target.position.y;
let dir: Side;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else dir = dy > 0 ? "S" : "N";
const step = stepTarget(view, target.position, dir);
if (step.kind === "blocked") return;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") return;
target.position = step.to;
}
}
// ---------------------------------------------------------------------------
// Effect helpers
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
const n = neighbor(cell, side);
return sightBetween(board, from, cell) || sightBetween(board, from, n);
}
function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean {
return cellKey(pos) === cellKey(cell) || cellKey(pos) === cellKey(neighbor(cell, side));
}
function doorTarget(
state: GameState,
cmd: Extract<Command, { type: "cast" }>,
): { cell: Cell; side: Side } | string {
if (!cmd.target || cmd.target.kind !== "edge") return "target a door";
const { cell, side } = cmd.target;
const current = boardView(state).edges[edgeKey(cell, side)] ?? "open";
if (current !== "door") return "that is not a door";
return { cell, side };
}
function unlockDoor(
state: GameState,
events: GameEvent[],
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
cardId: string,
opts: { requireAdjacent: boolean },
): string | null {
const found = doorTarget(state, cmd);
if (typeof found === "string") return found;
const key = edgeKey(found.cell, found.side);
if (state.doorStates[key] === "jammed") return "the lock is jammed solid";
if (state.doorStates[key] === "removed") return "that door has no lock";
if (opts.requireAdjacent && !isAdjacentToEdge(caster.position, found.cell, found.side)) {
return "you must be adjacent to the door";
}
if (!state.openDoorEdges.includes(key)) state.openDoorEdges.push(key);
events.push({ type: "doorUnlocked", player: caster.id, edge: found, withCardId: cardId });
if (cmd.params?.hold) {
if (!state.heldDoors.some((h) => h.key === key)) {
state.heldDoors.push({ key, by: caster.id, cell: found.cell, side: found.side });
}
events.push({ type: "doorHeld", player: caster.id, edge: found });
}
return null;
}
/** An open doorway: lock removed for good, keyed or picked this turn, or
* held by a standing wizard. */
function doorIsOpen(state: GameState, key: string): boolean {
return state.doorStates[key] === "removed" ||
state.openDoorEdges.includes(key) ||
state.heldDoors.some((h) => h.key === key);
}
/** A held door needs its holder: standing adjacent and alive. Anything that
* moves or fells them — a step, a shove, a teleport, a killing blow — lets
* the door swing shut. */
function sweepHeldDoors(state: GameState, events: GameEvent[]): void {
for (const h of [...state.heldDoors]) {
const holder = state.players.find((p) => p.id === h.by);
if (holder?.alive && isAdjacentToEdge(holder.position, h.cell, h.side)) continue;
state.heldDoors = state.heldDoors.filter((x) => x !== h);
events.push({ type: "doorReleased", edge: { cell: h.cell, side: h.side } });
}
}
function attachSustained(
state: GameState,
events: GameEvent[],
cardId: string,
casterId: PlayerId,
targetId: PlayerId,
turns: number,
): void {
const effect: SustainedEffect = {
id: `fx-${state.nextEffectId++}`,
cardId,
casterId,
targetId,
remainingTurns: Math.max(1, turns),
data: {},
};
state.sustained.push(effect);
events.push({
type: "spellSustained",
effectId: effect.id,
cardId,
caster: casterId,
target: targetId,
turns: effect.remainingTurns,
});
}
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
function walkingDistance(state: GameState, from: Cell, to: Cell): number {
if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state);
const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from];
while (queue.length > 0) {
const cur = queue.shift()!;
const d = seen.get(cellKey(cur))!;
if (d >= 6) break;
for (const side of SIDES) {
const step = stepTarget(view, cur, side);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
if (seen.has(cellKey(step.to))) continue;
seen.set(cellKey(step.to), d + 1);
if (cellKey(step.to) === cellKey(to)) return d + 1;
queue.push(step.to);
}
}
return seen.get(cellKey(to)) ?? Infinity;
}
/** BFS steps between cells ignoring walls (teleport distance). */
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: [],
heldDoors: [],
createdEdges: {},
squareContents: {},
groundObjects: {},
lastSpellUsed: {},
illusionWalls: {},
creatures: [],
nextCreatureId: 1,
wandCharges: {},
tempWarpEdges: [],
boobytraps: [],
gluedCells: {},
openSafes: [],
enchantedObjects: {},
dimWarps: [],
outOfTurnWindow: null,
ambushes: [],
nextAmbushId: 1,
players,
treasures,
sustained: [],
deck,
discard,
turn: {
round: 1,
firstIndex,
activeIndex: firstIndex,
movementAllowance: BASE_MOVEMENT,
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
wandsUsed: [],
movementAddUsed: false,
attackForbidden: false,
actionsEnded: false,
},
stack: null,
rng,
winner: null,
winReason: null,
pendingDiscard: null,
nextEffectId: 1,
};
events.unshift({
type: "gameStarted",
players: config.playerIds,
firstPlayer: players[firstIndex]!.id,
dieRolls,
placements: board.placements,
homes: board.homes,
});
events.push({ type: "turnStarted", player: players[firstIndex]!.id, round: 1 });
return { state, events };
}
// ---------------------------------------------------------------------------
// Command application
export function applyCommand(state: GameState, playerId: PlayerId, command: Command): CommandResult {
const result = applyCommandInner(state, playerId, command);
if (result.ok) sweepHeldDoors(result.state, result.events);
return result;
}
function applyCommandInner(state: GameState, playerId: PlayerId, command: Command): CommandResult {
if (state.phase !== "playing") return err("game is over");
if (state.pendingDiscard) {
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
if (command.type !== "discard") return err("you must discard down to the hand limit first");
return doDiscard(state, playerId, command.instanceIds);
}
if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass");
}
if (state.chaosPending) {
const head = state.chaosPending.queue[0];
if (playerId !== head) return err("waiting for another player to face the chaos");
if (command.type === "counteract") {
const st = clone(state);
const p = st.players.find((q) => q.id === playerId)!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
if (!card) return err("card not in hand");
if (card.cardId !== "full-shield") return err("only FULL SHIELD keeps your hand out of the chaos");
takeFromHand(p, command.instanceId);
st.discard.push(card);
st.chaosPending!.excluded.push(playerId);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [{ type: "chaosShielded", player: playerId }];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
if (command.type === "pass") {
const st = clone(state);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
return err("chaos is coming — shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
if (state.outOfTurnWindow) {
if (playerId !== state.outOfTurnWindow.playerId) {
return err("an interruption is being resolved");
}
if (command.type === "pass") {
const st = clone(state);
st.outOfTurnWindow = null;
return { ok: true, state: st, events: [] };
}
if (command.type !== "cast" && command.type !== "punch") {
return err("use your interruption (cast or punch) or pass");
}
const st = clone(state);
const idx = st.players.findIndex((p) => p.id === playerId);
const saved = { ...st.turn };
st.turn = {
...st.turn,
activeIndex: idx,
attackUsed: false,
secondAttackUsed: false,
attackForbidden: false,
actionsEnded: false,
};
const kind = st.outOfTurnWindow!.kind;
// OPPORTUNITY FIRE permits an attack; INTERRUPT any one spell.
if (kind === "opportunity-fire" && command.type === "cast") {
const p = st.players[idx]!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
const fx = card ? CARD_EFFECTS[card.cardId] : undefined;
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
}
if (kind === "interrupt" && command.type === "punch") {
return err("interrupt lets you cast a spell, not brawl");
}
st.outOfTurnWindow = null;
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
if (!result.ok) return result; // the window stays open in `state`
const out = result.state;
out.turn = {
...saved,
round: out.turn.round,
};
return { ok: true, state: out, events: result.events };
}
if (activePlayer(state).id !== playerId) {
// Playing INTERRUPT or OPPORTUNITY FIRE out of turn opens a window.
if (command.type === "cast" && !state.stack) {
const p = state.players.find((q) => q.id === playerId && q.alive);
const card = p?.hand.find((c) => c.instanceId === command.instanceId);
if (p && card && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
if (state.turn.round === 1) return err("no combat during the first round of turns");
const castBlock = castingBlocked(state, playerId);
if (castBlock) return err(castBlock);
const st = clone(state);
const pp = st.players.find((q) => q.id === playerId)!;
const taken = takeFromHand(pp, command.instanceId)!;
st.discard.push(taken);
st.outOfTurnWindow = { playerId, kind: card.cardId as "interrupt" | "opportunity-fire" };
st.lastSpellUsed[playerId] = card.cardId;
return {
ok: true,
state: st,
events: [{ type: "outOfTurnWindow", player: playerId, kind: st.outOfTurnWindow!.kind }],
};
}
}
return err("not your turn");
}
switch (command.type) {
case "move": return doMove(state, command.direction, command.over === true);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "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, command.treasureId);
case "pickUpObject": return doPickUpObject(state, command.instanceId);
case "dropObject": return doDropObject(state, command.instanceId);
case "dropTreasure": return doDropTreasure(state);
case "discard": return doDiscard(state, playerId, command.instanceIds);
case "endTurn": return doEndTurn(state, command.draw);
}
}
function err(error: string): CommandResult {
return { ok: false, error };
}
export function activePlayer(state: GameState): PlayerState {
return state.players[state.turn.activeIndex]!;
}
function clone(state: GameState): GameState {
return structuredClone(state);
}
/** Everything an eliminated wizard leaves behind goes with them.
* "If you die, any monster controlled by you immediately disappears." */
function sweepEliminated(state: GameState, playerId: PlayerId): void {
state.sustained = state.sustained.filter((s) => s.targetId !== playerId && s.casterId !== playerId);
state.creatures = state.creatures.filter((c) => c.controllerId !== playerId);
for (const a of state.ambushes.filter((a) => a.ownerId === playerId)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== playerId);
}
function requireActionsAvailable(state: GameState): string | null {
if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object";
return null;
}
function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
const idx = p.hand.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return null;
const card = p.hand.splice(idx, 1)[0]!;
p.displayed = p.displayed.filter((id) => id !== instanceId);
return card;
}
// --- Movement ---------------------------------------------------------------
/** 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)) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
}
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
{
const key = edgeKey(p.position, direction);
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" && doorIsOpen(state, 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 = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
if (roll === 1) {
p.inPit = true;
events.push({ type: "fellInPit", player: p.id, at: p.position });
applyDamage(state, events, p, 2, "pit fall", null, "physical");
state.turn.movementUsed = state.turn.movementAllowance;
checkVictory(state, events);
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
return { ok: true, state, events };
}
const beyond = neighbor(p.position, direction);
const beyondOk =
view.cells[cellKey(beyond)] &&
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
state.squareContents[cellKey(beyond)]?.kind !== "stone";
if (beyondOk) {
const pitCell = p.position;
p.position = beyond;
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond });
content = state.squareContents[cellKey(p.position)];
} else {
// Nowhere to land: teeter back where you started.
p.position = from;
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
}
state.turn.movementUsed++;
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
// WALKING DEAD: 1/2 point per space moved (a full point every two steps).
for (const fx of sustainedOn(state, p.id, "walking-dead")) {
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
if (fx.data.halfSteps % 2 === 0) {
applyDamage(state, events, p, 1, "walking dead", null);
checkVictory(state, events);
}
}
// DISEASE: the carrier infects every other player in a square they enter.
if (p.alive && sustainedOn(state, p.id, "disease").length > 0) {
for (const other of state.players) {
if (!other.alive || other.id === p.id) continue;
if (cellKey(other.position) !== cellKey(p.position)) continue;
applyDamage(state, events, other, 3, "disease", null, "physical");
}
checkVictory(state, events);
}
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).
const preRoll: GameEvent[] = [];
if (isBlinded(state, attacker)) {
const roll = rollD4(state, preRoll, attacker.id, "a blind swing — only a 1 connects");
if (roll !== 1) {
state.turn.attackUsed = true;
return {
ok: true,
state,
events: [...preRoll, {
type: "attackMisdirected", attacker: attacker.id, intended: target.id,
rolledDirection: SIDES[roll - 1]!, newTarget: null,
}],
};
}
}
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.stack = {
attackerId: attacker.id,
defenderId: target.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: "physical",
counters: [],
waitingOn: target.id,
};
return {
ok: true,
state,
events: [...preRoll, { type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }],
};
}
interface CastConsumables {
numbers: CardInstance[];
amplifies: CardInstance[];
add: CardInstance | null;
extend: CardInstance | null;
aroundCorner: CardInstance | null;
powerAttack: CardInstance | null;
powerAttackPoints: number;
magnitude: Magnitude;
}
/** Validate and gather the number/modifier cards attached to a cast. */
function gatherModifiers(
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
): CastConsumables | string {
const numberIds = [...(cmd.numberInstanceIds ?? [])];
if (cmd.numberInstanceId && !numberIds.includes(cmd.numberInstanceId)) {
numberIds.push(cmd.numberInstanceId);
}
const find = (id: string) => caster.hand.find((c) => c.instanceId === id);
const numbers: CardInstance[] = [];
for (const id of numberIds) {
const c = find(id);
if (!c) return "number card not in hand";
if (!isNumberCard(c.cardId)) return "that is not a number card";
numbers.push(c);
}
let add: CardInstance | null = null;
if (cmd.addInstanceId) {
const c = find(cmd.addInstanceId);
if (!c || c.cardId !== "add") return "ADD card not in hand";
add = c;
}
// "Only one NUMBER card can be played per action" — ADD permits two.
if (numbers.length > (add ? 2 : 1)) {
return add ? "ADD permits at most two number cards" : "only one number card per action (use ADD for two)";
}
const amplifies: CardInstance[] = [];
for (const id of cmd.amplifyInstanceIds ?? []) {
const c = find(id);
if (!c || c.cardId !== "amplify") return "AMPLIFY card not in hand";
amplifies.push(c);
}
if (amplifies.length > 2) return "at most two AMPLIFY cards may be combined";
let extend: CardInstance | null = null;
if (cmd.extendInstanceId) {
const c = find(cmd.extendInstanceId);
if (!c || c.cardId !== "extend") return "EXTEND card not in hand";
extend = c;
}
let aroundCorner: CardInstance | null = null;
if (cmd.aroundCornerInstanceId) {
const c = find(cmd.aroundCornerInstanceId);
if (!c || c.cardId !== "around-the-corner") return "AROUND THE CORNER card not in hand";
aroundCorner = c;
}
let powerAttack: CardInstance | null = null;
let powerAttackPoints = 0;
if (cmd.powerAttackInstanceId) {
const c = find(cmd.powerAttackInstanceId);
if (!c || c.cardId !== "power-attack") return "POWER ATTACK card not in hand";
const pts = cmd.powerAttackPoints ?? 0;
if (!Number.isInteger(pts) || pts < 1) return "choose how many life points to burn";
if (pts >= caster.life) return "that would kill you";
powerAttack = c;
powerAttackPoints = pts;
}
// POWERSTONE: "Add 1 to any NUMBER card played."
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
const sum = numbers.length > 0
? numbers.reduce((t, c) => t + numberValue(c.cardId), 0) + stoneBonus
: null;
const amp = 2 ** amplifies.length;
const ext = extend ? 2 : 1;
return {
numbers,
amplifies,
add,
extend,
aroundCorner,
powerAttack,
powerAttackPoints,
magnitude: {
numberValue: sum,
power: (sum ?? 1) * amp,
duration: (sum ?? 1) * amp * ext,
},
};
}
function consumeCast(
state: GameState,
caster: PlayerState,
card: CardInstance,
mods: CastConsumables,
keepInHand: boolean,
): void {
// (Wand cards manage their own lifetime via charges — callers pass
// keepInHand=true for them and never discard here.)
if (keepInHand) {
if (caster.hand.some((c) => c.instanceId === card.instanceId) &&
!caster.displayed.includes(card.instanceId)) {
caster.displayed.push(card.instanceId);
}
} else {
takeFromHand(caster, card.instanceId);
state.discard.push(card);
}
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner, mods.powerAttack]) {
if (!c) continue;
takeFromHand(caster, c.instanceId);
state.discard.push(c);
}
}
function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const caster = activePlayer(state);
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId);
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") {
return err(`${def.name} is a counteraction — play it in response to an attack`);
}
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
}
// 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("there is no wall or door on that edge to attack");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall");
}
if (effect.requiresLos && !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
}
}
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster" (rules rev 16; earlier ledgers resolved these blows
* unmirrored and replay so). */
function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void {
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind);
if ((ctx.state.config.deckRev ?? 1) >= 16 && dmg > 0 && ctx.attacker.alive &&
sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) {
applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind);
}
}
/** A NUMBER card riding a counteraction sets the raised spell's duration. */
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
const num = (numberInstanceIds ?? [])
.map((id) => player.hand.find((c) => c.instanceId === id))
.find((c): c is CardInstance => c != null && isNumberCard(c.cardId));
if (!num) return 1;
takeFromHand(player, num.instanceId);
state.discard.push(num);
return numberValue(num.cardId);
}
function doCounteract(
prev: GameState, playerId: PlayerId, instanceId: string,
params?: { cell?: Cell }, numberInstanceIds?: string[],
): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!;
const card = player.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
const def = cardDef(card.cardId);
// "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA);
// NO SPELL blocks all spells too.
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" }],
};
}
// INVISIBLE (corner: NEUTRAL/COUNTERACTION): vanish as the bolt flies.
// The spell truly goes up — duration from an attached NUMBER card — and
// the attacker's 1-in-4 hit roll happens at resolution.
if (card.cardId === "invisible") {
if (stack.creatureId && (state.config.deckRev ?? 1) < 13) {
return err("in this game's revision, creatures are not fooled by the vanishing");
}
const duration = counterDuration(state, player, numberInstanceIds);
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
];
attachSustained(state, events, "invisible", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return { ok: true, state, events };
}
// EMPATHY (corner: COUNTERACTION): "Any attack done in any form against
// you acts against both you and the caster" — raised as the blow lands,
// lingering for the NUMBER card's duration after.
if (card.cardId === "empathy") {
const duration = counterDuration(state, player, numberInstanceIds);
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
];
attachSustained(state, events, "empathy", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return { ok: true, state, events };
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
// WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image.
if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") {
const stops = card.cardId === "wall-of-fire" ? "waterbolt" : "fireball";
if (stack.attackCard?.cardId !== stops) {
return err(`as a counteraction, ${cardDef(card.cardId).name} only stops a ${cardDef(stops).name}`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stops }],
};
}
// SHIELDSTONE: "use a NUMBER card as a counteraction against point- or
// duration-based spells, reducing effects by [its] value."
if (isNumberCard(card.cardId)) {
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction";
if (!isCounter || !(card.cardId in CARD_EFFECTS) || CARD_EFFECTS[card.cardId]!.kind !== "counter") {
return err(`${def.name} cannot counteract (or is not implemented yet)`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
if (playerId === stack.attackerId) {
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
// ABSORB SPELL "will work on other COUNTERACTIONS" — but only ones used
// against YOU: "will work on FULL REFLECTION or REFLECTION, but not FULL
// SHIELD, as that is not used against you" (official FAQ).
if (card.cardId === "absorb-spell") {
if (!targetCounter) return err("no counteraction to absorb");
const id = targetCounter.card.cardId;
if (id !== "reflection" && id !== "full-reflection") {
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
// The absorbed reflection leaves the discard for the absorber's hand.
const di = state.discard.findIndex((c) => c.instanceId === targetCounter.card.instanceId);
if (di !== -1) {
state.discard.splice(di, 1);
player.hand.push(targetCounter.card);
if (player.hand.length > handLimit(player)) state.pendingDiscard = player.id;
}
stack.waitingOn = stack.defenderId;
return {
ok: true,
state,
events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }],
};
}
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
if (!targetCounter) return err("no counteraction to nullify");
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
// (the card face). Teleport is rev-6-gated — earlier games replay their
// old chains; invisible needs no gate, since no earlier engine ever
// accepted it as a counteraction for a ledger to contain.
if (targetCounter.card.cardId === "invisible" ||
((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) {
return err("ANTI-ANTI does not work against escape");
}
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
const events: GameEvent[] = [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }];
if (targetCounter.card.cardId === "empathy") {
// The raised link dies with its counteraction.
const fx = state.sustained.filter((f) => f.cardId === "empathy" && f.targetId === stack.defenderId).pop();
if (fx) {
state.sustained = state.sustained.filter((f) => f !== fx);
events.push({ type: "spellExpired", effectId: fx.id, cardId: "empathy", target: stack.defenderId });
}
}
stack.waitingOn = stack.defenderId;
return { ok: true, state, events };
}
return err("you are not part of this exchange");
}
/** Counters that leave the defender nothing worth adding: the spell is dead,
* escaped, thrown back whole, or drunk as a tonic. */
const TOTAL_STOP_COUNTERS = new Set([
"full-shield", "force-field", "teleport", "full-reflection", "reverse", "waterwall",
]);
function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const events: GameEvent[] = [];
if (playerId === stack.attackerId) {
// The attacker declining to answer bounces the exchange back so the
// defender may stack further counters — but not past a total stop:
// re-prompting then reads as "your shield failed" and goads the table
// into burning cards against a spell that is already dead (rules rev 10).
const rev = state.config.deckRev ?? 1;
const totalStop = rev >= 10 &&
stack.counters.some((c) =>
!c.nullified &&
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
// Wall-of-fire counters pre-date the total-stop rule; their stored
// exchanges bounced, and replay so (rules rev 12).
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
(stack.kind === "spell" || c.card.cardId === "teleport"));
if (!totalStop) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events };
}
}
resolveStack(state, events);
checkVictory(state, events);
return { ok: true, state, events };
}
function resolveStack(state: GameState, events: GameEvent[]): void {
const stack = state.stack!;
state.stack = null;
const attacker = state.players.find((p) => p.id === stack.attackerId)!;
const defender = state.players.find((p) => p.id === stack.defenderId)!;
const attackId = stack.attackCard?.cardId ?? null;
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
// Creature blows roll the same dice from rev 13 — the cards carve out no
// exception for monsters; earlier games skipped the rolls and replay so.
const missRolls = !stack.creatureId || (state.config.deckRev ?? 1) >= 13;
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
if (roll !== 1) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
return;
}
}
if (missRolls && sustainedOn(state, defender.id, "shrink").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
if (roll > 2) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
return;
}
}
let base = effect
? effect.baseDamage(stack.numberValue, stack.params)
: stack.creatureId
? (stack.params?.damage ?? 1) // a creature's blow
: 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" || counter.card.cardId === "waterwall") {
// Fire meets water, whichever was thrown first: entirely stopped.
pipe.damage = 0;
pipe.fullyStopped = true;
continue;
}
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
continue; // their work is the sustained effect, not the damage pipe
}
if (counter.card.cardId === "teleport") {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
continue;
}
if (stack.creatureId &&
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
// "REFLECTIONs used on the wraith's touch will damage the wraith."
if (counter.card.cardId === "reflection") {
const half = Math.ceil(pipe.damage / 2);
pipe.reflectedDamage += half;
pipe.damage = half;
} else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
}
continue;
}
const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe);
}
// A surviving teleport counter whisks the defender away before anything lands.
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
if (escape) {
const from = defender.position;
defender.position = { ...escape.cell! };
events.push({ type: "teleported", player: defender.id, from, to: defender.position, by: defender.id, cardId: "teleport" });
}
let damageDealt = 0;
const attackingCreature = stack.creatureId
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) {
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" });
sweepEliminated(state, target.id);
if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
t.carriedBy = null;
t.position = target.position;
target.carriedTreasureId = null;
events.push({
type: "treasureDropped",
player: target.id,
treasureId: t.id,
at: target.position,
onHomeOf: homeOwnerAt(state, target.position),
});
}
const killer = attackerId ? state.players.find((p) => p.id === attackerId) : undefined;
if (killer && killer.alive && target.hand.length > 0) {
const taken = target.hand.splice(0);
target.displayed = [];
killer.hand.push(...taken);
events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length });
events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken });
if (killer.hand.length > handLimit(killer)) state.pendingDiscard = killer.id;
} else if (target.hand.length > 0) {
state.discard.push(...target.hand.splice(0));
target.displayed = [];
}
}
function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
const p = state.players.find((p) => cellKey(p.home) === cellKey(cell));
return p ? p.id : null;
}
// --- Treasures ---------------------------------------------------------------
function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const p = activePlayer(state);
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
if (sustainedOn(state, p.id, "weakness").length > 0) return err("you are too weak to carry treasure");
const here = cellKey(p.position);
if (state.gluedCells[here]) return err("it is glued fast to the floor");
const safe = state.squareContents[here]?.kind === "safe";
if (safe && state.squareContents[here]!.createdBy !== p.id && !state.openSafes.includes(here)) {
return err("it is locked inside a safe");
}
// Two treasures can share a square; an id names the one wanted.
const t = state.treasures.find(
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy &&
(treasureId == null || t.id === treasureId),
);
if (!t) return err(treasureId ? "that treasure is not here for the taking" : "no treasure here");
t.carriedBy = p.id;
t.position = null;
p.carriedTreasureId = t.id;
state.turn.actionsEnded = true;
const events: GameEvent[] = [
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
];
// WARD: "you may play at that time (out of turn) this card on him" — 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" });
if ((state.config.deckRev ?? 1) >= 14) sweepEliminated(state, p.id);
}
}
for (const p of state.players) {
if (!p.alive) continue;
const stolenAtHome = state.treasures.filter(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home),
);
if (stolenAtHome.length >= 2) {
state.phase = "finished";
state.winner = p.id;
state.winReason = "treasures";
events.push({ type: "gameWon", player: p.id, reason: "treasures" });
return;
}
}
const alive = state.players.filter((p) => p.alive);
if (alive.length === 1) {
state.phase = "finished";
state.winner = alive[0]!.id;
state.winReason = "lastStanding";
events.push({ type: "gameWon", player: alive[0]!.id, reason: "lastStanding" });
}
}
// --- Hand management and turn flow -------------------------------------------
function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): CommandResult {
const state = clone(prev);
const p = state.players.find((p) => p.id === playerId)!;
const cards: CardInstance[] = [];
for (const id of instanceIds) {
const card = takeFromHand(p, id);
if (!card) return err(`card not in hand: ${id}`);
cards.push(card);
}
state.discard.push(...cards);
if (state.pendingDiscard === playerId && p.hand.length <= handLimit(p)) {
state.pendingDiscard = null;
}
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
}
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
const stacks = sustainedOn(state, p.id, "slow-death").length;
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return;
for (let i = 0; i < cardsDrawn * stacks; i++) {
if (!p.alive) break;
applyDamage(state, events, p, 1, "slow death", null);
}
checkVictory(state, events);
}
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
if (state.deck.length === 0) {
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
state.rng = rngNext;
state.deck = reshuffled;
state.discard = [];
events.push({ type: "deckReshuffled", size: state.deck.length });
if (state.deck.length === 0) return null;
}
return state.deck.shift()!;
}
/** Sustained-effect upkeep + turn flags when `player` begins a turn. */
function beginTurnFor(state: GameState, events: GameEvent[], index: number): void {
const player = state.players[index]!;
// Creatures refresh at each game-turn boundary. Democratic monsters refresh
// movement for EVERY player's turn; others for their controller's.
for (const c of state.creatures) {
c.justCreated = false;
c.scorchedThisTurn = [];
if (c.kind === "democratic-monster") {
c.movementUsed = 0;
// 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) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
// KILLER OOZE / PIT: struggle rolls before you may move this turn.
let struggleImmobilized = false;
if (player.fallenInOoze) {
const roll = rollD4(state, events, player.id, "struggling in the ooze — 1-2 breaks free");
const stood = roll <= 2;
events.push({ type: "struggledInOoze", player: player.id, stood });
if (stood) player.fallenInOoze = false;
else struggleImmobilized = true;
}
if (player.inPit) {
const roll = rollD4(state, events, player.id, "climbing from the pit — 1-2 gets out");
const out = roll <= 2;
events.push({ type: "climbedFromPit", player: player.id, success: out });
if (out) player.inPit = false;
else struggleImmobilized = true;
}
// Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK
// forces 2; SPEEDSTONE adds 1 otherwise.
let allowance = BASE_MOVEMENT;
if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2);
if (displays(player, "speedstone")) allowance += 1;
// STICKY WAND webs: "reducing movement by 3 (enemy can still use NUMBER
// cards for additional movement)."
allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length);
const slows = sustainedOn(state, player.id, "slow");
if (slows.length > 0) allowance = 1;
if (struggleImmobilized) allowance = 0;
// SLOW: "his attacks [reduce] to every other turn, starting on his next
// turn" — forbidden on the 1st, 3rd, ... slowed turns.
let attackForbidden = false;
for (const s of slows) {
s.data.turnCount = (s.data.turnCount ?? 0) + 1;
if (s.data.turnCount % 2 === 1) attackForbidden = true;
}
state.turn = {
round: state.turn.round,
firstIndex: state.turn.firstIndex,
activeIndex: index,
movementAllowance: allowance,
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
secondAttackUsed: false,
wandsUsed: [],
movementAddUsed: false,
attackForbidden,
actionsEnded: false,
};
}
/** Roll the D4 in the open: the pips land in the chronicle. */
function rollD4(state: GameState, events: GameEvent[], player: PlayerId | null, purpose: string): number {
const [roll, next] = rollDie(state.rng);
state.rng = next;
events.push({ type: "dieRolled", player, roll, purpose });
return roll;
}
/** An effect's turns ran out: announce it and clean up what it built. */
function expireEffect(state: GameState, events: GameEvent[], s: SustainedEffect): void {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
}
function doEndTurn(prev: GameState, draw: number): CommandResult {
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
const state = clone(prev);
const p = activePlayer(state);
const events: GameEvent[] = [];
const room = handLimit(p) - p.hand.length;
const count = 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);
// "If you have SPEED on you while under the influence of a duration
// spell, it uses up a turn of that duration" — recipient-counted (FAQ;
// rules rev 8). Self-cast durations already burned in beginTurnFor.
if ((state.config.deckRev ?? 1) >= 8) {
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
}
events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events };
}
const n = state.players.length;
let next = state.turn.activeIndex;
for (;;) {
next = (next + 1) % n;
if (next === state.turn.firstIndex) state.turn.round++;
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 };
}