// 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; /** For edge-bound spells (WALL OF FIRE): the edge to clean up on expiry. */ edge?: string; } export type AmbushTrigger = | { kind: "los" } // an opponent enters my line of sight | { kind: "near" } // an opponent comes within one space of me | { kind: "treasure" }; // an opponent picks up any treasure export interface AmbushState { id: string; ownerId: PlayerId; /** The card that grants the interruption. */ via: CardInstance; trigger: AmbushTrigger; /** The committed attack and its number cards, held out of the hand. */ spell: CardInstance; numbers: CardInstance[]; } /** A summoned creature (or SHADOW/ALTER EGO double). */ export interface CreatureState { id: string; kind: "troll" | "skeleton" | "wraith" | "fire-imp" | "democratic-monster" | "shadow" | "alter-ego"; controllerId: PlayerId; position: Cell; /** Damage taken so far. */ damage: number; /** Damage needed to destroy (Infinity: immune to ordinary damage). */ maxDamage: number; movesPerTurn: number; movementUsed: number; attackUsed: boolean; /** No attacks on the turn it was created. */ justCreated: boolean; /** WRAITH: may pass through one wall/object per turn. */ wallPassesPerTurn: number; wallPassUsed: number; /** FIRE IMP: players already scorched this game-turn. */ scorchedThisTurn: PlayerId[]; } /** Something occupying a whole square (stone, bushes, ooze, pits, ...). */ export interface SquareContent { kind: "stone" | "thornbush" | "rosebush" | "ooze" | "dust" | "slime" | "tacks" | "pit" | "safe"; /** Damage taken so far (thornbush dies at 5, rosebush at 5, ooze at 5 fire). */ damage: number; createdBy: PlayerId; } const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"] as const; /** Duration meaning "for the rest of the game" ("This card is permanent."). */ const PERMANENT_TURNS = 1_000_000_000; /** Which square contents block line of sight. */ export const LOS_BLOCKING_CONTENT: Record = { 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 }[]; waitingOn: PlayerId; } export interface CastParams { damage?: number; knockback?: number; cell?: Cell; /** BOOBYTRAP: the four token cells; the FIRST is the real trap. */ cells?: Cell[]; cardId?: string; points?: number; clockwise?: boolean; } export interface GameConfig { playerIds: PlayerId[]; seed: number; sets: CardSet[]; /** Chosen wizard colors, per player (index 0-5 into the six physical * standees: green, red, magenta, blue, light blue, yellow). Defaults to * seat order. */ colors?: number[]; /** * Deck revision. Absent = the original build, which stored games replay * against forever. Revision 2 removes LIFESAVER from two-player decks * ("Not applicable in a 2-player game." — the card face). */ 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; /** Permanent door-lock changes, by edge key. */ doorStates: Record; /** Door edges unlocked until the end of the current turn. */ openDoorEdges: string[]; /** Walls/firewalls conjured during play (dispellable), by edge key. */ createdEdges: Record; /** Square-filling creations, by cell key. */ squareContents: Record; /** Object cards lying on the floor, by cell key. */ groundObjects: Record; /** The last spell card each player used (for REUSE SPELL). */ lastSpellUsed: Record; /** ILLUSION WALLs by edge key: real only for those who believe. */ illusionWalls: Record }>; creatures: CreatureState[]; nextCreatureId: number; /** Remaining charges per wand card instance (set on first use). */ wandCharges: Record; /** 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; /** SAFE cells unlocked until end of turn (lock cards / the creator). */ openSafes: string[]; /** SWARTHMORE'S ENCHANTMENT: enchanted object instances (+1 magical). */ enchantedObjects: Record; /** 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 { const blockers: Record = {}; for (const [key, content] of Object.entries(state.squareContents)) { if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; } for (const p of state.players) { if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true; } return blockers; } /** LOS including square-filling blockers. */ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { return sightBetween(boardView(state), from, to, losBlockers(state)); } /** Parse an edge key back into its north/west cell and side. */ function parseEdgeKey(key: string): { cell: Cell; side: Side } { const [kind, coords] = key.split(":") as [string, string]; const [x, y] = coords.split(",").map(Number) as [number, number]; return { cell: { x, y }, side: kind === "V" ? "E" : "S" }; } /** * What does this player believe about an illusion wall? Rolls the 50% chance * lazily the first time it matters ("when they gain L.O.S. to it"). */ function illusionBelief( state: GameState, events: GameEvent[], playerId: PlayerId, key: string, ): "believes" | "seesThrough" { const wall = state.illusionWalls[key]!; if (wall.createdBy === playerId) return "seesThrough"; const known = wall.belief[playerId]; if (known) return known; const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; const result = roll <= 2 ? "seesThrough" : "believes"; wall.belief[playerId] = result; events.push({ type: "illusionTested", player: playerId, edge: key, result }); return result; } /** The board as one player perceives it: believed illusions become walls. */ function perceivedBoard( state: GameState, events: GameEvent[], viewerId: PlayerId, sightLine?: { from: Cell; to: Cell }, ): AssembledBoard { const view = boardView(state); const keys = Object.keys(state.illusionWalls); if (keys.length === 0) return view; const edges = { ...view.edges }; for (const key of keys) { const known = state.illusionWalls[key]!.belief[viewerId]; const isCreator = state.illusionWalls[key]!.createdBy === viewerId; if (isCreator || known === "seesThrough") continue; if (known === "believes") { edges[key] = "wall"; continue; } // Untested: only roll if this sight line would actually cross it. if (sightLine) { const test = { ...view, edges: { [key]: "wall" as const } }; const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to); if (crossesIt) { if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall"; } } else { edges[key] = "wall"; // no sight context: treat as real until tested } } return { ...view, edges }; } /** * LOS for a caster: VISIONSTONE lets its holder see through exactly one * wall or door (of any type); believed ILLUSION WALLs block them. */ function casterLos( state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[] = [], ): boolean { const board = perceivedBoard(state, events, caster.id, { from, to }); const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; for (const key of Object.keys(board.edges)) { if ((board.edges[key] ?? "open") === "open") continue; const edges = { ...board.edges }; delete edges[key]; if (sightBetween({ ...board, edges }, from, to, blockers)) return true; } return false; } /** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean { if (casterLos(state, caster, from, to, events)) return true; const view = boardView(state); for (const key of Object.keys(view.cells)) { const [mx, my] = key.split(",").map(Number) as [number, number]; const mid = { x: mx, y: my }; if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) { return true; } } return false; } function inThornbush(state: GameState, p: PlayerState): boolean { return state.squareContents[cellKey(p.position)]?.kind === "thornbush"; } function isMisted(state: GameState, playerId: PlayerId): boolean { return sustainedOn(state, playerId, "mist-body").length > 0; } function isLockedInPlace(state: GameState, playerId: PlayerId): boolean { return sustainedOn(state, playerId, "lock-in-place").length > 0; } /** BLIND spell, or standing inside a DUST CLOUD. */ function isBlinded(state: GameState, p: PlayerState): boolean { if (sustainedOn(state, p.id, "blind").length > 0) return true; return state.squareContents[cellKey(p.position)]?.kind === "dust"; } /** Is a stone (or other displayable) face-up in front of this player? */ export function displays(p: PlayerState, cardId: string): boolean { return p.hand.some((c) => c.cardId === cardId && p.displayed.includes(c.instanceId)); } /** BRAINSTONE: "Hand limit is now nine cards (including this card)." */ export function handLimit(p: PlayerState): number { return displays(p, "brainstone") ? HAND_LIMIT + 2 : HAND_LIMIT; } // --------------------------------------------------------------------------- // Events export type GameEvent = | { type: "gameStarted"; players: PlayerId[]; firstPlayer: PlayerId; dieRolls: Record; placements: AssembledBoard["placements"]; homes: Cell[] } | { type: "cardsDealt"; player: PlayerId; count: number } | { type: "cardsDealtPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] } | { type: "trapRedrawnDuringDeal"; player: PlayerId } | { type: "turnStarted"; player: PlayerId; round: number } | { type: "turnSkipped"; player: PlayerId; reason: "lostTurn" } | { type: "extraTurnStarted"; player: PlayerId } | { type: "moved"; player: PlayerId; from: Cell; to: Cell; direction: Side; via: "step" | "warp" | "passWall" } | { type: "numberPlayedForMovement"; player: PlayerId; card: CardInstance; value: number; newAllowance: number } | { type: "punched"; attacker: PlayerId; target: PlayerId; at: Cell } | { type: "spellCast"; caster: PlayerId; card: CardInstance; cardId: string; numberCards: CardInstance[]; numberValue: number | null; from: Cell; target: PlayerId | null; targetCell: Cell | null } | { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string } | { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance } | { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance } | { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" } | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number } | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" } | { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number } | { type: "stunned"; player: PlayerId; turnsLost: number } | { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number } | { type: "stonesDestroyed"; player: PlayerId; cards: CardInstance[] } | { type: "spellSustained"; effectId: string; cardId: string; caster: PlayerId; target: PlayerId; turns: number } | { type: "spellExpired"; effectId: string; cardId: string; target: PlayerId } | { type: "teleported"; player: PlayerId; from: Cell; to: Cell; by: PlayerId; cardId: string } | { type: "positionsSwapped"; a: PlayerId; b: PlayerId; aTo: Cell; bTo: Cell } | { type: "cardErased"; player: PlayerId; cardId: string | null; found: boolean } | { type: "cardsStolen"; from: PlayerId; to: PlayerId; count: number } | { type: "cardsStolenPrivate"; visibleTo: PlayerId; cards: CardInstance[] } | { type: "handRevealed"; player: PlayerId; to: PlayerId } | { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] } | { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number } | { type: "firewallExpired"; edge: string } | { type: "firewallBurned"; player: PlayerId } | { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number } | { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: SquareContent["kind"] } | { type: "creationDispelled"; caster: PlayerId; what: string } | { type: "enteredThornbush"; player: PlayerId; at: Cell } | { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell } | { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean } | { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell } | { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell } | { type: "spellReused"; player: PlayerId; card: CardInstance } | { type: "castAroundCorner"; caster: PlayerId } | { type: "moveBumped"; player: PlayerId; direction: Side } | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } | { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell } | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell } | { type: "creatureCreated"; creatureId: string; kind: CreatureState["kind"]; controller: PlayerId; at: Cell } | { type: "creatureMoved"; creatureId: string; from: Cell; to: Cell; direction: Side; by: PlayerId } | { type: "creatureAttacked"; creatureId: string; target: PlayerId | string; dieRoll: number | null } | { type: "creatureTouched"; creatureId: string; player: PlayerId } | { type: "creatureDamaged"; creatureId: string; amount: number; source: string; damageTotal: number } | { type: "creatureDestroyed"; creatureId: string; kind: CreatureState["kind"]; by: string } | { type: "trollRegenerated"; creatureId: string } | { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number } | { type: "impScorches"; creatureId: string; player: PlayerId } | { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" } | { type: "wandCharged"; player: PlayerId; card: CardInstance; charges: number } | { type: "wandUsed"; player: PlayerId; cardId: string; chargesLeft: number } | { type: "wandExhausted"; player: PlayerId; card: CardInstance } | { type: "wallWarpedOpen"; player: PlayerId; edge: { cell: Cell; side: Side } } | { type: "wallsWarpedBack"; count: number } | { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId } | { type: "webbed"; player: PlayerId } | { type: "cardRetrieved"; player: PlayerId; cardId: string } | { type: "slippedInOoze"; player: PlayerId; at: Cell } | { type: "struggledInOoze"; player: PlayerId; stood: boolean } | { type: "steppedOnTacks"; player: PlayerId; at: Cell } | { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell } | { type: "fellInPit"; player: PlayerId; at: Cell } | { type: "climbedFromPit"; player: PlayerId; success: boolean } | { type: "stuckInSlime"; player: PlayerId; at: Cell } | { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] } | { type: "boobytrapSprung"; player: PlayerId; at: Cell } | { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell } | { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number } | { type: "safeCreated"; caster: PlayerId; at: Cell } | { type: "safeOpened"; player: PlayerId; at: Cell } | { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell } | { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null } | { type: "handsSwapped"; a: PlayerId; b: PlayerId } | { type: "handsScrambled"; caster: PlayerId } | { type: "rammed"; attacker: PlayerId; target: PlayerId; distance: number } | { type: "treasureThrown"; attacker: PlayerId; at: Cell; distance: number } | { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean } | { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string } | { type: "itemsSwapped"; a: PlayerId; b: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId } | { type: "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: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorsRelocked"; count: number } | { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } } | { type: "lockRemoved"; player: PlayerId; edge: { cell: Cell; side: Side } } | { type: "cardDisplayed"; player: PlayerId; card: CardInstance } | { type: "extraTurnGranted"; player: PlayerId } | { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number } | { type: "trapSprung"; player: PlayerId } | { type: "died"; player: PlayerId; killedBy: PlayerId | null } | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } | { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] } | { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell } | { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null } | { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" } | { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] } | { type: "cardsDrawn"; player: PlayerId; count: number } | { type: "cardsDrawnPrivate"; visibleTo: PlayerId; cards: CardInstance[] } | { type: "deckReshuffled"; size: number } | { type: "turnEnded"; player: PlayerId } | { type: "gameWon"; player: PlayerId; reason: "treasures" | "lastStanding" }; export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | null { if ("visibleTo" in event && event.visibleTo !== viewer) return null; return event; } // --------------------------------------------------------------------------- // Commands export type CastTarget = | { kind: "player"; playerId: PlayerId } | { kind: "creature"; creatureId: string } | { kind: "edge"; cell: Cell; side: Side } | { kind: "cell"; cell: Cell }; export type Command = | { type: "move"; direction: Side } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "punch"; targetId: PlayerId } | { 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 } | { type: "pass" } | { type: "pickUpTreasure" } | { type: "pickUpObject"; instanceId: string } | { type: "dropObject"; instanceId: string } | { type: "dropTreasure" } | { type: "discard"; instanceIds: string[] } | { type: "endTurn"; draw: number }; export type CommandResult = | { ok: true; state: GameState; events: GameEvent[] } | { ok: false; error: string }; // --------------------------------------------------------------------------- // Card effects registry type AttackEffect = { kind: "attack"; requiresLos?: boolean; /** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */ physical?: boolean; /** Attacker must share the target's square (WIZARDBLADE). */ sameSquare?: boolean; baseDamage: (numberValue: number | null, params: CastParams | null) => number; /** A duration spell: attach a sustained effect on resolution. */ sustains?: boolean; /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ keepInHand?: boolean; validate?: (state: GameState, cmd: Extract) => 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, 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 = { // --- 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. (Counteraction // use vs WATERBOLT: TODO.) resolve: (state, events, caster, cmd, magnitude) => { if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge"; const { cell, side } = cmd.target; const view = boardView(state); if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { return "the fire must span a corridor between two spaces"; } const key = edgeKey(cell, side); if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "firewall"; state.createdEdges[key] = true; const fx: SustainedEffect = { id: `fx-${state.nextEffectId++}`, cardId: "wall-of-fire", casterId: caster.id, targetId: caster.id, remainingTurns: Math.max(1, magnitude.duration), data: {}, edge: key, }; state.sustained.push(fx); events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns }); return null; }, }, waterwall: { kind: "neutral", // "The moment you create it, it collapses, washing away any player within // two spaces back two spaces (including the caster)." resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge"; const { cell, side } = cmd.target; const view = boardView(state); if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { return "the wave must span a corridor between two spaces"; } if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } }); // The two sides of the edge, and the push directions away from it. waveFromEdge(state, events, cell, side, 2, "waterwall"); checkVictory(state, events); return null; }, }, "dispel-creation": { kind: "neutral", resolve: (state, events, caster, cmd) => { const view = boardView(state); if (cmd.target?.kind === "edge") { const key = edgeKey(cmd.target.cell, cmd.target.side); if (state.illusionWalls[key]) { if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight"; delete state.illusionWalls[key]; events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" }); return null; } if (!state.createdEdges[key]) return "that is not a created thing"; if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight"; const was = view.edges[key]; delete state.edgeOverrides[key]; delete state.createdEdges[key]; state.sustained = state.sustained.filter((s) => s.edge !== key); events.push({ type: "creationDispelled", caster: caster.id, what: was === "firewall" ? "wall of fire" : "created wall" }); return null; } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); const creature = creatureAt(state, cmd.target.cell); if (creature) { if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; destroyCreature(state, events, creature, "dispel creation"); events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind }); return null; } const content = state.squareContents[key]; if (!content) return "nothing created there"; if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); return null; } return "dispel targets a created wall, fire, stone, or bush"; }, }, drag: { kind: "neutral", // "Drags any moveable object within L.O.S. towards you ..." (and, per the // rulebook's Objects section, players can be DRAGged too). resolve: (state, events, caster, cmd) => { if (cmd.target?.kind === "player") { const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return "no such living player"; if (target.id === caster.id) return "you cannot drag yourself"; if (!gameLos(state, caster.position, target.position)) return "no line of sight"; if (isLockedInPlace(state, target.id)) return "they are locked in place"; const from = target.position; dragToward(state, target, caster.position); events.push({ type: "objectDragged", caster: caster.id, what: target.id, from, to: target.position }); return null; } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; const objects = state.groundObjects[key]; const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key); if (objects && objects.length > 0) { const card = objects[objects.length - 1]!; objects.pop(); if (objects.length === 0) delete state.groundObjects[key]; const destKey = cellKey(caster.position); state.groundObjects[destKey] = [...(state.groundObjects[destKey] ?? []), card]; events.push({ type: "objectDragged", caster: caster.id, what: card.cardId, from: cmd.target.cell, to: caster.position }); return null; } if (treasure) { const from = treasure.position!; treasure.position = { ...caster.position }; events.push({ type: "objectDragged", caster: caster.id, what: treasure.id, from, to: caster.position }); checkVictory(state, events); return null; } return "nothing to drag there"; } return "drag targets an object square or a player"; }, }, // --- Magic stones --------------------------------------------------------- bloodstone: stoneEffect(), powerstone: stoneEffect(), shadowstone: stoneEffect(), soulstone: stoneEffect(), speedstone: stoneEffect((state, _events, caster) => { // "Your movement rate is increased by 1" — starting now, not next turn. // A delta (not a recompute) so number cards already played stay counted. // No bump while SLOW forces 1, or when this turn's movement is already // forced to zero (pit struggle, sticky webs) — next turn recomputes. const isActive = state.players[state.turn.activeIndex]?.id === caster.id; const slowed = sustainedOn(state, caster.id, "slow").length > 0; if (isActive && !slowed && state.turn.movementAllowance > 0) { state.turn.movementAllowance += 1; } }), shieldstone: stoneEffect(), visionstone: stoneEffect(), brainstone: stoneEffect((state, events, caster) => { // "Draw two more cards, now." const drawn: CardInstance[] = []; for (let i = 0; i < 2; i++) { const card = drawOne(state, events); if (card) drawn.push(card); } caster.hand.push(...drawn); events.push({ type: "cardsDrawn", player: caster.id, count: drawn.length }); events.push({ type: "cardsDrawnPrivate", visibleTo: caster.id, cards: drawn }); applySlowDeathOnDraw(state, events, caster, drawn.length); }), "slow-death": { kind: "attack", requiresLos: true, baseDamage: () => 0, // "This is permanent. Once SLOW DEATH is on, it can't be turned off." onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); }, }, blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, "around-the-corner": { kind: "neutral", // Never cast alone — attached to an attack via aroundCornerInstanceId. resolve: () => "attach Around The Corner to an attack instead of casting it alone", }, ugly: { kind: "neutral", // "All opponents in L.O.S. retreat as far away as necessary to avoid // L.O.S., along the shortest path available." resolve: (state, events, caster) => { for (const opp of state.players) { if (!opp.alive || opp.id === caster.id) continue; if (!gameLos(state, caster.position, opp.position)) continue; if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue; retreatFromSight(state, events, opp, caster.position); } return null; }, }, "illusion-wall": { kind: "neutral", resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge"; const { cell, side } = cmd.target; const view = boardView(state); if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { return "the illusion must span two spaces on the board"; } const key = edgeKey(cell, side); if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line"; if (state.illusionWalls[key]) return "an illusion already shimmers there"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.illusionWalls[key] = { createdBy: caster.id, belief: {} }; events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } }); return null; }, }, "rotate-sector": { kind: "neutral", // "Allows you to rotate any one sector 90 degrees." resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "cell") return "click a square in the sector to rotate"; const idx = sectorIndexAt(state.board, cmd.target.cell); if (idx === -1) return "that is not on a sector"; rotateSector(state, idx, cmd.params?.clockwise ?? true); events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true }); return null; }, }, "relocate-sector": { kind: "neutral", // "Relocate (but not rotate) any one sector to any other area, so long as // all sectors are still adjacent to at least one other." resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "cell") return "click the destination area"; const fromCell = cmd.params?.cell; if (!fromCell) return "pick the sector to move first"; const idx = sectorIndexAt(state.board, fromCell); if (idx === -1) return "that is not on a sector"; const dest: Cell = { x: Math.floor(cmd.target.cell.x / 5) * 5, y: Math.floor(cmd.target.cell.y / 5) * 5, }; const fromOrigin = { ...state.board.placements[idx]!.origin }; const problem = relocateSector(state, idx, dest); if (problem) return problem; events.push({ type: "sectorRelocated", caster: caster.id, sectorIndex: idx, from: fromOrigin, to: dest, }); return null; }, }, // --- Expansion #1: creatures --------------------------------------------- troll: summonEffect("troll"), skeleton: summonEffect("skeleton"), wraith: summonEffect("wraith"), "fire-imp": summonEffect("fire-imp"), "democratic-monster": summonEffect("democratic-monster"), shadow: summonEffect("shadow"), "alter-ego": { kind: "neutral", // "Create a stationary double of yourself in the square you now occupy." resolve: (state, events, caster) => { if (creatureAt(state, caster.position)) return "a creature is already here"; spawnCreature(state, events, "alter-ego", caster.id, caster.position); return null; }, }, "mega-monster": { kind: "neutral", resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "creature") return "mega-monster targets a monster"; const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); if (!creature) return "no such monster"; if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster"; if (!gameLos(state, caster.position, creature.position)) return "no line of sight"; const boost = cmd.params?.cardId === "movement" ? "movement" : "life"; if (boost === "movement") creature.movesPerTurn *= 2; else creature.maxDamage *= 2; events.push({ type: "monsterBoosted", creatureId: creature.id, boost }); return null; }, }, adrenaline: { kind: "neutral", // "Allows two attacks in one turn. Duration equals NUMBER card played." resolve: (state, events, caster, _cmd, magnitude) => { attachSustained(state, events, "adrenaline", caster.id, caster.id, magnitude.duration); return null; }, }, "mad-dash": { kind: "neutral", // "Doubles your movement (including NUMBER cards and other add-ons) for // one turn. You cannot carry treasures while exerting yourself." resolve: (state, events, caster) => { if (caster.carriedTreasureId) return "you cannot mad-dash while carrying a treasure"; state.turn.movementAllowance *= 2; events.push({ type: "lifeTraded", player: caster.id, points: 0, newAllowance: state.turn.movementAllowance }); return null; }, }, lifesaver: { kind: "neutral", resolve: (state, events, caster) => { if (state.players.filter((p) => p.alive).length <= 2) return "not applicable in a 2-player game"; attachSustained(state, events, "lifesaver", caster.id, caster.id, PERMANENT_TURNS); return null; }, }, // --- Expansion #1: magic wands ------------------------------------------- "blaster-wand": { kind: "attack", requiresLos: true, keepInHand: true, // "does 3 points of magical damage per charge" baseDamage: () => 3, }, "sticky-wand": { kind: "attack", requiresLos: true, keepInHand: true, baseDamage: () => 0, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; // "reducing movement by 3 ... Lasts one turn. Any fire damage done to // player in webs causes two extra points." attachSustained(ctx.state, ctx.events, "sticky-web", ctx.attacker.id, ctx.defender.id, 1); ctx.events.push({ type: "webbed", player: ctx.defender.id }); }, }, "shift-wand": { kind: "attack", requiresLos: true, keepInHand: true, baseDamage: () => 0, validate: (state, cmd) => { const cell = cmd.params?.cell; if (!cell) return "choose the adjacent space to shove them into"; if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; return null; }, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return; const to = ctx.stack.params!.cell!; const from = ctx.defender.position; const dist = Math.abs(to.x - from.x) + Math.abs(to.y - from.y); if (dist !== 1) return; // must be adjacent to where they stand // "he could be shoved through a stone wall" — walls do not stop it. ctx.defender.position = to; ctx.events.push({ type: "shoved", player: ctx.defender.id, from, to, by: ctx.attacker.id }); }, }, "warp-wand": { kind: "neutral", keepInHand: true, // "makes 1 section (one space long) of wall disappear during your turn, // reappearing at the end of your turn." resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "edge") return "aim the wand at a wall section"; const { cell, side } = cmd.target; const key = edgeKey(cell, side); const view = boardView(state); if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.edgeOverrides[key] = "open"; events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); return null; }, }, "deja-vu": { kind: "neutral", // "Retrieve any one card from the discard pile (except a MAGIC WAND)." resolve: (state, events, caster, cmd) => { const wanted = cmd.params?.cardId; if (!wanted) return "name the card to retrieve"; if ((WAND_CARD_IDS as readonly string[]).includes(wanted)) { return "deja-vu cannot retrieve a magic wand"; } for (let i = state.discard.length - 1; i >= 0; i--) { if (state.discard[i]!.cardId === wanted) { const [card] = state.discard.splice(i, 1); caster.hand.push(card!); events.push({ type: "cardRetrieved", player: caster.id, cardId: wanted }); if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id; return null; } } return "that card is not in the discard pile"; }, }, // --- Expansion #1: terrain ----------------------------------------------- "killer-ooze": terrainEffect("ooze"), rosebush: terrainEffect("rosebush"), "dust-cloud": terrainEffect("dust"), "fill-square-with-slime": terrainEffect("slime"), "create-pit": terrainEffect("pit"), "handful-of-tacks": { kind: "neutral", // NEUTRAL / ADJACENT: scattered at your feet, not thrown across the maze. resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "cell") return "target a square"; const cell = cmd.target.cell; if (Math.abs(cell.x - caster.position.x) + Math.abs(cell.y - caster.position.y) > 1) { return "you must be adjacent to scatter tacks"; } const problem = emptySquareTarget(state, cmd, caster); if (typeof problem === "string") return problem; state.squareContents[cellKey(cell)] = { kind: "tacks", damage: 0, createdBy: caster.id }; events.push({ type: "squareFilled", caster: caster.id, cell, kind: "tacks" }); return null; }, }, "create-door": { kind: "neutral", resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "edge") return "create-door targets a wall"; const { cell, side } = cmd.target; const key = edgeKey(cell, side); const view = boardView(state); if ((view.edges[key] ?? "open") === "open") { if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { return "the door must stand between two spaces"; } } else if (view.edges[key] !== "wall") { return "a door goes into a stone wall or an open corridor"; } if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "door"; state.createdEdges[key] = true; events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); return null; }, }, boobytrap: { kind: "neutral", // Four face-down tokens; only the caster knows which is real. resolve: (state, events, caster, cmd) => { const cells = cmd.params?.cells; if (!cells || cells.length !== 4) return "place four tokens (the first is the real trap)"; const view = boardView(state); for (const c of cells) { if (!view.cells[cellKey(c)]) return "a token is off the board"; if (state.squareContents[cellKey(c)]?.kind === "stone") return "a token is inside solid stone"; } const uniq = new Set(cells.map(cellKey)); if (uniq.size !== 4) return "the four tokens go on four different squares"; state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) }); events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] }); events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! }); return null; }, }, glue: { kind: "neutral", resolve: (state, events, caster, cmd, magnitude) => { if (!cmd.target || cmd.target.kind !== "cell") return "glue targets an object's square"; const key = cellKey(cmd.target.cell); const hasObject = (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to glue down"; if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; state.gluedCells[key] = true; // "a duration equal to twice the NUMBER card played" const turns = magnitude.duration * 2; const fx: SustainedEffect = { id: `fx-${state.nextEffectId++}`, cardId: "glue", casterId: caster.id, targetId: caster.id, remainingTurns: Math.max(1, turns), data: {}, edge: key, }; state.sustained.push(fx); events.push({ type: "objectsGlued", caster: caster.id, at: cmd.target.cell, turns: fx.remainingTurns }); return null; }, }, safe: { kind: "neutral", resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "cell") return "safe targets a treasure or item"; const key = cellKey(cmd.target.cell); if (state.squareContents[key]) return "that square is occupied"; const hasObject = (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to lock up"; if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id }; events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); return null; }, }, trader: { kind: "neutral", // Swap two floor items, both in LOS. Does not overcome GLUE. resolve: (state, events, caster, cmd) => { const a = cmd.params?.cell; const bT = cmd.target; if (!a || !bT || bT.kind !== "cell") return "pick the two item squares to swap"; const b = bT.cell; const ka = cellKey(a), kb = cellKey(b); if (ka === kb) return "pick two different squares"; if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe"; if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight"; const itemsA = state.groundObjects[ka] ?? []; const itemsB = state.groundObjects[kb] ?? []; const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); const treasureB = state.treasures.find((t) => t.position && cellKey(t.position) === kb); if (itemsA.length + (treasureA ? 1 : 0) === 0 || itemsB.length + (treasureB ? 1 : 0) === 0) { return "both squares must hold an item"; } if (itemsA.length > 0 || itemsB.length > 0) { if (itemsA.length > 0) state.groundObjects[kb] = [...itemsA]; else delete state.groundObjects[kb]; if (itemsB.length > 0) state.groundObjects[ka] = [...itemsB]; else delete state.groundObjects[ka]; } if (treasureA) treasureA.position = { ...b }; if (treasureB) treasureB.position = { ...a }; events.push({ type: "itemsTraded", caster: caster.id, a, b }); checkVictory(state, events); return null; }, }, "stone-to-water": { kind: "neutral", resolve: (state, events, caster, cmd) => { const view = boardView(state); if (cmd.target?.kind === "edge") { const { cell, side } = cmd.target; const key = edgeKey(cell, side); if (view.edges[key] !== "wall") return "that is not a stone wall"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "open"; delete state.createdEdges[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null }); // "Wall turns into a WATERWALL with a range and damage of 2." waveFromEdge(state, events, cell, side, 2); checkVictory(state, events); return null; } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); // "Solid stone block turns into a WATERWALL with a range and damage of 4." waveFromCell(state, events, cmd.target.cell, 4); checkVictory(state, events); return null; } return "target a stone wall or a solid stone block"; }, }, // --- Expansion #1: fortune and misfortune -------------------------------- "gift-from-above": { kind: "neutral", // "Add three points to your total, now. You may go higher than fifteen." resolve: (state, events, caster) => { caster.life += 3; events.push({ type: "lifeGained", player: caster.id, amount: 3, source: "gift from above", lifeAfter: caster.life }); return null; }, }, "power-attack": { kind: "neutral", // Handled as a cast modifier (powerAttackPoints); casting it alone is a // usage error. resolve: () => "attach Power Attack to a damage spell (choose life points to burn)", }, strength: { kind: "neutral", resolve: (state, events, caster, _cmd, magnitude) => { attachSustained(state, events, "strength", caster.id, caster.id, magnitude.duration); return null; }, }, weakness: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; // "Opponent drops any treasure carried." if (ctx.defender.carriedTreasureId) { const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!; t.carriedBy = null; t.position = ctx.defender.position; ctx.defender.carriedTreasureId = null; ctx.events.push({ type: "treasureDropped", player: ctx.defender.id, treasureId: t.id, at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position), }); } }, }, "walking-dead": { kind: "attack", requiresLos: true, baseDamage: () => 0, // "1/2 point of damage for every space moved. This spell is permanent." onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); }, }, disease: { kind: "attack", baseDamage: () => 0, sameSquare: false, validate: (state, cmd) => { const target = state.players.find((p) => p.id === (cmd.target as { playerId?: PlayerId })?.playerId); const caster = activePlayer(state); if (!target) return null; const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y); return d <= 1 ? null : "disease spreads by touch — you must be adjacent"; }, sustains: true, }, empathy: { kind: "neutral", // Counteraction card used proactively: while it lasts, attacks against // you act against the attacker too. resolve: (state, events, caster, _cmd, magnitude) => { attachSustained(state, events, "empathy", caster.id, caster.id, magnitude.duration); return null; }, }, "force-field": { kind: "counter", // Stops the spell attack outright (daggers and blades slip through). apply: (p) => { if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; } }, }, "mental-swap": { kind: "attack", requiresLos: true, baseDamage: () => 0, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; const aHand = ctx.attacker.hand; ctx.attacker.hand = ctx.defender.hand; ctx.defender.hand = aHand; const aDisp = ctx.attacker.displayed; ctx.attacker.displayed = ctx.defender.displayed; ctx.defender.displayed = aDisp; ctx.events.push({ type: "handsSwapped", a: ctx.attacker.id, b: ctx.defender.id }); const check = (p: PlayerState) => { if (p.hand.length > handLimit(p)) ctx.state.pendingDiscard = p.id; }; check(ctx.attacker); check(ctx.defender); }, }, "mental-force": { kind: "attack", baseDamage: () => 0, // no LOS printed validate: (state, cmd) => { const cell = cmd.params?.cell; if (!cell) return "say where they go (within three moved spaces)"; if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; return null; }, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return; const to = ctx.stack.params!.cell!; if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; const from = ctx.defender.position; ctx.defender.position = to; ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" }); }, }, "butt-head": { kind: "attack", physical: true, baseDamage: () => 0, // computed at resolution: distance rammed onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + Math.abs(ctx.defender.position.y - ctx.attacker.position.y); if (d === 0) return; ctx.attacker.position = { ...ctx.defender.position }; ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); applyDamage(ctx.state, ctx.events, ctx.defender, d, "goat ram", ctx.attacker.id, "physical"); }, }, "heave-ho": { kind: "attack", requiresLos: true, physical: true, baseDamage: () => 0, validate: (state) => { const caster = activePlayer(state); return caster.carriedTreasureId ? null : "you have no treasure to throw"; }, onResolved: (ctx) => { if (!ctx.attacker.carriedTreasureId) return; const t = ctx.state.treasures.find((t) => t.id === ctx.attacker.carriedTreasureId)!; const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + Math.abs(ctx.defender.position.y - ctx.attacker.position.y); t.carriedBy = null; t.position = { ...ctx.defender.position }; ctx.attacker.carriedTreasureId = null; ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d }); if (!ctx.fullyStopped && ctx.defender.alive && d > 0) { applyDamage(ctx.state, ctx.events, ctx.defender, d, "hurled treasure", ctx.attacker.id, "physical"); } ctx.events.push({ type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id, at: t.position!, onHomeOf: homeOwnerAt(ctx.state, t.position!), }); checkVictory(ctx.state, ctx.events); }, }, thief: { kind: "attack", sameSquare: true, baseDamage: () => 0, validate: (_s, cmd) => (cmd.params?.cardId ? null : "name the item to steal"), onResolved: (ctx) => { if (ctx.fullyStopped) return; const wanted = ctx.stack.params!.cardId!; if (wanted === "treasure") return; // "The item may not be a treasure." const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted && cardDef(c.cardId).cardType === "object"); if (idx === -1) return; const [card] = ctx.defender.hand.splice(idx, 1); ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId); ctx.attacker.hand.push(card!); ctx.events.push({ type: "itemStolen", from: ctx.defender.id, to: ctx.attacker.id, cardId: wanted }); if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id; }, }, chaos: { kind: "attack", baseDamage: () => 0, // Everyone's hands into one pile, shuffled, dealt back in equal counts. // (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions // are not modeled — chaos resolves for all living players at once.) onResolved: (ctx) => { const players = ctx.state.players.filter((p) => p.alive); 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(ctx.state.rng, pile); ctx.state.rng = rngNext; let i = 0; players.forEach((p, pi) => { p.hand = shuffled.slice(i, i + counts[pi]!); i += counts[pi]!; ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] }); }); ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id }); }, }, "illusionary-attack": { kind: "attack", requiresLos: true, baseDamage: () => 0, validate: (_s, cmd) => { const chosen = cmd.params?.cardId; if (!chosen) return "choose the attack spell to fake"; const fx = CARD_EFFECTS[chosen]; if (!fx || fx.kind !== "attack") return "that is not an attack spell"; return null; }, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; const chosen = ctx.stack.params!.cardId!; const fx = CARD_EFFECTS[chosen] as AttackEffect; const [roll, rngNext] = rollDie(ctx.state.rng); ctx.state.rng = rngNext; const believed = roll <= 2; ctx.events.push({ type: "illusionBelieved", player: ctx.defender.id, cardId: chosen, believed }); if (!believed) return; const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null); if (dmg > 0) { applyDamage(ctx.state, ctx.events, ctx.defender, dmg, `illusionary ${chosen}`, ctx.attacker.id); } }, }, "swap-meet": { kind: "attack", requiresLos: true, baseDamage: () => 0, validate: (_s, cmd) => (cmd.params?.cardId ? null : "name your item and theirs (yours;theirs)"), onResolved: (ctx) => { if (ctx.fullyStopped) return; const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";"); const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object"); const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object"); if (mine === -1 || theirs === -1) return; const [a] = ctx.attacker.hand.splice(mine, 1); const [b] = ctx.defender.hand.splice(theirs, 1); ctx.attacker.hand.push(b!); ctx.defender.hand.push(a!); ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId); ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId); ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id }); }, }, "remove-curse": { kind: "neutral", // Counteraction used out of the stack: strip one duration spell. resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "player") return "choose whose curse to remove"; const wanted = cmd.params?.cardId; if (!wanted) return "name the spell to remove"; const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target) return "no such player"; const idx = state.sustained.findIndex((fx) => fx.targetId === target.id && fx.cardId === wanted); if (idx === -1) return "no such spell on them"; // "Has to hit to affect SHRINK and INVISIBLE." if (wanted === "invisible" || wanted === "shrink") { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; const needed = wanted === "invisible" ? 1 : 2; if (roll > needed) { events.push({ type: "attackMissed", attacker: caster.id, defender: target.id, attackCardId: "remove-curse", because: wanted as "invisible" | "shrink" }); return null; } } const [fx] = state.sustained.splice(idx, 1); if (fx!.cardId === "glue" && fx!.edge) delete state.gluedCells[fx!.edge]; events.push({ type: "curseRemoved", caster: caster.id, target: target.id, cardId: wanted }); return null; }, }, "swarthmores-enchantment": { kind: "neutral", resolve: (state, events, caster, cmd) => { const wanted = cmd.params?.cardId; if (!wanted) return "name the object to enchant"; // Find the instance: your hand, a target player's hand, or the floor. let instance: CardInstance | undefined = caster.hand.find((c) => c.cardId === wanted); if (!instance && cmd.target?.kind === "player") { const t = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); instance = t?.hand.find((c) => c.cardId === wanted); } if (!instance) { for (const objs of Object.values(state.groundObjects)) { instance = objs.find((c) => c.cardId === wanted); if (instance) break; } } if (!instance) return "no such object in sight"; state.enchantedObjects[instance.instanceId] = true; events.push({ type: "objectEnchanted", caster: caster.id, cardId: wanted }); return null; }, }, ward: { kind: "neutral", // WARD is never cast from the hand — it springs automatically when your // treasure is grabbed (see doPickUpTreasure). resolve: () => "Ward waits in your hand and springs when your treasure is taken", }, idiot: { kind: "attack", requiresLos: true, baseDamage: () => 0, // "Opponent heads straight for the nearest of his own treasures ... This // lasts until opponent is on his own treasure." sustains: false, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position); if (!hasTreasureOut) return; // "ends if both treasures are being carried" attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); }, }, "big-man": { kind: "neutral", resolve: (state, events, caster, _cmd, magnitude) => { attachSustained(state, events, "big-man", caster.id, caster.id, magnitude.duration); return null; }, }, fear: { kind: "neutral", resolve: (state, events, caster, _cmd, magnitude) => { attachSustained(state, events, "fear", caster.id, caster.id, magnitude.duration); return null; }, }, "dimensional-warp": { kind: "neutral", // Two tokens anywhere (except home bases); stepping between them costs 1. resolve: (state, events, caster, cmd) => { const a = cmd.params?.cell; const bT = cmd.target; if (!a || !bT || bT.kind !== "cell") return "place the two warp tokens"; const b = bT.cell; const view = boardView(state); for (const c of [a, b]) { if (!view.cells[cellKey(c)]) return "off the board"; if (view.homes.some((h) => cellKey(h) === cellKey(c))) return "not on a home base"; if (state.squareContents[cellKey(c)]?.kind === "stone") return "inside solid stone"; } if (cellKey(a) === cellKey(b)) return "the tokens go on two different squares"; state.dimWarps.push({ a: { ...a }, b: { ...b } }); events.push({ type: "warpTokensPlaced", caster: caster.id, a, b }); return null; }, }, redirection: { kind: "neutral", // "Swap two external sector exits" — their wraparound destinations trade. resolve: (state, events, caster, cmd) => { const a = cmd.params?.cell; const bT = cmd.target; if (!a || !bT || bT.kind !== "cell") return "pick the two exits to swap"; const b = bT.cell; const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a)); const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b)); if (!wa || !wb || wa === wb) return "pick two different outer exits"; // Swap destinations and fix the reciprocal warps to match. const destA = { ...wa.to }; const destB = { ...wb.to }; wa.to = destB; wb.to = destA; for (const w of state.board.warps) { if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side }; if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side }; } events.push({ type: "exitsRedirected", caster: caster.id }); return null; }, }, "opportunity-fire": { kind: "neutral", resolve: () => "played out of turn — wait for another player's turn, then use it", }, interrupt: { kind: "neutral", resolve: () => "played out of turn — use it during another player's turn", }, "thumb-of-god": { kind: "neutral", // Digital redesign ("divine meteor", chosen by the owner): aim at a // square; the die drifts 0-2 squares in a random direction, then every // token in and around the landing square — objects, treasures, creatures, // even wizards — is flung to a random nearby square. Walls mean nothing // to falling cardboard. "There is no COUNTERACTION against this spell." resolve: (state, events, caster, cmd) => { const pre = attackPreconditions(state); if (pre) return pre; if (!cmd.target || cmd.target.kind !== "cell") return "aim the die at a square"; const aim = cmd.target.cell; const view = boardView(state); if (!view.cells[cellKey(aim)]) return "off the board"; if (!casterLos(state, caster, caster.position, aim, events)) return "no line of sight"; state.turn.attackUsed = true; const clampToBoard = (c: Cell): Cell => { if (view.cells[cellKey(c)]) return c; // knocked off the board: settle at the nearest on-board cell let best: Cell = aim; let bestD = Infinity; for (const key of Object.keys(view.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; const d = Math.abs(x - c.x) + Math.abs(y - c.y); if (d < bestD) { bestD = d; best = { x, y }; } } return best; }; // Drift: 1 = dead on; 2-3 = one square off; 4 = two squares off. let landed = aim; { const [d1, r1] = rollDie(state.rng); state.rng = r1; const drift = d1 === 1 ? 0 : d1 === 4 ? 2 : 1; if (drift > 0) { const [d2, r2] = rollDie(state.rng); state.rng = r2; const dir = SIDES[d2 - 1]!; landed = clampToBoard({ x: aim.x + (dir === "E" ? drift : dir === "W" ? -drift : 0), y: aim.y + (dir === "S" ? drift : dir === "N" ? -drift : 0), }); } } events.push({ type: "thumbOfGod", caster: caster.id, aimedAt: aim, landedAt: landed }); const inBlast = (c: Cell) => Math.abs(c.x - landed.x) <= 1 && Math.abs(c.y - landed.y) <= 1; const scatterTo = (from: Cell): Cell => { const [d, rNext] = rollDie(state.rng); state.rng = rNext; const dir = SIDES[d - 1]!; const [d2, rNext2] = rollDie(state.rng); state.rng = rNext2; const dist = d2 <= 2 ? 1 : 2; return clampToBoard({ x: from.x + (dir === "E" ? dist : dir === "W" ? -dist : 0), y: from.y + (dir === "S" ? dist : dir === "N" ? -dist : 0), }); }; const safeCell = (c: Cell): Cell => state.squareContents[cellKey(c)]?.kind === "stone" ? landed : c; for (const [key, objs] of Object.entries({ ...state.groundObjects })) { const [x, y] = key.split(",").map(Number) as [number, number]; if (!inBlast({ x, y })) continue; delete state.groundObjects[key]; for (const o of objs) { const to = safeCell(scatterTo({ x, y })); state.groundObjects[cellKey(to)] = [...(state.groundObjects[cellKey(to)] ?? []), o]; events.push({ type: "tokenScattered", what: o.cardId, from: { x, y }, to }); } } for (const t of state.treasures) { if (!t.position || !inBlast(t.position)) continue; const from = t.position; t.position = safeCell(scatterTo(from)); events.push({ type: "tokenScattered", what: t.id, from, to: t.position }); } for (const c of state.creatures) { if (!inBlast(c.position)) continue; const from = c.position; c.position = safeCell(scatterTo(from)); events.push({ type: "tokenScattered", what: c.kind, from, to: c.position }); } for (const p of state.players) { if (!p.alive || !inBlast(p.position)) continue; if (isLockedInPlace(state, p.id)) continue; const from = p.position; p.position = safeCell(scatterTo(from)); events.push({ type: "tokenScattered", what: p.id, from, to: p.position }); } checkVictory(state, events); return null; }, }, "swap-home-bases": { kind: "neutral", // "Swap your home base with any other player, as long as you both have an // equal number of treasures on your home bases. You must be within L.O.S." resolve: (state, events, caster, cmd) => { if (!cmd.target || cmd.target.kind !== "player") return "choose whose home to take"; const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!other || !other.alive) return "no such living player"; if (other.id === caster.id) return "that is already your home"; if (!gameLos(state, caster.position, other.position)) return "no line of sight to them"; const onHome = (home: Cell) => state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length; if (onHome(caster.home) !== onHome(other.home)) { return "your home bases must hold an equal number of treasures"; } const mine = caster.home; caster.home = other.home; other.home = mine; events.push({ type: "positionsSwapped", a: caster.id, b: other.id, aTo: caster.home, bTo: other.home }); checkVictory(state, events); return null; }, }, "reuse-spell": { kind: "neutral", // "You may retrieve any spell you use immediately after you use it (but // not the NUMBER card)." resolve: (state, events, caster) => { const lastId = state.lastSpellUsed[caster.id]; if (!lastId || lastId === "reuse-spell") return "no spell to retrieve"; // The most recent copy of that card in the discard pile is yours. for (let i = state.discard.length - 1; i >= 0; i--) { if (state.discard[i]!.cardId === lastId) { const [card] = state.discard.splice(i, 1); caster.hand.push(card!); events.push({ type: "spellReused", player: caster.id, card: card! }); if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id; delete state.lastSpellUsed[caster.id]; return null; } } return "that spell is no longer in the discard pile"; }, }, }; /** Simple square-filling terrain creations (ooze, rosebush, dust, slime, pit). */ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect { return { kind: "neutral", resolve: (state, events, caster, cmd) => { const problem = emptySquareTarget(state, cmd, caster); if (typeof problem === "string") return problem; state.squareContents[cellKey(problem)] = { kind, damage: 0, createdBy: caster.id }; events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind }); return null; }, }; } /** A collapsing waterwall wave from an edge: wash players back `range`. */ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void { const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E"); const pushes: { start: Cell; dir: Side }[] = [ { start: cell, dir: away(side) }, { start: neighbor(cell, side), dir: side }, ]; for (const { start, dir } of pushes) { let probe = start; for (let dist = 0; dist < range; dist++) { for (const p of state.players) { if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range); } for (const c of [...state.creatures]) { if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) { destroyCreature(state, events, c, reason); } } probe = neighbor(probe, dir); } } } /** A wave bursting outward from a cell in all four directions. */ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void { for (const dir of SIDES) { let probe = center; for (let dist = 0; dist < range; dist++) { probe = neighbor(probe, dir); for (const p of state.players) { if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range); } for (const c of [...state.creatures]) { if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) { destroyCreature(state, events, c, "rushing water"); } } } } } /** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */ function summonEffect(kind: CreatureState["kind"]): NeutralEffect { return { kind: "neutral", resolve: (state, events, caster, cmd) => { // Summons are printed ATTACK cards: they respect attack rules and // consume the turn's attack ("creating a monster counts as your // attack ... on the turn he is created"), but open no counteraction // window — nobody is attacked yet. const pre = attackPreconditions(state); if (pre) return pre; if (!cmd.target || cmd.target.kind !== "cell") return "choose a square in sight for the creature"; const at = cmd.target.cell; const view = boardView(state); if (!view.cells[cellKey(at)]) return "off the board"; if (state.squareContents[cellKey(at)]) return "that square is blocked"; if (creatureAt(state, at)) return "a creature is already there"; if (!casterLos(state, caster, caster.position, at, events)) return "no line of sight"; state.turn.attackUsed = true; spawnCreature(state, events, kind, caster.id, at); return null; }, }; } /** A displayable stone: casting it turns it face-up; its power is passive. */ function stoneEffect(onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect { return { kind: "neutral", keepInHand: true, displayOnce: true, resolve: (state, events, caster) => { onDisplay?.(state, events, caster); return null; }, }; } /** Thrown weapons land in the target's square, whatever the counters did. */ function landThrownObject(ctx: ResolutionContext, cardId: string): void { const card = ctx.stack.attackCard!; const key = cellKey(ctx.defender.position); ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card]; // The card was discarded on cast; move it from the discard to the floor. const di = ctx.state.discard.findIndex((c) => c.instanceId === card.instanceId); if (di !== -1) ctx.state.discard.splice(di, 1); ctx.events.push({ type: "objectThrown", attacker: ctx.attacker.id, cardId, landedAt: ctx.defender.position }); } /** Validate a cell target for square-filling creations. */ function emptySquareTarget( state: GameState, cmd: Extract, caster: PlayerState, ): Cell | string { if (!cmd.target || cmd.target.kind !== "cell") return "target a square"; const cell = cmd.target.cell; const key = cellKey(cell); const view = boardView(state); if (!view.cells[key]) return "off the board"; if (state.squareContents[key]) return "that square is occupied"; if (view.homes.some((h) => cellKey(h) === key)) return "you cannot create on a home base"; if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there"; if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there"; if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there"; if (!gameLos(state, caster.position, cell)) return "no line of sight"; return cell; } /** WATERWALL family: push a player `range` spaces; 1 damage per blocked space. */ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: Side, range: number): void { if (isLockedInPlace(state, p.id)) return; const view = boardView(state); const from = p.position; let moved = 0; for (let i = 0; i < range; i++) { const step = stepTarget(view, p.position, dir); if (step.kind === "blocked") break; if (state.squareContents[cellKey(step.to)]?.kind === "stone") break; p.position = step.to; moved++; } const blockedSpaces = range - moved; events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces }); if (blockedSpaces > 0) { applyDamage(state, events, p, blockedSpaces, "waterwall crush", null, "physical"); } } // --------------------------------------------------------------------------- // Creatures const CREATURE_STATS: Record = { 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, amount, source, damageTotal: creature.damage }); if (creature.damage >= creature.maxDamage) { destroyCreature(state, events, creature, source); } } function destroyCreature(state: GameState, events: GameEvent[], creature: CreatureState, by: string): void { state.creatures = state.creatures.filter((c) => c.id !== creature.id); events.push({ type: "creatureDestroyed", creatureId: creature.id, kind: creature.kind, by }); } /** FIRE IMP: scorch any player in its LOS, once per player per game-turn. */ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): void { for (const imp of state.creatures.filter((c) => c.kind === "fire-imp")) { for (const p of state.players) { if (!p.alive) continue; if (onlyPlayer && p.id !== onlyPlayer) continue; if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn if (imp.scorchedThisTurn.includes(p.id)) continue; // "Imp cannot see through a FIREWALL!" — gameLos already blocks on // firewall edges and terrain. if (!gameLos(state, imp.position, p.position)) continue; imp.scorchedThisTurn.push(p.id); events.push({ type: "impScorches", creatureId: imp.id, player: p.id }); applyDamage(state, events, p, 2, "fire imp", null); } } checkVictory(state, events); } function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); const state = clone(prev); const active = activePlayer(state); const creature = creatureById(state, creatureId); if (!creature) return err("no such creature"); // The DEMOCRATIC MONSTER is moved by every player; others obey their creator. if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) { return err("that creature does not obey you"); } if (creature.movesPerTurn === 0) return err("that creature cannot move"); if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left"); const events: GameEvent[] = []; const view = boardView(state); const from = creature.position; const target = stepTarget(view, creature.position, direction); if (target.kind === "blocked") { // WRAITH: "can move ... through 1 wall or object per turn." const dest = neighbor(creature.position, direction); if ( creature.wallPassesPerTurn > creature.wallPassUsed && view.cells[cellKey(dest)] && state.squareContents[cellKey(dest)]?.kind !== "stone" ) { creature.wallPassUsed++; creature.position = dest; } else { return err(`blocked by ${target.by}`); } } else { const content = state.squareContents[cellKey(target.to)]; if (content?.kind === "stone") return err("that square is solid stone"); 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. 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, player: p.id }); 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, player: p.id }); applyDamage(state, events, p, 2, "clawing monster", null); } } checkVictory(state, events); return { ok: true, state, events }; } 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, target: targetId, dieRoll: roll }); if (targetPlayer) { 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([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 = (rec: Record, mapKey: (k: string) => string): Record => 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.createdEdges = remapRecord(state.createdEdges, mapEdgeKey); state.doorStates = remapRecord(state.doorStates, mapEdgeKey); state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey); state.squareContents = remapRecord(state.squareContents, mapCellKey); state.groundObjects = remapRecord(state.groundObjects, mapCellKey); for (const fx of state.sustained) { if (fx.edge) fx.edge = mapEdgeKey(fx.edge); } for (const p of state.players) { if (inSector(p.position)) p.position = mapCell(p.position); if (inSector(p.home)) p.home = mapCell(p.home); } for (const t of state.treasures) { if (t.position && inSector(t.position)) t.position = mapCell(t.position); } } /** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ function rotateSector(state: GameState, index: number, clockwise: boolean): void { const placement = state.board.placements[index]!; const { x: ox, y: oy } = placement.origin; const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5; const mapCell = (c: Cell): Cell => { const lx = c.x - ox, ly = c.y - oy; return clockwise ? { x: ox + (4 - ly), y: oy + lx } : { x: ox + ly, y: oy + (4 - lx) }; }; const order: Side[] = ["N", "E", "S", "W"]; const mapSide = (s: Side): Side => order[(order.indexOf(s) + (clockwise ? 1 : 3)) % 4]!; const newPlacements = state.board.placements.map((p, i) => i === index ? { ...p, rotation: (((p.rotation + (clockwise ? 90 : 270)) % 360) as 0 | 90 | 180 | 270) } : p, ); const oldWarps = state.board.warps; const rebuilt = assembleBoard(newPlacements); rebuilt.warps = oldWarps; // openings are centered: rotation never moves them state.board = rebuilt; remapState(state, inSector, mapCell, mapSide); } /** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */ function relocateSector(state: GameState, index: number, dest: Cell): string | null { const placements = state.board.placements; const current = placements[index]!.origin; if (dest.x === current.x && dest.y === current.y) return "the sector is already there"; if (dest.x < 0 || dest.y < 0) return "the sector cannot go there"; for (let i = 0; i < placements.length; i++) { if (i === index) continue; const o = placements[i]!.origin; if (o.x === dest.x && o.y === dest.y) return "another sector is there"; } // "All sectors still adjacent to at least one other." const origins = placements.map((p, i) => (i === index ? dest : p.origin)); const adjacent = (a: Cell, b: Cell) => (Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x); for (let i = 0; i < origins.length; i++) { if (!origins.some((o, j) => j !== i && adjacent(origins[i]!, o))) { return "every sector must stay adjacent to at least one other"; } } const { x: ox, y: oy } = current; const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5; const dx = dest.x - ox, dy = dest.y - oy; const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy }); const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p)); // "Only opposite board edges connect" after a relocation: default pairings. state.board = assembleBoard(newPlacements); remapState(state, inSector, mapCell, (s) => s); return null; } /** DRAG a player straight toward the caster, stopping at walls. */ function dragToward(state: GameState, target: PlayerState, dest: Cell): void { const view = boardView(state); for (let guard = 0; guard < 20; guard++) { if (cellKey(target.position) === cellKey(dest)) return; const dx = dest.x - target.position.x; const dy = dest.y - target.position.y; let dir: Side; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; else dir = dy > 0 ? "S" : "N"; const step = stepTarget(view, target.position, dir); if (step.kind === "blocked") return; if (state.squareContents[cellKey(step.to)]?.kind === "stone") return; target.position = step.to; } } // --------------------------------------------------------------------------- // Effect helpers function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean { const n = neighbor(cell, side); return sightBetween(board, from, cell) || sightBetween(board, from, n); } function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean { return cellKey(pos) === cellKey(cell) || cellKey(pos) === cellKey(neighbor(cell, side)); } function doorTarget( state: GameState, cmd: Extract, ): { 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, cardId: string, opts: { requireAdjacent: boolean }, ): string | null { const found = doorTarget(state, cmd); if (typeof found === "string") return found; const key = edgeKey(found.cell, found.side); if (state.doorStates[key] === "jammed") return "the lock is jammed solid"; if (state.doorStates[key] === "removed") return "that door has no lock"; if (opts.requireAdjacent && !isAdjacentToEdge(caster.position, found.cell, found.side)) { return "you must be adjacent to the door"; } if (!state.openDoorEdges.includes(key)) state.openDoorEdges.push(key); events.push({ type: "doorUnlocked", player: caster.id, edge: found, withCardId: cardId }); return null; } function attachSustained( state: GameState, events: GameEvent[], cardId: string, casterId: PlayerId, targetId: PlayerId, turns: number, ): void { const effect: SustainedEffect = { id: `fx-${state.nextEffectId++}`, cardId, casterId, targetId, remainingTurns: Math.max(1, turns), data: {}, }; state.sustained.push(effect); events.push({ type: "spellSustained", effectId: effect.id, cardId, caster: casterId, target: targetId, turns: effect.remainingTurns, }); } /** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ function walkingDistance(state: GameState, from: Cell, to: Cell): number { if (cellKey(from) === cellKey(to)) return 0; const view = boardView(state); const seen = new Map([[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([[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 = 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(); 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: {}, doorStates: {}, openDoorEdges: [], createdEdges: {}, squareContents: {}, groundObjects: {}, lastSpellUsed: {}, illusionWalls: {}, creatures: [], nextCreatureId: 1, wandCharges: {}, tempWarpEdges: [], boobytraps: [], gluedCells: {}, openSafes: [], enchantedObjects: {}, dimWarps: [], outOfTurnWindow: null, ambushes: [], nextAmbushId: 1, players, treasures, sustained: [], deck, discard, turn: { round: 1, firstIndex, activeIndex: firstIndex, movementAllowance: BASE_MOVEMENT, movementUsed: 0, numberPlayedForMovement: false, attackUsed: false, secondAttackUsed: false, wandsUsed: [], movementAddUsed: false, attackForbidden: false, actionsEnded: false, }, stack: null, rng, winner: null, winReason: null, pendingDiscard: null, nextEffectId: 1, }; events.unshift({ type: "gameStarted", players: config.playerIds, firstPlayer: players[firstIndex]!.id, dieRolls, placements: board.placements, homes: board.homes, }); events.push({ type: "turnStarted", player: players[firstIndex]!.id, round: 1 }); return { state, events }; } // --------------------------------------------------------------------------- // Command application export function applyCommand(state: GameState, playerId: PlayerId, command: Command): CommandResult { if (state.phase !== "playing") return err("game is over"); if (state.pendingDiscard) { if (playerId !== state.pendingDiscard) return err("waiting for another player to discard"); if (command.type !== "discard") return err("you must discard down to the hand limit first"); return doDiscard(state, playerId, command.instanceIds); } if (state.stack) { if (playerId !== state.stack.waitingOn) return err("waiting for another player's response"); if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId); if (command.type === "pass") return doPass(state, playerId); return err("an attack is being resolved — counteract 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); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "punch": return doPunch(state, command.targetId); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "cast": return doCast(state, command); case "setAmbush": return doSetAmbush(state, command); case "cancelAmbush": return doCancelAmbush(state, command.ambushId); case "counteract": return err("nothing to counteract"); case "pass": return err("nothing to pass on"); case "pickUpTreasure": return doPickUpTreasure(state); case "pickUpObject": return doPickUpObject(state, command.instanceId); case "dropObject": return doDropObject(state, command.instanceId); case "dropTreasure": return doDropTreasure(state); case "discard": return doDiscard(state, playerId, command.instanceIds); case "endTurn": return doEndTurn(state, command.draw); } } function err(error: string): CommandResult { return { ok: false, error }; } export function activePlayer(state: GameState): PlayerState { return state.players[state.turn.activeIndex]!; } function clone(state: GameState): GameState { return structuredClone(state); } function requireActionsAvailable(state: GameState): string | null { if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object"; return null; } function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null { const idx = p.hand.findIndex((c) => c.instanceId === instanceId); if (idx === -1) return null; const card = p.hand.splice(idx, 1)[0]!; p.displayed = p.displayed.filter((id) => id !== instanceId); return card; } // --- Movement --------------------------------------------------------------- /** A blind wizard's wasted lurch into a wall still costs a movement point. */ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult { state.turn.movementUsed++; events.push({ type: "moveBumped", player: p.id, direction }); return { ok: true, state, events }; } function doMove(prev: GameState, direction: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); const mover = activePlayer(prev); if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa"); if (isLockedInPlace(prev, mover.id)) return err("you are locked in place"); const state = clone(prev); const p = activePlayer(state); const events: GameEvent[] = []; // IDIOT: every move heads for the nearest of their own treasures. if (sustainedOn(state, p.id, "idiot").length > 0) { const steered = idiotSteer(state, p); if (steered) direction = steered; } // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // move ... bumping into a wall counts as one space of movement." if (isBlinded(state, p)) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; direction = SIDES[roll - 1]!; } // A believed (or untested, when bumped) ILLUSION WALL blocks the believer. { const key = edgeKey(p.position, direction); if (state.illusionWalls[key] && illusionBelief(state, events, p.id, key) === "believes") { if (isBlinded(state, p)) return blindBump(state, events, p, direction); return err("blocked by wall"); } } const view = boardView(state); const target = stepTarget(view, p.position, direction); const misted = isMisted(state, p.id); const from = p.position; let via: "step" | "warp" | "passWall"; let crossedFirewall = false; if (target.kind === "blocked") { const key = edgeKey(p.position, direction); const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); if (!view.cells[cellKey(dest)]) { if (isBlinded(state, p)) return blindBump(state, events, p, direction); return err("blocked"); } if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) { p.position = dest; via = "step"; } else if (edge === "firewall") { // "Passing through it does four points of magical damage." Firewalls // burn even a MIST-BODY. p.position = dest; via = "step"; crossedFirewall = true; } else if (misted && edge === "door") { // MIST-BODY: "pass through anything but solid stone or stone walls" — // doors yes, the maze's stone walls no. p.position = dest; via = "passWall"; } else if (edge === "wall" && p.passWallCharges > 0) { p.passWallCharges--; p.position = dest; via = "passWall"; } else if (isBlinded(state, p)) { return blindBump(state, events, p, direction); } else { return err(`blocked by ${target.by}`); } } else { p.position = target.to; via = target.kind; } // Square contents at the destination. let content = state.squareContents[cellKey(p.position)]; if (content?.kind === "stone") return err("that square is solid stone"); // BIG MAN: nobody enters his square. for (const other of state.players) { if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) && sustainedOn(state, other.id, "big-man").length > 0) { p.position = from; return err("a giant fills that corridor"); } } // FEAR: no one moves within 3 spaces of the fearsome one. for (const other of state.players) { if (other.id === p.id || !other.alive) continue; if (sustainedOn(state, other.id, "fear").length === 0) continue; const d = Math.abs(other.position.x - p.position.x) + Math.abs(other.position.y - p.position.y); const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y); if (d <= 3 && d < dBefore) { p.position = from; return err("an unnatural dread keeps you away"); } } // CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you // fall in (2 damage, movement over); otherwise you sail across to the far // side (if there is open floor there). if (content?.kind === "pit" && !misted && !p.inPit) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll === 1) { p.inPit = true; events.push({ type: "fellInPit", player: p.id, at: p.position }); applyDamage(state, events, p, 2, "pit fall", null, "physical"); state.turn.movementUsed = state.turn.movementAllowance; checkVictory(state, events); events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via }); return { ok: true, state, events }; } const beyond = neighbor(p.position, direction); const beyondOk = view.cells[cellKey(beyond)] && (view.edges[edgeKey(p.position, direction)] ?? "open") === "open" && state.squareContents[cellKey(beyond)]?.kind !== "stone"; if (beyondOk) { const pitCell = p.position; p.position = beyond; events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond }); content = state.squareContents[cellKey(p.position)]; } else { // Nowhere to land: teeter back where you started. p.position = from; state.turn.movementUsed++; events.push({ type: "moveBumped", player: p.id, direction }); return { ok: true, state, events }; } } state.turn.movementUsed++; events.push({ type: "moved", player: p.id, from, to: p.position, direction, via }); // WALKING DEAD: 1/2 point per space moved (a full point every two steps). for (const fx of sustainedOn(state, p.id, "walking-dead")) { fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1; if (fx.data.halfSteps % 2 === 0) { applyDamage(state, events, p, 1, "walking dead", null); checkVictory(state, events); } } // DISEASE: the carrier infects every other player in a square they enter. if (p.alive && sustainedOn(state, p.id, "disease").length > 0) { for (const other of state.players) { if (!other.alive || other.id === p.id) continue; if (cellKey(other.position) !== cellKey(p.position)) continue; applyDamage(state, events, other, 3, "disease", null, "physical"); } checkVictory(state, events); } if (crossedFirewall) { events.push({ type: "firewallBurned", player: p.id }); const webbed = sustainedOn(state, p.id, "sticky-web").length > 0; applyDamage(state, events, p, webbed ? 6 : 4, "wall of fire", null); checkVictory(state, events); } // FIRE IMP: scorches the moment a player enters its line of sight. impCheck(state, events, p.id); // THORNBUSH: "his turn ends, he loses his following turn, and he takes one // point of physical damage from thorns." Mist drifts through unharmed. if (content?.kind === "thornbush" && p.alive && !misted) { events.push({ type: "enteredThornbush", player: p.id, at: p.position }); applyDamage(state, events, p, 1, "thorns", null, "physical"); p.lostTurns++; state.turn.actionsEnded = true; checkVictory(state, events); } // ROSEBUSH: 3 points passing through (no turn loss). if (content?.kind === "rosebush" && p.alive && !misted) { applyDamage(state, events, p, 3, "rosebush thorns", null, "physical"); checkVictory(state, events); } // HANDFUL OF TACKS: 3 points crossing them. if (content?.kind === "tacks" && p.alive && !misted) { events.push({ type: "steppedOnTacks", player: p.id, at: p.position }); applyDamage(state, events, p, 3, "tacks", null, "physical"); checkVictory(state, events); } // KILLER OOZE: 1 point on entry; roll 1-2 to slip — drop treasure, 2 more // points, and movement is over. if (content?.kind === "ooze" && p.alive && !misted) { applyDamage(state, events, p, 1, "acidic ooze", null, "physical"); if (p.alive) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll <= 2) { p.fallenInOoze = true; events.push({ type: "slippedInOoze", player: p.id, at: p.position }); if (p.carriedTreasureId) { const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!; t.carriedBy = null; t.position = p.position; p.carriedTreasureId = null; events.push({ type: "treasureDropped", player: p.id, treasureId: t.id, at: p.position, onHomeOf: homeOwnerAt(state, p.position) }); } applyDamage(state, events, p, 2, "ooze fall", null, "physical"); state.turn.movementUsed = state.turn.movementAllowance; } } checkVictory(state, events); } // FILL SQUARE WITH SLIME: entering ends your turn's actions. if (content?.kind === "slime" && p.alive && !misted) { events.push({ type: "stuckInSlime", player: p.id, at: p.position }); state.turn.actionsEnded = true; } // 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; } function doPunch(prev: GameState, targetId: PlayerId): CommandResult { const pre = attackPreconditions(prev); if (pre) return err(pre); const state = clone(prev); const attacker = activePlayer(state); if (targetId === attacker.id) return err("you cannot attack yourself"); const target = state.players.find((p) => p.id === targetId); if (!target || !target.alive) return err("no such living player"); if (cellKey(target.position) !== cellKey(attacker.position)) { return err("you must be in the same square to punch"); } const bushOrMist = attackBlockedByStatus(state, attacker, target); if (bushOrMist) return err(bushOrMist); state.sustained = state.sustained.filter( (s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id), ); // BLIND: "...engage in combat..." — a blinded brawler flails on a die // roll; the swing connects only on a 1 (same convention as INVISIBLE). if (isBlinded(state, attacker)) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll !== 1) { state.turn.attackUsed = true; return { ok: true, state, events: [{ type: "attackMisdirected", attacker: attacker.id, intended: target.id, rolledDirection: SIDES[roll - 1]!, newTarget: null, }], }; } } if (state.turn.attackUsed) state.turn.secondAttackUsed = true; state.turn.attackUsed = true; state.stack = { attackerId: attacker.id, defenderId: target.id, attackCard: null, numberValue: null, amplifyFactor: 1, extendFactor: 1, powerAttackPoints: 0, params: null, kind: "physical", counters: [], waitingOn: target.id, }; return { ok: true, state, events: [{ type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }], }; } interface CastConsumables { numbers: CardInstance[]; amplifies: CardInstance[]; add: CardInstance | null; extend: CardInstance | null; aroundCorner: CardInstance | null; powerAttack: CardInstance | null; powerAttackPoints: number; magnitude: Magnitude; } /** Validate and gather the number/modifier cards attached to a cast. */ function gatherModifiers( caster: PlayerState, cmd: Extract, ): 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): 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(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, amount: 0, source: inHand.cardId, damageTotal: creature.damage }); } else if (dmg > 0) { damageCreature(state, events, creature, dmg, inHand.cardId); } return { ok: true, state, events }; } if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return err("no such living player"); if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { return err("you must be in the same square"); } const statusBlock = attackBlockedByStatus(state, caster, target); if (statusBlock) return err(statusBlock); const preEvents: GameEvent[] = []; if (effect.requiresLos) { const sighted = mods.aroundCorner ? bentLos(state, caster, caster.position, target.position, 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): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); const state = clone(prev); const owner = activePlayer(state); const via = owner.hand.find((c) => c.instanceId === cmd.instanceId); if (!via) return err("card not in hand"); if (via.cardId !== "interrupt" && via.cardId !== "opportunity-fire") { return err("only Interrupt or Opportunity Fire can spring an ambush"); } const spell = owner.hand.find((c) => c.instanceId === cmd.spellInstanceId); if (!spell) return err("the attack to commit is not in your hand"); const fx = CARD_EFFECTS[spell.cardId]; if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush"); if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush"); const numbers: CardInstance[] = []; for (const id of cmd.numberInstanceIds ?? []) { const c = owner.hand.find((x) => x.instanceId === id); if (!c || !isNumberCard(c.cardId)) return err("number card not in hand"); numbers.push(c); } if (numbers.length > 1) return err("one number card per action"); if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) { return err("choose a trigger: line of sight, close approach, or treasure"); } // Commit the cards out of the hand; they return if the ambush is cancelled. takeFromHand(owner, via.instanceId); takeFromHand(owner, spell.instanceId); for (const n of numbers) takeFromHand(owner, n.instanceId); const ambush: AmbushState = { id: `ambush-${state.nextAmbushId++}`, ownerId: owner.id, via, trigger: cmd.trigger, spell, numbers, }; state.ambushes.push(ambush); return { ok: true, state, events: [{ type: "ambushSet", visibleTo: owner.id, ambushId: ambush.id, via: via.cardId, spell: spell.cardId, trigger: cmd.trigger, }], }; } function doCancelAmbush(prev: GameState, ambushId: string): CommandResult { const state = clone(prev); const owner = activePlayer(state); const idx = state.ambushes.findIndex((a) => a.id === ambushId && a.ownerId === owner.id); if (idx === -1) return err("no such ambush of yours"); const [ambush] = state.ambushes.splice(idx, 1); const p = state.players.find((q) => q.id === owner.id)!; p.hand.push(ambush!.via, ambush!.spell, ...ambush!.numbers); if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id; return { ok: true, state, events: [{ type: "ambushCancelled", visibleTo: owner.id, ambushId }], }; } /** * After an actor moves (or grabs a treasure), armed ambushes may spring: the * committed attack fires at the triggering wizard through the normal * counteraction stack. Fires at most one ambush per check. */ function checkAmbushes( state: GameState, events: GameEvent[], actor: PlayerState, context: { movedFrom?: Cell; pickedUpTreasure?: boolean }, ): void { if (state.stack || state.phase !== "playing") return; if (state.turn.round === 1) return; // no combat during the first round for (const ambush of [...state.ambushes]) { if (ambush.ownerId === actor.id) continue; const owner = state.players.find((p) => p.id === ambush.ownerId); if (!owner || !owner.alive || !actor.alive) continue; if (attackBlockedByStatus(state, owner, actor)) continue; let sprung = false; if (ambush.trigger.kind === "treasure") { sprung = context.pickedUpTreasure === true; } else if (context.movedFrom) { if (ambush.trigger.kind === "los") { const before = gameLos(state, owner.position, context.movedFrom); const now = gameLos(state, owner.position, actor.position); sprung = now && !before; } else if (ambush.trigger.kind === "near") { const dist = (c: Cell) => Math.abs(owner.position.x - c.x) + Math.abs(owner.position.y - c.y); sprung = dist(actor.position) <= 1 && dist(context.movedFrom) > 1; } } if (!sprung) continue; // The committed spell must be legal right now, or the ambush stays armed. const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue; state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); const numberTotal = ambush.numbers.length > 0 ? ambush.numbers.reduce((t, c) => t + (cardDef(c.cardId).value ?? 0), 0) : null; events.push({ type: "ambushSprung", owner: owner.id, victim: actor.id, via: ambush.via.cardId, spellCardId: ambush.spell.cardId, trigger: ambush.trigger, }); state.stack = { attackerId: owner.id, defenderId: actor.id, attackCard: ambush.spell, numberValue: numberTotal, amplifyFactor: 1, extendFactor: 1, powerAttackPoints: 0, params: null, kind: fx.physical ? "physical" : "spell", counters: [], waitingOn: actor.id, }; events.push({ type: "spellCast", caster: owner.id, card: ambush.spell, cardId: ambush.spell.cardId, numberCards: ambush.numbers, numberValue: numberTotal, from: owner.position, target: actor.id, targetCell: actor.position, }); return; // one ambush per check; others may spring on later steps } } function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): 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); 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 }; } // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." if (card.cardId === "wall-of-fire") { if (stack.attackCard?.cardId !== "waterbolt") { return err("as a counteraction, Wall of Fire only stops a Waterbolt"); } takeFromHand(player, instanceId); state.discard.push(card); stack.counters.push({ player: playerId, card, nullified: false }); stack.waitingOn = stack.attackerId; return { ok: true, state, events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: "waterbolt" }], }; } // SHIELDSTONE: "use a NUMBER card as a counteraction against point- or // duration-based spells, reducing effects by [its] value." if (isNumberCard(card.cardId)) { if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards"); if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks"); takeFromHand(player, instanceId); state.discard.push(card); stack.counters.push({ player: playerId, card, nullified: false }); stack.waitingOn = stack.attackerId; return { ok: true, state, events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }], }; } const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction"; if (!isCounter || !(card.cardId in CARD_EFFECTS) || CARD_EFFECTS[card.cardId]!.kind !== "counter") { return err(`${def.name} cannot counteract (or is not implemented yet)`); } takeFromHand(player, instanceId); state.discard.push(card); stack.counters.push({ player: playerId, card, nullified: false }); stack.waitingOn = stack.attackerId; return { ok: true, state, events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }], }; } if (playerId === stack.attackerId) { if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction"); const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified); if (!targetCounter) return err("no counteraction to nullify"); takeFromHand(player, instanceId); state.discard.push(card); targetCounter.nullified = true; stack.waitingOn = stack.defenderId; return { ok: true, state, events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }], }; } return err("you are not part of this exchange"); } function doPass(prev: GameState, playerId: PlayerId): CommandResult { const state = clone(prev); const stack = state.stack!; if (playerId === stack.attackerId) { stack.waitingOn = stack.defenderId; return { ok: true, state, events: [] }; } const events: GameEvent[] = []; resolveStack(state, events); checkVictory(state, events); return { ok: true, state, events }; } function resolveStack(state: GameState, events: GameEvent[]): void { const stack = state.stack!; state.stack = null; const attacker = state.players.find((p) => p.id === stack.attackerId)!; const defender = state.players.find((p) => p.id === stack.defenderId)!; const attackId = stack.attackCard?.cardId ?? null; const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null; // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and // SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." if (sustainedOn(state, defender.id, "invisible").length > 0) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll !== 1) { events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" }); events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); return; } } if (sustainedOn(state, defender.id, "shrink").length > 0) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll > 2) { events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" }); events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); return; } } let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1 // 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; const pipe: DamagePipeline = { damage: base, duration: baseDuration, reflectedDamage: 0, splitDuration: false, redirected: false, fullyStopped: false, reversed: false, kind: stack.kind, }; for (const counter of stack.counters) { if (counter.nullified) continue; if (isNumberCard(counter.card.cardId)) { // SHIELDSTONE number counter: reduce point AND duration effects. const v = numberValue(counter.card.cardId); pipe.damage = Math.max(0, pipe.damage - v); pipe.duration = Math.max(0, pipe.duration - v); continue; } if (counter.card.cardId === "wall-of-fire") { // The wave meets the fire: the waterbolt is entirely stopped. pipe.damage = 0; pipe.fullyStopped = true; continue; } const ce = CARD_EFFECTS[counter.card.cardId]; if (ce && ce.kind === "counter") ce.apply(pipe); } let damageDealt = 0; if (pipe.redirected) { if (pipe.damage > 0) { 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) { applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id, pipe.kind); damageDealt = pipe.damage; } if (pipe.reflectedDamage > 0) { 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 || (damageDealt === 0 && !pipe.redirected && !effect?.sustains), redirected: pipe.redirected, }); if (effect?.onResolved && !pipe.redirected) { effect.onResolved({ state, events, attacker, defender, damageDealt, fullyStopped: pipe.fullyStopped, duration: pipe.duration, stack, }); } } function knockBack( state: GameState, events: GameEvent[], attacker: PlayerState, defender: PlayerState, squares: number, ): void { const dx = defender.position.x - attacker.position.x; const dy = defender.position.y - attacker.position.y; let dir: Side | null = null; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; else if (dy !== 0) dir = dy > 0 ? "S" : "N"; if (!dir) { // Same square (e.g. GO AWAY point blank): random direction, per the die's // "random direction" use. const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; dir = SIDES[roll - 1]!; } const from = defender.position; if (isLockedInPlace(state, defender.id)) return; let moved = 0; const view = boardView(state); for (let i = 0; i < squares; i++) { const step = stepTarget(view, defender.position, dir); if (step.kind === "blocked") break; const content = state.squareContents[cellKey(step.to)]; if (content?.kind === "stone") break; defender.position = step.to; moved++; if (content?.kind === "thornbush") break; // tangled in the thorns } if (moved > 0) { events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved }); } } function applyDamage( state: GameState, events: GameEvent[], target: PlayerState, amount: number, source: string, attackerId: PlayerId | null, damageKind: "spell" | "physical" = "spell", ): void { // MEDUSA: "opponent is also immune to any damage." if (sustainedOn(state, target.id, "medusa").length > 0) { events.push({ type: "damageImmune", player: target.id, source, because: "medusa" }); return; } // BLOODSTONE: "Lowers all damage done you by one point per attack." if (displays(target, "bloodstone")) { amount = Math.max(0, amount - 1); if (amount === 0) return; } // SOULSTONE: "Last three points ... can only be lost to physical damage." if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) { amount = Math.min(amount, target.life - 3); } target.life -= amount; events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life }); if (target.life > 0) return; target.alive = false; target.finalHand = [...target.hand]; events.push({ type: "died", player: target.id, killedBy: attackerId }); events.push({ type: "playerEliminated", player: target.id, reason: "killed" }); state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id); // "If you die, any monster controlled by you immediately disappears." state.creatures = state.creatures.filter((c) => c.controllerId !== target.id); for (const a of state.ambushes.filter((a) => a.ownerId === target.id)) { state.discard.push(a.via, a.spell, ...a.numbers); } state.ambushes = state.ambushes.filter((a) => a.ownerId !== target.id); if (target.carriedTreasureId) { const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!; t.carriedBy = null; t.position = target.position; target.carriedTreasureId = null; events.push({ type: "treasureDropped", player: target.id, treasureId: t.id, at: target.position, onHomeOf: homeOwnerAt(state, target.position), }); } const killer = attackerId ? state.players.find((p) => p.id === attackerId) : undefined; if (killer && killer.alive && target.hand.length > 0) { const taken = target.hand.splice(0); target.displayed = []; killer.hand.push(...taken); events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length }); events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken }); if (killer.hand.length > handLimit(killer)) state.pendingDiscard = killer.id; } else if (target.hand.length > 0) { state.discard.push(...target.hand.splice(0)); target.displayed = []; } } function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null { const p = state.players.find((p) => cellKey(p.home) === cellKey(cell)); return p ? p.id : null; } // --- Treasures --------------------------------------------------------------- function doPickUpTreasure(prev: GameState): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); const state = clone(prev); const p = activePlayer(state); if (p.carriedTreasureId) return err("you can only carry one treasure at a time"); if (sustainedOn(state, p.id, "weakness").length > 0) return err("you are too weak to carry treasure"); const here = cellKey(p.position); if (state.gluedCells[here]) return err("it is glued fast to the floor"); const safe = state.squareContents[here]?.kind === "safe"; if (safe && state.squareContents[here]!.createdBy !== p.id && !state.openSafes.includes(here)) { return err("it is locked inside a safe"); } const t = state.treasures.find( (t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy, ); if (!t) return err("no treasure here"); t.carriedBy = p.id; t.position = null; p.carriedTreasureId = t.id; state.turn.actionsEnded = true; const events: GameEvent[] = [ { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, ]; // WARD: the treasure's owner may have trapped it. (Simplification: springs // automatically whenever the owner holds the card.) const owner = state.players.find((q) => q.id === t.owner); if (owner && owner.alive && owner.id !== p.id) { 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!); events.push({ type: "wardSprung", owner: owner.id, victim: p.id }); applyDamage(state, events, p, 3, "warded treasure", null); checkVictory(state, events); } } checkAmbushes(state, events, p, { pickedUpTreasure: true }); return { ok: true, state, events }; } function doPickUpObject(prev: GameState, instanceId: string): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); const state = clone(prev); const p = activePlayer(state); const key = cellKey(p.position); if (state.gluedCells[key]) return err("it is glued fast to the floor"); if (state.squareContents[key]?.kind === "safe" && state.squareContents[key]!.createdBy !== p.id && !state.openSafes.includes(key)) { return err("it is locked inside a safe"); } const here = state.groundObjects[key] ?? []; const idx = here.findIndex((c) => c.instanceId === instanceId); if (idx === -1) return err("that object is not here"); const [card] = here.splice(idx, 1); if (here.length === 0) delete state.groundObjects[key]; p.hand.push(card!); // "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT." state.turn.actionsEnded = true; if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id; return { ok: true, state, events: [{ type: "objectPickedUp", player: p.id, card: card!, at: p.position }], }; } /** * Physical, droppable objects beyond the object-type stones. Rulebook: * "Movable objects include magic stones, treasure chests, the dagger, the * large rock, and the wizardblade" — plus the expansion wands, which the * rules expect to change hands ("its remaining charges go with it"; * charges are keyed by instance, so they travel automatically). */ const MOVABLE_OBJECT_CARD_IDS = new Set([ "dagger", "large-rock", "wizardblade", ...WAND_CARD_IDS, ]); export function isMovableObject(cardId: string): boolean { return cardDef(cardId).cardType === "object" || MOVABLE_OBJECT_CARD_IDS.has(cardId); } function doDropObject(prev: GameState, instanceId: string): CommandResult { const state = clone(prev); const p = activePlayer(state); const card = p.hand.find((c) => c.instanceId === instanceId); if (!card) return err("card not in hand"); if (!isMovableObject(card.cardId)) return err("only objects can be dropped"); takeFromHand(p, instanceId); const key = cellKey(p.position); state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card]; return { ok: true, state, events: [{ type: "objectDropped", player: p.id, card, at: p.position, forced: false }], }; } function doDropTreasure(prev: GameState): CommandResult { const state = clone(prev); const p = activePlayer(state); if (!p.carriedTreasureId) return err("you are not carrying a treasure"); const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!; t.carriedBy = null; t.position = p.position; p.carriedTreasureId = null; const events: GameEvent[] = [{ type: "treasureDropped", player: p.id, treasureId: t.id, at: p.position, onHomeOf: homeOwnerAt(state, p.position), }]; checkVictory(state, events); return { ok: true, state, events }; } function checkVictory(state: GameState, events: GameEvent[]): void { if (state.phase !== "playing") return; for (const p of state.players) { if (!p.alive) continue; const mine = state.treasures.filter((t) => t.owner === p.id); const lost = mine.every((t) => { if (!t.position) return false; const owner = homeOwnerAt(state, t.position); const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null; return owner !== null && owner !== p.id && ownerPlayer?.alive === true; }); if (lost) { p.alive = false; p.finalHand = [...p.hand]; state.discard.push(...p.hand.splice(0)); p.displayed = []; events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" }); } } for (const p of state.players) { if (!p.alive) continue; const stolenAtHome = state.treasures.filter( (t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home), ); if (stolenAtHome.length >= 2) { state.phase = "finished"; state.winner = p.id; state.winReason = "treasures"; events.push({ type: "gameWon", player: p.id, reason: "treasures" }); return; } } const alive = state.players.filter((p) => p.alive); if (alive.length === 1) { state.phase = "finished"; state.winner = alive[0]!.id; state.winReason = "lastStanding"; events.push({ type: "gameWon", player: alive[0]!.id, reason: "lastStanding" }); } } // --- Hand management and turn flow ------------------------------------------- function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): CommandResult { const state = clone(prev); const p = state.players.find((p) => p.id === playerId)!; const cards: CardInstance[] = []; for (const id of instanceIds) { const card = takeFromHand(p, id); if (!card) return err(`card not in hand: ${id}`); cards.push(card); } state.discard.push(...cards); if (state.pendingDiscard === playerId && p.hand.length <= handLimit(p)) { state.pendingDiscard = null; } return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] }; } /** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void { const stacks = sustainedOn(state, p.id, "slow-death").length; if (stacks === 0 || cardsDrawn === 0 || !p.alive) return; for (let i = 0; i < cardsDrawn * stacks; i++) { if (!p.alive) break; applyDamage(state, events, p, 1, "slow death", null); } checkVictory(state, events); } function drawOne(state: GameState, events: GameEvent[]): CardInstance | null { if (state.deck.length === 0) { const [reshuffled, rngNext] = shuffle(state.rng, state.discard); state.rng = rngNext; state.deck = reshuffled; state.discard = []; events.push({ type: "deckReshuffled", size: state.deck.length }); if (state.deck.length === 0) return null; } return state.deck.shift()!; } /** Sustained-effect upkeep + turn flags when `player` begins a turn. */ function beginTurnFor(state: GameState, events: GameEvent[], index: number): void { const player = state.players[index]!; // Creatures refresh at each game-turn boundary. Democratic monsters refresh // movement for EVERY player's turn; others for their controller's. for (const c of state.creatures) { c.justCreated = false; c.scorchedThisTurn = []; if (c.kind === "democratic-monster") { c.movementUsed = 0; // its single attack refreshes per ROUND: on the first player's turn if (index === state.turn.firstIndex) c.attackUsed = false; } else if (c.controllerId === player.id) { c.movementUsed = 0; c.wallPassUsed = 0; c.attackUsed = false; } } // SHADOW upkeep: 1 life per turn, even during lost turns (handled where // turns are skipped too). const shadows = state.creatures.filter((c) => c.kind === "shadow" && c.controllerId === player.id).length; for (let i = 0; i < shadows; i++) { player.life -= 1; events.push({ type: "shadowUpkeep", player: player.id, lifeAfter: player.life }); if (player.life <= 0) { applyDamage(state, events, player, 0, "shadow upkeep", null); // triggers death path at <=0 } } // Duration spells expire at the start of their CASTER's turns. const surviving: SustainedEffect[] = []; for (const s of state.sustained) { if (s.casterId === player.id) { s.remainingTurns--; if (s.remainingTurns <= 0) { events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId }); // Edge-bound spells clean up their edge (WALL OF FIRE burns out). if (s.edge && state.edgeOverrides[s.edge] === "firewall") { delete state.edgeOverrides[s.edge]; delete state.createdEdges[s.edge]; events.push({ type: "firewallExpired", edge: s.edge }); } // GLUE dries out: the cell key rides in the same field. if (s.cardId === "glue" && s.edge) { delete state.gluedCells[s.edge]; } continue; } } surviving.push(s); } state.sustained = surviving; // KILLER OOZE / PIT: struggle rolls before you may move this turn. let struggleImmobilized = false; if (player.fallenInOoze) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; const stood = roll <= 2; events.push({ type: "struggledInOoze", player: player.id, stood }); if (stood) player.fallenInOoze = false; else struggleImmobilized = true; } if (player.inPit) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; const out = roll <= 2; events.push({ type: "climbedFromPit", player: player.id, success: out }); if (out) player.inPit = false; else struggleImmobilized = true; } // Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK // forces 2; SPEEDSTONE adds 1 otherwise. let allowance = BASE_MOVEMENT; if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2); if (displays(player, "speedstone")) allowance += 1; // STICKY WAND webs: "reducing movement by 3 (enemy can still use NUMBER // cards for additional movement)." allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length); const slows = sustainedOn(state, player.id, "slow"); if (slows.length > 0) allowance = 1; if (struggleImmobilized) allowance = 0; // SLOW: "his attacks [reduce] to every other turn, starting on his next // turn" — forbidden on the 1st, 3rd, ... slowed turns. let attackForbidden = false; for (const s of slows) { s.data.turnCount = (s.data.turnCount ?? 0) + 1; if (s.data.turnCount % 2 === 1) attackForbidden = true; } state.turn = { round: state.turn.round, firstIndex: state.turn.firstIndex, activeIndex: index, movementAllowance: allowance, movementUsed: 0, numberPlayedForMovement: false, attackUsed: false, secondAttackUsed: false, wandsUsed: [], movementAddUsed: false, attackForbidden, actionsEnded: false, }; } function doEndTurn(prev: GameState, draw: number): CommandResult { if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`); const state = clone(prev); const p = activePlayer(state); const events: GameEvent[] = []; const room = handLimit(p) - p.hand.length; const count = Math.min(draw, room); if (count > 0) { const drawn: CardInstance[] = []; let toDraw = count; while (toDraw > 0) { const card = drawOne(state, events); if (!card) break; if (isTrap(card.cardId)) { state.discard.push(card); p.lostTurns++; events.push({ type: "trapSprung", player: p.id }); continue; } if (card.cardId === "gift-from-below") { // "You lose 3 points to magical damage, now ... then discard and redraw." state.discard.push(card); events.push({ type: "trapSprung", player: p.id }); applyDamage(state, events, p, 3, "gift from below", null); checkVictory(state, events); if (!p.alive) break; continue; } drawn.push(card); toDraw--; } p.hand.push(...drawn); events.push({ type: "cardsDrawn", player: p.id, count: drawn.length }); events.push({ type: "cardsDrawnPrivate", visibleTo: p.id, cards: drawn }); applySlowDeathOnDraw(state, events, p, drawn.length); } // TROLL: "at the end of each turn of its creator, it gets back one point." for (const c of state.creatures.filter((c) => c.kind === "troll" && c.controllerId === p.id)) { if (c.damage > 0) { c.damage -= 1; events.push({ type: "trollRegenerated", creatureId: c.id }); } } // WARP WAND: opened walls reappear at the end of the turn. if (state.tempWarpEdges.length > 0) { for (const t of state.tempWarpEdges) { if (t.prior === null) delete state.edgeOverrides[t.key]; else state.edgeOverrides[t.key] = t.prior; } events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length }); state.tempWarpEdges = []; } // Doors unlocked this turn relock ("the door will relock behind you"). if (state.openDoorEdges.length > 0) { events.push({ type: "doorsRelocked", count: state.openDoorEdges.length }); state.openDoorEdges = []; } state.openSafes = []; events.push({ type: "turnEnded", player: p.id }); if (p.extraTurns > 0) { p.extraTurns--; beginTurnFor(state, events, state.turn.activeIndex); events.push({ type: "extraTurnStarted", player: p.id }); events.push({ type: "turnStarted", player: p.id, round: state.turn.round }); return { ok: true, state, events }; } const n = state.players.length; let next = state.turn.activeIndex; for (;;) { next = (next + 1) % n; if (next === state.turn.firstIndex) state.turn.round++; const candidate = state.players[next]!; if (!candidate.alive) continue; if (candidate.lostTurns > 0) { candidate.lostTurns--; events.push({ type: "turnSkipped", player: candidate.id, reason: "lostTurn" }); continue; } break; } beginTurnFor(state, events, next); events.push({ type: "turnStarted", player: state.players[next]!.id, round: state.turn.round }); // FIRE IMP: "...or if in L.O.S. at the start of a player's turn." impCheck(state, events, state.players[next]!.id); return { ok: true, state, events }; }