Compare commits
2
Commits
fce3adc340
...
41c384274d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c384274d | ||
|
|
64454ffc3e |
@@ -18,6 +18,10 @@ export interface SectorPlacement {
|
||||
rotation: Rotation;
|
||||
}
|
||||
|
||||
export function opposite(s: Side): Side {
|
||||
return s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E";
|
||||
}
|
||||
|
||||
export interface Warp {
|
||||
/** Leaving this cell through this side of the map... */
|
||||
from: { cell: Cell; side: Side };
|
||||
@@ -198,7 +202,6 @@ export function assembleBoard(
|
||||
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) {
|
||||
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`
|
||||
* (solid stone, thornbushes) it passes through. Grazing a wall endpoint
|
||||
* (passing exactly through a corner adjacent to a wall) counts as blocked —
|
||||
* strict reading; revisit against FAQ rulings if needed. LOS through
|
||||
* wraparound openings is not yet modeled (TODO).
|
||||
* strict reading. Direct sight only; `sightBetween` adds the wraparound
|
||||
* openings.
|
||||
*/
|
||||
export function hasLineOfSight(
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -21,6 +21,7 @@ import {
|
||||
cellKey,
|
||||
edgeKey,
|
||||
hasLineOfSight,
|
||||
sightBetween,
|
||||
neighbor,
|
||||
stepTarget,
|
||||
} from "./board";
|
||||
@@ -214,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. */
|
||||
@@ -290,7 +293,7 @@ export function losBlockers(state: GameState): Record<string, true> {
|
||||
|
||||
/** LOS including square-filling blockers. */
|
||||
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. */
|
||||
@@ -368,13 +371,13 @@ function casterLos(
|
||||
): boolean {
|
||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
||||
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;
|
||||
for (const key of Object.keys(board.edges)) {
|
||||
if ((board.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true;
|
||||
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -531,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 } }
|
||||
@@ -570,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 }
|
||||
@@ -2653,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);
|
||||
@@ -2752,7 +2758,7 @@ function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
|
||||
|
||||
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
|
||||
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 {
|
||||
@@ -2946,6 +2952,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
phase: "playing",
|
||||
board,
|
||||
edgeOverrides: {},
|
||||
wallDamage: {},
|
||||
doorStates: {},
|
||||
openDoorEdges: [],
|
||||
createdEdges: {},
|
||||
@@ -3098,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);
|
||||
@@ -3518,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);
|
||||
@@ -3812,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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { hasLineOfSight, type AssembledBoard } from "./board";
|
||||
import { sightBetween, type AssembledBoard } from "./board";
|
||||
import { type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
@@ -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] })),
|
||||
@@ -164,7 +167,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
}
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
assembleBoard,
|
||||
edgeState,
|
||||
hasLineOfSight,
|
||||
sightBetween,
|
||||
layoutIds,
|
||||
stepTarget,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
let peekCard = $state<CardInstance | null>(null);
|
||||
/** When the peeked card is a creature on the board, its live stats ride along. */
|
||||
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 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}
|
||||
<strong class="hint-name">{selectedDef.name}</strong>
|
||||
{#if edgeSelectMode}<span>— click a wall line</span>{/if}
|
||||
{#if selectedDef.cardType === "attack" && !edgeSelectMode && !cellSelectMode}
|
||||
<span>— click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"}</span>
|
||||
{#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
|
||||
<span>— click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
|
||||
{/if}
|
||||
{#if attachedMods.length > 0}
|
||||
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
|
||||
@@ -1183,6 +1197,9 @@
|
||||
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
||||
Pick up {cardDef(obj.cardId).name}</button>
|
||||
{/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>
|
||||
<label class="inline draw-pick">
|
||||
draw
|
||||
|
||||
@@ -312,6 +312,21 @@
|
||||
{/if}
|
||||
{/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 -->
|
||||
{#each view.knownIllusionEdges as key (key)}
|
||||
{@const kind = key.split(":")[0]}
|
||||
@@ -604,6 +619,13 @@
|
||||
text-anchor: middle; pointer-events: none;
|
||||
}
|
||||
.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:hover { fill: rgba(30, 120, 240, 0.5); }
|
||||
</style>
|
||||
|
||||
@@ -129,6 +129,10 @@ export function humanize(e: GameEvent): string | null {
|
||||
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 "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 "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;
|
||||
|
||||
Reference in New Issue
Block a user