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; }