Walls and doors fall to sustained assault
"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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
64454ffc3e
commit
41c384274d
@@ -215,6 +215,8 @@ export interface GameState {
|
||||
board: AssembledBoard;
|
||||
/** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */
|
||||
edgeOverrides: Record<string, EdgeState>;
|
||||
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
|
||||
wallDamage: Record<string, number>;
|
||||
/** Permanent door-lock changes, by edge key. */
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** 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<Command, { type: "cast" }>): 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);
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface GameView {
|
||||
squareContents: Record<string, SquareContent>;
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Accumulated attack damage per edge (public — cracks show). */
|
||||
wallDamage: Record<string, number>;
|
||||
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] })),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user