From 41c384274dfe10a7c84acaf90a46494400db731e Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 12:48:49 -0400 Subject: [PATCH] Walls and doors fall to sustained assault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn." Damage accumulates per edge in wallDamage (remapped through sector rotations, public in the view), fed two ways: a punchWall command for the bare-fisted (1 point, from a square touching the edge) and attack spells cast at an edge target — LOS to the wall for L.O.S. cards, touching it for same-square cards, amplify and power-attack honored, wand charges spent, no counteractions since stonework plays none. Thrown daggers and rocks clatter to the floor at the foot of the wall. At the threshold the edge opens through the same override path destroy-wall uses. On the table: damaged walls wear spreading cracks, an attack card's hint offers "or a wall line to batter it" with the edge layer live, and a "Punch a wall…" stamp arms a click-the-wall mode. The chronicle counts the blows: "alice batters the wall with bare fists — 3/20." Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 111 +++++++++++++++++++++++++++ packages/engine/src/view.ts | 3 + packages/engine/test/casting.test.ts | 61 ++++++++++++++- packages/web/src/App.svelte | 23 +++++- packages/web/src/Board.svelte | 22 ++++++ packages/web/src/net.svelte.ts | 4 + 6 files changed, 220 insertions(+), 4 deletions(-) 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} +