From 3a35922a40e8c010e40e982cb183aaa4820f68f0 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 22:52:34 -0400 Subject: [PATCH] Expansion wave 3: twelve terrain cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KILLER OOZE (1 damage on entry, slip on 1-2: drop treasure, 2 more, flat on your face until you roll to stand), CREATE PIT (jumps roll a D4 — clear it on 2-4, fall in on a 1 and climb out on later rolls), ROSEBUSH (3-point passage, blocks sight), DUST CLOUD (a permanent zone of blindness: direction rolls inside, no LOS through, defeats Visionstone), FILL SQUARE WITH SLIME (entering ends your actions, blocks sight), HANDFUL OF TACKS (adjacent-only scatter, 3 points to cross), CREATE DOOR (a new locked door in any wall or corridor), BOOBYTRAP (four face-down tokens, one secretly real — 4 points under anyone but the caster; the caster's view alone marks the true token), GLUE (objects pinned for twice the number card), SAFE (items locked away from everyone but the creator), TRADER (swap two floor items in sight; glue and safes hold fast), and STONE TO WATER (walls melt into range-2 waves, stone blocks into range-4 bursts). LOS blocking is now per-terrain-kind. The board renders all of it, boobytrap placement is a four-click ritual, and Trader is two. 108 tests passing. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 429 ++++++++++++++++++++++++-- packages/engine/src/view.ts | 10 + packages/engine/test/terrain2.test.ts | 206 +++++++++++++ packages/web/src/App.svelte | 33 +- packages/web/src/Board.svelte | 33 +- 5 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 packages/engine/test/terrain2.test.ts diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ba3e7b0..6b1086d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -66,6 +66,10 @@ export interface PlayerState { 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. */ @@ -103,14 +107,20 @@ export interface CreatureState { scorchedThisTurn: PlayerId[]; } -/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */ +/** Something occupying a whole square (stone, bushes, ooze, pits, ...). */ export interface SquareContent { - kind: "stone" | "thornbush"; - /** Damage taken so far; thornbushes die at 5. Stone is indestructible. */ + 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; } +/** Which square contents block line of sight. */ +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; @@ -148,6 +158,8 @@ 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; @@ -185,6 +197,12 @@ export interface GameState { 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[]; players: PlayerState[]; treasures: TreasureState[]; sustained: SustainedEffect[]; @@ -212,7 +230,9 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin /** LOS including square-filling blockers (stone, thornbushes). */ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { const blockers: Record = {}; - for (const key of Object.keys(state.squareContents)) blockers[key] = true; + for (const [key, content] of Object.entries(state.squareContents)) { + if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; + } return hasLineOfSight(boardView(state), from, to, blockers); } @@ -293,7 +313,9 @@ function casterLos( ): boolean { const board = perceivedBoard(state, events, caster.id, { from, to }); const blockers: Record = {}; - for (const key of Object.keys(state.squareContents)) blockers[key] = true; + for (const [key, content] of Object.entries(state.squareContents)) { + if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; + } if (hasLineOfSight(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; for (const key of Object.keys(board.edges)) { @@ -331,6 +353,12 @@ 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)); @@ -382,7 +410,7 @@ export type GameEvent = | { 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: "stone" | "thornbush" } + | { 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 } @@ -416,6 +444,21 @@ export type GameEvent = | { 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: "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 } @@ -1438,6 +1481,171 @@ const CARD_EFFECTS: Record 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] = [...itemsB.filter(() => false), ...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"; + }, + }, "reuse-spell": { kind: "neutral", // "You may retrieve any spell you use immediately after you use it (but @@ -1461,6 +1669,61 @@ const CARD_EFFECTS: Record }, }; +/** 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): 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, "rushing water"); + } + } + 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 { @@ -1531,26 +1794,30 @@ function emptySquareTarget( return cell; } -/** WATERWALL: push a player 2 spaces along dir; 1 damage per blocked space. */ -function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void { +/** 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 < 2; i++) { + 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 = 2 - 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"); } } +function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void { + washBackN(state, events, p, dir, 2); +} + // --------------------------------------------------------------------------- // Creatures @@ -2033,6 +2300,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game lostTurns: 0, extraTurns: 0, passWallCharges: 0, + fallenInOoze: false, + inPit: false, })); const treasures: TreasureState[] = players.flatMap((p, i) => @@ -2095,6 +2364,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game nextCreatureId: 1, wandCharges: {}, tempWarpEdges: [], + boobytraps: [], + gluedCells: {}, + openSafes: [], players, treasures, sustained: [], @@ -2210,9 +2482,9 @@ function doMove(prev: GameState, direction: Side): CommandResult { const p = activePlayer(state); const events: GameEvent[] = []; - // BLIND: "must roll direction on D4 if attempting to move ... bumping into - // a wall counts as one space of movement. Reroll for each movement point." - if (sustainedOn(state, p.id, "blind").length > 0) { + // 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]!; @@ -2223,7 +2495,7 @@ function doMove(prev: GameState, direction: Side): CommandResult { const key = edgeKey(p.position, direction); if (state.illusionWalls[key] && illusionBelief(state, events, p.id, key) === "believes") { - if (sustainedOn(state, p.id, "blind").length > 0) { + if (isBlinded(state, p)) { state.turn.movementUsed++; events.push({ type: "moveBumped", player: p.id, direction }); return { ok: true, state, events }; @@ -2244,7 +2516,7 @@ function doMove(prev: GameState, direction: Side): CommandResult { const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); if (!view.cells[cellKey(dest)]) { - if (sustainedOn(state, p.id, "blind").length > 0) { + if (isBlinded(state, p)) { state.turn.movementUsed++; events.push({ type: "moveBumped", player: p.id, direction }); return { ok: true, state, events }; @@ -2269,7 +2541,7 @@ function doMove(prev: GameState, direction: Side): CommandResult { p.passWallCharges--; p.position = dest; via = "passWall"; - } else if (sustainedOn(state, p.id, "blind").length > 0) { + } else if (isBlinded(state, p)) { // Blind bump: the wasted lurch costs a movement point. state.turn.movementUsed++; events.push({ type: "moveBumped", player: p.id, direction }); @@ -2283,9 +2555,43 @@ function doMove(prev: GameState, direction: Side): CommandResult { } // Square contents at the destination. - const content = state.squareContents[cellKey(p.position)]; + let content = state.squareContents[cellKey(p.position)]; if (content?.kind === "stone") return err("that square is solid stone"); + // 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 }); @@ -2308,6 +2614,55 @@ function doMove(prev: GameState, direction: Side): CommandResult { 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; + } + // 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 }; } @@ -2402,7 +2757,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult { // BLIND: "...engage in combat..." — a blinded brawler flails on a die // roll; the swing connects only on a 1 (same convention as INVISIBLE). - if (sustainedOn(state, attacker.id, "blind").length > 0) { + if (isBlinded(state, attacker)) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll !== 1) { @@ -2702,7 +3057,7 @@ function doCast(prev: GameState, cmd: Extract): Comma // go intended distance" — if the die disagrees with the true direction, // the spell hits whoever lies that way, or dissipates. let actualTarget = target; - if (sustainedOn(state, caster.id, "blind").length > 0 && + 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; @@ -3186,6 +3541,12 @@ function doPickUpTreasure(prev: GameState): CommandResult { const state = clone(prev); const p = activePlayer(state); if (p.carriedTreasureId) return err("you can only carry one treasure at a time"); + 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, ); @@ -3209,6 +3570,11 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult { 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"); @@ -3386,6 +3752,10 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi 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; } } @@ -3393,6 +3763,25 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi } 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; @@ -3403,6 +3792,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi 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. @@ -3480,6 +3870,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { events.push({ type: "doorsRelocked", count: state.openDoorEdges.length }); state.openDoorEdges = []; } + state.openSafes = []; events.push({ type: "turnEnded", player: p.id }); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 9f1d525..9526113 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -56,6 +56,8 @@ export interface GameView { creatures: CreatureState[]; /** Charges left on displayed wands (public), by card instance id. */ wandCharges: Record; + /** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */ + boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[]; } export function viewFor(state: GameState, playerId: PlayerId): GameView { @@ -109,5 +111,13 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { knownIllusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), wandCharges: { ...state.wandCharges }, + boobytraps: state.boobytraps.map((t) => { + const [rx, ry] = t.realKey.split(",").map(Number) as [number, number]; + return { + casterId: t.casterId, + cells: t.cells.map((c) => ({ ...c })), + realCell: t.casterId === playerId ? { x: rx, y: ry } : null, + }; + }), }; } diff --git a/packages/engine/test/terrain2.test.ts b/packages/engine/test/terrain2.test.ts new file mode 100644 index 0000000..f029ce5 --- /dev/null +++ b/packages/engine/test/terrain2.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + boardView, + gameLos, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; +import type { CardInstance } from "../src/cards"; + +function newGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] }); +} + +function must(state: GameState, player: PlayerId, command: Command): GameState { + const result = applyCommand(state, player, command); + if (!result.ok) throw new Error(`command failed: ${result.error}`); + return result.state; +} + +function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { + const p = state.players.find((p) => p.id === playerId)!; + const instance = { instanceId: `${cardId}#${tag}`, cardId }; + p.hand[slot] = instance; + return instance; +} + +function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } { + const view = boardView(state); + for (const side of SIDES) { + const t = stepTarget(view, of, side); + if (t.kind !== "step") continue; + const key = cellKey(t.to); + if (view.homes.some((h) => cellKey(h) === key)) continue; + if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue; + if (state.players.some((p) => cellKey(p.position) === key)) continue; + return { cell: t.to, side }; + } + throw new Error("no empty neighbor"); +} + +describe("expansion terrain", () => { + it("killer ooze burns on entry and can drop you on your face", () => { + // Across seeds we should see both slips and clean crossings. + let slips = 0, crossings = 0; + for (let seed = 1; seed <= 10; seed++) { + let { state } = newGame(seed); + const me = activePlayer(state); + const spot = emptyNeighborCell(state, me.position); + const oz = giveCard(state, me.id, "killer-ooze"); + state = must(state, me.id, { + type: "cast", instanceId: oz.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + state = must(state, me.id, { type: "move", direction: spot.side }); + const p = state.players.find((p) => p.id === me.id)!; + if (p.fallenInOoze) { slips++; expect(p.life).toBe(12); } // 1 + 2 + else { crossings++; expect(p.life).toBe(14); } + } + expect(slips + crossings).toBe(10); + expect(slips).toBeGreaterThan(0); + expect(crossings).toBeGreaterThan(0); + }); + + it("a pit is jumped on 2-4 and fallen into on a 1", () => { + let falls = 0, jumps = 0, teeters = 0; + for (let seed = 1; seed <= 12; seed++) { + let { state } = newGame(seed); + const me = activePlayer(state); + const spot = emptyNeighborCell(state, me.position); + const pit = giveCard(state, me.id, "create-pit"); + state = must(state, me.id, { + type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + state = must(state, me.id, { type: "move", direction: spot.side }); + const p = state.players.find((p) => p.id === me.id)!; + if (p.inPit) { falls++; expect(p.life).toBe(13); } + else if (cellKey(p.position) === cellKey(me.position)) teeters++; + else jumps++; + } + expect(falls + jumps + teeters).toBe(12); + expect(falls).toBeGreaterThan(0); + }); + + it("dust cloud blinds anyone standing inside it", () => { + let { state } = newGame(); + const me = activePlayer(state); + const spot = emptyNeighborCell(state, me.position); + const dc = giveCard(state, me.id, "dust-cloud"); + state = must(state, me.id, { + type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + // LOS through the cloud is blocked. + const view = boardView(state); + const beyond = { x: spot.cell.x + (spot.cell.x - me.position.x), y: spot.cell.y + (spot.cell.y - me.position.y) }; + if (view.cells[cellKey(beyond)]) { + expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false); + } + }); + + it("glue pins a treasure to the floor until it wears off", () => { + let { state } = newGame(); + const me = activePlayer(state); + const treasure = state.treasures.find((t) => t.position && t.owner !== me.id)!; + me.position = { ...treasure.position! }; + const gl = giveCard(state, me.id, "glue"); + giveCard(state, me.id, "number-2", "N", 1); + state = must(state, me.id, { + type: "cast", instanceId: gl.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "cell", cell: treasure.position! }, + }); + expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(false); + // 2 x 2 = 4 of the caster's turns later, the glue dries out. + for (let i = 0; i < 8; i++) { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! }; + expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true); + }); + + it("a safe locks a treasure away from everyone but its creator", () => { + let { state } = newGame(); + const me = activePlayer(state); + const enemy = state.players.find((p) => p.id !== me.id)!; + const treasure = state.treasures.find((t) => t.position && t.owner === enemy.id)!; + me.position = { ...treasure.position! }; + const sf = giveCard(state, me.id, "safe"); + state = must(state, me.id, { + type: "cast", instanceId: sf.instanceId, target: { kind: "cell", cell: treasure.position! }, + }); + // The enemy cannot take it... + state = must(state, me.id, { type: "endTurn", draw: 0 }); + const e = state.players.find((p) => p.id === enemy.id)!; + e.position = { ...treasure.position! }; + expect(applyCommand(state, enemy.id, { type: "pickUpTreasure" }).ok).toBe(false); + // ...but the creator knows the combination. + state = must(state, enemy.id, { type: "endTurn", draw: 0 }); + state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! }; + expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true); + }); + + it("boobytrap detonates only under its real token, never under the caster", () => { + let { state } = newGame(); + const me = activePlayer(state); + const enemy = state.players.find((p) => p.id !== me.id)!; + // Four distinct empty-ish cells: use home-adjacent floor cells of the board. + const view = boardView(state); + const open: Cell[] = []; + for (const key of Object.keys(view.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + const c = { x, y }; + if (state.squareContents[key]) continue; + open.push(c); + if (open.length === 4) break; + } + const bt = giveCard(state, me.id, "boobytrap"); + state = must(state, me.id, { + type: "cast", instanceId: bt.instanceId, params: { cells: open }, + }); + // Caster strolls across the real token unharmed. + const caster = state.players.find((p) => p.id === me.id)!; + caster.position = { ...open[0]! }; + // (position set directly — trap only triggers on a move; simulate enemy) + state = must(state, me.id, { type: "endTurn", draw: 0 }); + const e = state.players.find((p) => p.id === enemy.id)!; + // Stand the enemy adjacent to the real token and step onto it. + for (const side of SIDES) { + const from = { x: open[0]!.x + (side === "E" ? -1 : side === "W" ? 1 : 0), + y: open[0]!.y + (side === "S" ? -1 : side === "N" ? 1 : 0) }; + if (!view.cells[cellKey(from)]) continue; + const t = stepTarget(view, from, side); + if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) { + e.position = from; + const result = applyCommand(state, enemy.id, { type: "move", direction: side }); + if (result.ok) { + state = result.state; + const hurt = state.players.find((p) => p.id === enemy.id)!; + expect(hurt.life).toBeLessThanOrEqual(11); + expect(state.boobytraps.length).toBe(0); + } + return; + } + } + }); + + it("stone to water melts a stone block into a crashing wave", () => { + let { state } = newGame(); + const me = activePlayer(state); + const spot = emptyNeighborCell(state, me.position); + const fs = giveCard(state, me.id, "fill-square-with-stone"); + state = must(state, me.id, { + type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + const stw = giveCard(state, me.id, "stone-to-water", "SW", 1); + state = must(state, me.id, { + type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined(); + // The caster stood beside the block: the wave washed them somewhere (or + // crushed them for blocked spaces) — either way life or position changed + // is acceptable; assert no crash and the block is gone. + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 77b5311..8162a18 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -29,6 +29,10 @@ let attachedMods = $state([]); /** relocate-sector: the sector picked up, awaiting its destination. */ let pendingSectorFrom = $state<{ x: number; y: number } | null>(null); + /** boobytrap: cells picked so far (first is the real one). */ + let trapCells = $state<{ x: number; y: number }[]>([]); + /** trader: first item square picked. */ + let tradeFrom = $state<{ x: number; y: number } | null>(null); /** rotate-sector direction. */ let rotateCW = $state(true); /** Cards marked for discard. */ @@ -43,12 +47,14 @@ const EDGE_CARDS = new Set([ "create-wall", "destroy-wall", "wall-of-fire", "waterwall", "pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation", - "warp-wand", + "warp-wand", "create-door", "stone-to-water", ]); const CELL_CARDS = new Set([ "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag", "rotate-sector", "relocate-sector", "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", + "killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit", + "handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap", ]); const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); @@ -63,6 +69,8 @@ const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1); function clearSelection() { + trapCells = []; + tradeFrom = null; selectedCreature = null; selectedCard = null; attachedNumber = null; @@ -181,6 +189,23 @@ clearSelection(); return; } + if (selectedCard?.cardId === "boobytrap") { + trapCells = [...trapCells, cell]; + if (trapCells.length === 4) { + net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }); + clearSelection(); + } + return; + } + if (selectedCard?.cardId === "trader") { + if (!tradeFrom) { tradeFrom = cell; return; } + net.command({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "cell", cell }, params: { cell: tradeFrom }, + }); + clearSelection(); + return; + } if (selectedCard?.cardId === "rotate-sector") { net.command({ type: "cast", instanceId: selectedCard.instanceId, @@ -457,6 +482,12 @@ — click a square in the sector to rotate {/if} + {#if selectedCard?.cardId === "boobytrap"} + — place 4 tokens ({trapCells.length}/4; the FIRST is the real trap) + {/if} + {#if selectedCard?.cardId === "trader"} + {tradeFrom ? "— now the second item square" : "— click the first item square"} + {/if} {#if selectedCard?.cardId === "relocate-sector"} {#if pendingSectorFrom} — now click the destination area diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 091810e..6fee540 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -111,11 +111,31 @@ {@const sy = Number(key.split(",")[1])} {#if content.kind === "stone"} - {:else} - + {:else if content.kind === "thornbush" || content.kind === "rosebush"} + + {:else if content.kind === "ooze" || content.kind === "slime"} + + {:else if content.kind === "dust"} + + {:else if content.kind === "pit"} + + {:else if content.kind === "tacks"} + ✻✻ + {:else if content.kind === "safe"} + {/if} {/each} + + {#each view.boobytraps as trap, ti (ti)} + {#each trap.cells as tc, i (i)} + + {/each} + {/each} + {#each Object.entries(view.groundObjects) as [key, objects] (key)} {@const gx = Number(key.split(",")[0])} @@ -245,6 +265,15 @@ .firewall { fill: #e0442a; } .stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; } .bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; } + .rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; } + .ooze { fill: #58a12b; opacity: 0.85; } + .slime { fill: #8ec52e; opacity: 0.85; } + .dust { fill: #9b9184; opacity: 0.75; } + .pit { fill: #171512; } + .tacks { font-size: 15px; text-anchor: middle; fill: #444; } + .safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; } + .trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; } + .trap-real { stroke: #d3352b; stroke-width: 2.5; } .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; } .illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; } .creature { cursor: pointer; }