2 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 41c384274d 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>
2026-08-16 12:48:49 -04:00
Eric WagonerandClaude Fable 5 64454ffc3e Sight travels through the wraparound openings
"If casting a spell, or checking line of sight through the AUTO WARP,
treat it as a straight line, and the two connected boards as though
they were adjacent" — and the lettered openings reconnect edges the
same way. hasWarpLineOfSight models it: the line must run straight
down the corridor, out one mouth, and in through the paired mouth,
with both corridor legs clear and neither mouth filled solid. The new
sightBetween (direct or through-a-warp) is now the game's LOS check
everywhere — spells, ambush triggers, Around The Corner, the
visionstone sweep, and the targeting dim on the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:43:57 -04:00
8 changed files with 327 additions and 13 deletions
+63 -3
View File
@@ -18,6 +18,10 @@ export interface SectorPlacement {
rotation: Rotation; rotation: Rotation;
} }
export function opposite(s: Side): Side {
return s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E";
}
export interface Warp { export interface Warp {
/** Leaving this cell through this side of the map... */ /** Leaving this cell through this side of the map... */
from: { cell: Cell; side: Side }; from: { cell: Cell; side: Side };
@@ -198,7 +202,6 @@ export function assembleBoard(
case "E": return toGlobal(origin, 3, SECTOR); case "E": return toGlobal(origin, 3, SECTOR);
} }
}; };
const opposite = (s: Side): Side => (s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E");
if (options.warpPairs) { if (options.warpPairs) {
for (const [a, b] of options.warpPairs) { for (const [a, b] of options.warpPairs) {
@@ -255,8 +258,8 @@ export function stepTarget(
* wall/door/firewall edges the segment crosses and by any `blockedCells` * wall/door/firewall edges the segment crosses and by any `blockedCells`
* (solid stone, thornbushes) it passes through. Grazing a wall endpoint * (solid stone, thornbushes) it passes through. Grazing a wall endpoint
* (passing exactly through a corner adjacent to a wall) counts as blocked — * (passing exactly through a corner adjacent to a wall) counts as blocked —
* strict reading; revisit against FAQ rulings if needed. LOS through * strict reading. Direct sight only; `sightBetween` adds the wraparound
* wraparound openings is not yet modeled (TODO). * openings.
*/ */
export function hasLineOfSight( export function hasLineOfSight(
board: AssembledBoard, board: AssembledBoard,
@@ -329,3 +332,60 @@ function onSegment(ax: number, ay: number, bx: number, by: number, px: number, p
Math.min(ay, by) <= py && py <= Math.max(ay, by) Math.min(ay, by) <= py && py <= Math.max(ay, by)
); );
} }
/**
* Straight-corridor sight through a wraparound opening. Rulebook: "If
* casting a spell, or checking line of sight through the AUTO WARP, treat
* it as a straight line, and the two connected boards as though they were
* adjacent" — and the lettered openings reconnect board edges the same way
* ("you will reenter at point A"). The line must run down the corridor, out
* one mouth, and straight in through the paired mouth: no diagonals.
*/
export function hasWarpLineOfSight(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
for (const w of board.warps) {
const mouthA = w.from.cell;
const out = w.from.side;
const mouthB = w.to.cell;
const inward = opposite(w.to.side);
// The viewer stands in mouth A's corridor, on the interior side.
const fromAligned = out === "N" || out === "S" ? from.x === mouthA.x : from.y === mouthA.y;
if (!fromAligned) continue;
const fromInterior =
out === "N" ? from.y >= mouthA.y : out === "S" ? from.y <= mouthA.y :
out === "E" ? from.x <= mouthA.x : from.x >= mouthA.x;
if (!fromInterior) continue;
// The target sits in mouth B's corridor, from the mouth inward.
const toAligned = inward === "N" || inward === "S" ? to.x === mouthB.x : to.y === mouthB.y;
if (!toAligned) continue;
const toInterior =
inward === "N" ? to.y <= mouthB.y : inward === "S" ? to.y >= mouthB.y :
inward === "E" ? to.x >= mouthB.x : to.x <= mouthB.x;
if (!toInterior) continue;
// A filled mouth blocks the tunnel unless the viewer/target IS the mouth.
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
if (
hasLineOfSight(board, from, mouthA, blockedCells) &&
hasLineOfSight(board, mouthB, to, blockedCells)
) {
return true;
}
}
return false;
}
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
export function sightBetween(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
return hasLineOfSight(board, from, to, blockedCells) ||
hasWarpLineOfSight(board, from, to, blockedCells);
}
+116 -4
View File
@@ -21,6 +21,7 @@ import {
cellKey, cellKey,
edgeKey, edgeKey,
hasLineOfSight, hasLineOfSight,
sightBetween,
neighbor, neighbor,
stepTarget, stepTarget,
} from "./board"; } from "./board";
@@ -214,6 +215,8 @@ export interface GameState {
board: AssembledBoard; board: AssembledBoard;
/** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */ /** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */
edgeOverrides: Record<string, EdgeState>; 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. */ /** Permanent door-lock changes, by edge key. */
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */ /** Door edges unlocked until the end of the current turn. */
@@ -290,7 +293,7 @@ export function losBlockers(state: GameState): Record<string, true> {
/** LOS including square-filling blockers. */ /** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean { export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return hasLineOfSight(boardView(state), from, to, losBlockers(state)); return sightBetween(boardView(state), from, to, losBlockers(state));
} }
/** Parse an edge key back into its north/west cell and side. */ /** Parse an edge key back into its north/west cell and side. */
@@ -368,13 +371,13 @@ function casterLos(
): boolean { ): boolean {
const board = perceivedBoard(state, events, caster.id, { from, to }); const board = perceivedBoard(state, events, caster.id, { from, to });
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (hasLineOfSight(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false; if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) { for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue; if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges }; const edges = { ...board.edges };
delete edges[key]; delete edges[key];
if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true; if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
} }
return false; return false;
} }
@@ -531,6 +534,7 @@ export type GameEvent =
| { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string } | { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string }
| { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger } | { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } | { 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: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number } | { type: "doorsRelocked"; count: number }
| { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } } | { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } }
@@ -570,6 +574,7 @@ export type Command =
| { type: "move"; direction: Side } | { type: "move"; direction: Side }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId } | { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
@@ -2653,6 +2658,7 @@ function remapState(
}; };
state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey); state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey);
state.wallDamage = remapRecord(state.wallDamage, mapEdgeKey);
state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey); state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey);
state.doorStates = remapRecord(state.doorStates, mapEdgeKey); state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
@@ -2752,7 +2758,7 @@ function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean { function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
const n = neighbor(cell, side); const n = neighbor(cell, side);
return hasLineOfSight(board, from, cell) || hasLineOfSight(board, from, n); return sightBetween(board, from, cell) || sightBetween(board, from, n);
} }
function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean { function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean {
@@ -2946,6 +2952,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
phase: "playing", phase: "playing",
board, board,
edgeOverrides: {}, edgeOverrides: {},
wallDamage: {},
doorStates: {}, doorStates: {},
openDoorEdges: [], openDoorEdges: [],
createdEdges: {}, createdEdges: {},
@@ -3098,6 +3105,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "move": return doMove(state, command.direction); case "move": return doMove(state, command.direction);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId); case "punch": return doPunch(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side);
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
@@ -3518,6 +3526,60 @@ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target:
return null; 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 { function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev); const pre = attackPreconditions(prev);
if (pre) return err(pre); if (pre) return err(pre);
@@ -3812,6 +3874,56 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
return { ok: true, state, events }; 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 || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); 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); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
+5 -2
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the // The server sends this after every state change; clients never see the
// deck order or other players' hands. // deck order or other players' hands.
import { hasLineOfSight, type AssembledBoard } from "./board"; import { sightBetween, type AssembledBoard } from "./board";
import { type CardInstance } from "./cards"; import { type CardInstance } from "./cards";
import { import {
boardView, boardView,
@@ -55,6 +55,8 @@ export interface GameView {
squareContents: Record<string, SquareContent>; squareContents: Record<string, SquareContent>;
groundObjects: Record<string, CardInstance[]>; groundObjects: Record<string, CardInstance[]>;
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
openDoorEdges: string[]; openDoorEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */ /** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[]; knownIllusionEdges: string[];
@@ -123,6 +125,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]), Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
), ),
doorStates: { ...state.doorStates }, doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges], openDoorEdges: [...state.openDoorEdges],
knownIllusionEdges, knownIllusionEdges,
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
@@ -164,7 +167,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
} }
for (const key of Object.keys(view.board.cells)) { for (const key of Object.keys(view.board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (hasLineOfSight(view.board, me.position, { x, y }, blockers)) out.add(key); if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
} }
return out; return out;
} }
+37
View File
@@ -3,6 +3,7 @@ import {
assembleBoard, assembleBoard,
edgeState, edgeState,
hasLineOfSight, hasLineOfSight,
sightBetween,
layoutIds, layoutIds,
stepTarget, stepTarget,
type SectorPlacement, type SectorPlacement,
@@ -136,3 +137,39 @@ describe("player counts 2-6", () => {
} }
}); });
}); });
describe("line of sight through wraparound openings", () => {
const twoSector: SectorPlacement[] = [
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
];
it("sees straight down the corridor, out one mouth and in the other", () => {
const board = assembleBoard(twoSector);
// (2,0) N wraps to (2,9): the mouths see each other through the join.
expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false);
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true);
expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true);
});
it("demands a straight corridor: off-axis viewers see nothing", () => {
const board = assembleBoard(twoSector);
expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false);
expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false);
});
it("walls inside either corridor still block the warp sight", () => {
const board = assembleBoard(twoSector);
// Layout A: the wall between (2,0) and (2,1)'s row (home column sight is
// cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight.
const deepViewer = { x: 2, y: 2 };
expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false);
expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false);
});
it("a filled mouth chokes the tunnel", () => {
const board = assembleBoard(twoSector);
const stone = { "2,9": true as const };
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
});
});
+60 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; 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 type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
@@ -328,3 +328,62 @@ describe("deck revisions", () => {
expect(inGame(legacy.state)).toBe(true); 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);
});
});
+20 -3
View File
@@ -24,6 +24,8 @@
let peekCard = $state<CardInstance | null>(null); let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */ /** When the peeked card is a creature on the board, its live stats ride along. */
let peekCreatureId = $state<string | null>(null); let peekCreatureId = $state<string | null>(null);
/** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false);
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -105,7 +107,12 @@
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); 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 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( const cellSelectMode = $derived(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
); );
@@ -117,6 +124,7 @@
} }
function clearSelection() { function clearSelection() {
punchWallMode = false;
ambushVia = null; ambushVia = null;
ambushTrigger = null; ambushTrigger = null;
ambushSpell = null; ambushSpell = null;
@@ -461,11 +469,17 @@
} }
function clickEdge(cell: { x: number; y: number }, side: Side) { function clickEdge(cell: { x: number; y: number }, side: Side) {
if (punchWallMode) {
dispatch({ type: "punchWall", cell, side });
punchWallMode = false;
return;
}
if (!selectedCard || !edgeSelectMode) return; if (!selectedCard || !edgeSelectMode) return;
dispatch({ dispatch({
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side }, target: { kind: "edge", cell, side },
...(attackVsWall && attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
}); });
clearSelection(); clearSelection();
} }
@@ -1105,8 +1119,8 @@
{#if selectedDef} {#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong> <strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if} {#if edgeSelectMode}<span>— click a wall line</span>{/if}
{#if selectedDef.cardType === "attack" && !edgeSelectMode && !cellSelectMode} {#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
<span>— click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"}</span> <span>— click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
{/if} {/if}
{#if attachedMods.length > 0} {#if attachedMods.length > 0}
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span> <span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
@@ -1183,6 +1197,9 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button> Pick up {cardDef(obj.cardId).name}</button>
{/each} {/each}
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
onclick={() => (punchWallMode = !punchWallMode)}>
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button> <button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
<label class="inline draw-pick"> <label class="inline draw-pick">
draw draw
+22
View File
@@ -312,6 +312,21 @@
{/if} {/if}
{/each} {/each}
<!-- battle damage: cracks spread as a wall or door takes attacks -->
{#each Object.entries(view.wallDamage) as [key, dmg] (key)}
{@const kind = key.split(":")[0]}
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"}
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
class="crack" style:opacity={0.35 + frac * 0.65} />
{:else}
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
class="crack" style:opacity={0.35 + frac * 0.65} />
{/if}
{/each}
<!-- illusions YOU know are fake: ghostly dashed lines --> <!-- illusions YOU know are fake: ghostly dashed lines -->
{#each view.knownIllusionEdges as key (key)} {#each view.knownIllusionEdges as key (key)}
{@const kind = key.split(":")[0]} {@const kind = key.split(":")[0]}
@@ -604,6 +619,13 @@
text-anchor: middle; pointer-events: none; text-anchor: middle; pointer-events: none;
} }
.carried { fill: gold; stroke: #111; stroke-width: 1; } .carried { fill: gold; stroke: #111; stroke-width: 1; }
.crack {
stroke: #efe8d4;
stroke-width: 2;
stroke-dasharray: 3 5;
stroke-linecap: round;
pointer-events: none;
}
.edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; } .edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
.edge-hit:hover { fill: rgba(30, 120, 240, 0.5); } .edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
</style> </style>
+4
View File
@@ -129,6 +129,10 @@ export function humanize(e: GameEvent): string | null {
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`; case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`; case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`; case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`;
}
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null; case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`; case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;