diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a3fa62d..5312f1c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -215,6 +215,8 @@ export interface GameState { board: AssembledBoard; /** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */ edgeOverrides: Record; + /** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */ + wallDamage: Record; /** Permanent door-lock changes, by edge key. */ doorStates: Record; /** Door edges unlocked until the end of the current turn. */ @@ -532,6 +534,7 @@ export type GameEvent = | { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string } | { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } + | { type: "wallDamaged"; player: PlayerId; edge: { cell: Cell; side: Side }; amount: number; total: number; needed: number; source: string } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorsRelocked"; count: number } | { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } } @@ -571,6 +574,7 @@ export type Command = | { type: "move"; direction: Side } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "punch"; targetId: PlayerId } + | { type: "punchWall"; cell: Cell; side: Side } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } | { type: "creatureAttack"; creatureId: string; targetId: string } @@ -2654,6 +2658,7 @@ function remapState( }; state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey); + state.wallDamage = remapRecord(state.wallDamage, mapEdgeKey); state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey); state.doorStates = remapRecord(state.doorStates, mapEdgeKey); state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); @@ -2947,6 +2952,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game phase: "playing", board, edgeOverrides: {}, + wallDamage: {}, doorStates: {}, openDoorEdges: [], createdEdges: {}, @@ -3099,6 +3105,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm case "move": return doMove(state, command.direction); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "punch": return doPunch(state, command.targetId); + case "punchWall": return doPunchWall(state, command.cell, command.side); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); @@ -3519,6 +3526,60 @@ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: return null; } +/** + * "A wall takes 20 points of damage to destroy; a door takes 15." Damage + * accumulates across turns and players; at the threshold the edge opens. + */ +function damageWall( + state: GameState, + events: GameEvent[], + attacker: PlayerState, + cell: Cell, + side: Side, + amount: number, + source: string, +): string | null { + const view = boardView(state); + const key = edgeKey(cell, side); + const current = view.edges[key] ?? "open"; + if (current !== "wall" && current !== "door") { + return "only walls, doors, and thornbushes can be attacked"; + } + const needed = current === "door" ? 15 : 20; + const total = (state.wallDamage[key] ?? 0) + amount; + events.push({ type: "wallDamaged", player: attacker.id, edge: { cell, side }, amount, total, needed, source }); + if (total >= needed) { + delete state.wallDamage[key]; + state.edgeOverrides[key] = "open"; + delete state.doorStates[key]; + delete state.createdEdges[key]; + events.push({ type: "wallDestroyed", caster: attacker.id, edge: { cell, side }, wasDoor: current === "door" }); + } else { + state.wallDamage[key] = total; + } + return null; +} + +/** The edge must border the wizard's own square. */ +function touchesEdge(position: Cell, cell: Cell, side: Side): boolean { + return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side)); +} + +function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { + const pre = attackPreconditions(prev); + if (pre) return err(pre); + const state = clone(prev); + const attacker = activePlayer(state); + if (!touchesEdge(attacker.position, cell, side)) { + return err("you must stand beside the wall to punch it"); + } + const events: GameEvent[] = []; + const problem = damageWall(state, events, attacker, cell, side, 1, "punch"); + if (problem) return err(problem); + state.turn.attackUsed = true; + return { ok: true, state, events }; +} + function doPunch(prev: GameState, targetId: PlayerId): CommandResult { const pre = attackPreconditions(prev); if (pre) return err(pre); @@ -3813,6 +3874,56 @@ function doCast(prev: GameState, cmd: Extract): Comma } return { ok: true, state, events }; } + // "Any attack against an inanimate object counts as your one attack for + // the turn." A wall or door soaks the spell's damage; no counteractions. + if (cmd.target?.kind === "edge") { + const { cell, side } = cmd.target; + const view = boardView(state); + const current = view.edges[edgeKey(cell, side)] ?? "open"; + if (current !== "wall" && current !== "door") { + return err("only walls, doors, and thornbushes can be attacked"); + } + if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { + return err("you must stand beside the wall"); + } + if (effect.requiresLos && !losToEdge(view, caster.position, cell, side)) { + return err("no line of sight to the wall"); + } + const dmg = + effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length) + + mods.powerAttackPoints; + if (dmg <= 0) return err("that spell cannot harm stonework"); + const wandEvents: GameEvent[] = []; + { + const werr = spendWandCharge(state, caster, wandEvents); + if (werr) return err(werr); + } + if (mods.powerAttackPoints > 0) { + caster.life -= mods.powerAttackPoints; + wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + } + consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); + if (state.turn.attackUsed) state.turn.secondAttackUsed = true; + state.turn.attackUsed = true; + state.lastSpellUsed[caster.id] = inHand.cardId; + const events2: GameEvent[] = [...wandEvents, { + type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, + numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, + from: caster.position, target: null, targetCell: cell, + }]; + const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId); + if (problem) return err(problem); + // Thrown weapons clatter to the floor at the foot of the wall. + if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") { + const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId); + if (di !== -1) { + const [card] = state.discard.splice(di, 1); + state.groundObjects[cellKey(cell)] = [...(state.groundObjects[cellKey(cell)] ?? []), card!]; + events2.push({ type: "objectThrown", attacker: caster.id, cardId: inHand.cardId, landedAt: cell }); + } + } + return { ok: true, state, events: events2 }; + } if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 5920efa..6833d75 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -55,6 +55,8 @@ export interface GameView { squareContents: Record; groundObjects: Record; doorStates: Record; + /** Accumulated attack damage per edge (public — cracks show). */ + wallDamage: Record; openDoorEdges: string[]; /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ knownIllusionEdges: string[]; @@ -123,6 +125,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), ), doorStates: { ...state.doorStates }, + wallDamage: { ...state.wallDamage }, openDoorEdges: [...state.openDoorEdges], knownIllusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 6da3625..d8ce857 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; -import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board"; +import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; @@ -328,3 +328,62 @@ describe("deck revisions", () => { expect(inGame(legacy.state)).toBe(true); }); }); + +describe("attacking walls and doors", () => { + function wallBeside(state: GameState): { cell: Cell; side: Side } { + const me = activePlayer(state); + const view = boardView(state); + for (const side of SIDES) { + if (view.edges[edgeKey(me.position, side)] === "wall") return { cell: me.position, side }; + } + throw new Error("setup: seed 42 lost its adjacent wall"); + } + + it("punches chip a wall for 1 and spend the turn's attack", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state); + const { cell, side } = wallBeside(state); + state = must(state, me.id, { type: "punchWall", cell, side }); + expect(state.wallDamage[edgeKey(cell, side)]).toBe(1); + expect(state.turn.attackUsed).toBe(true); + expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false); + }); + + it("a powered fireball brings a wall down at 20 accumulated damage", () => { + let { state } = newGame(); + state = toRound2(state); + let me = activePlayer(state); + const { cell, side } = wallBeside(state); + const key = edgeKey(cell, side); + // Fireball is a flat 5: four castings accumulate 5, 10, 15, then 20 fells it. + for (let round = 0; round < 4; round++) { + me = activePlayer(state); + const fb = giveCard(state, me.id, "fireball", `F${round}`, 0); + state = must(state, me.id, { + type: "cast", instanceId: fb.instanceId, + target: { kind: "edge", cell, side }, + }); + if (round < 3) { + expect(state.wallDamage[key]).toBe(5 * (round + 1)); + state = must(state, me.id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + } + expect(state.wallDamage[key]).toBeUndefined(); + expect(boardView(state).edges[key]).toBe("open"); + // The way is open: walk through where the wall stood. + state = must(state, me.id, { type: "move", direction: side }); + expect(cellKey(activePlayer(state).position)).toBe(cellKey(neighbor(cell, side))); + }); + + it("open corridors and firewalls are not punchable", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state); + const view = boardView(state); + const openSide = SIDES.find((s) => (view.edges[edgeKey(me.position, s)] ?? "open") === "open")!; + const r = applyCommand(state, me.id, { type: "punchWall", cell: me.position, side: openSide }); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index c6f943e..1fd07d9 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -24,6 +24,8 @@ let peekCard = $state(null); /** When the peeked card is a creature on the board, its live stats ride along. */ let peekCreatureId = $state(null); + /** Bare-knuckle demolition: click a wall to punch it. */ + let punchWallMode = $state(false); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let hotseatCount = $state(2); let setupName = $state(""); @@ -105,7 +107,12 @@ const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); - const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); + const attackVsWall = $derived( + selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId), + ); + const edgeSelectMode = $derived( + (selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode, + ); const cellSelectMode = $derived( (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, ); @@ -117,6 +124,7 @@ } function clearSelection() { + punchWallMode = false; ambushVia = null; ambushTrigger = null; ambushSpell = null; @@ -461,11 +469,17 @@ } function clickEdge(cell: { x: number; y: number }, side: Side) { + if (punchWallMode) { + dispatch({ type: "punchWall", cell, side }); + punchWallMode = false; + return; + } if (!selectedCard || !edgeSelectMode) return; dispatch({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "edge", cell, side }, + ...(attackVsWall && attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), }); clearSelection(); } @@ -1105,8 +1119,8 @@ {#if selectedDef} {selectedDef.name} {#if edgeSelectMode}— click a wall line{/if} - {#if selectedDef.cardType === "attack" && !edgeSelectMode && !cellSelectMode} - — click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"} + {#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")} + — click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""} {/if} {#if attachedMods.length > 0} [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}] @@ -1183,6 +1197,9 @@ onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> Pick up {cardDef(obj.cardId).name} {/each} +