Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c384274d | ||
|
|
64454ffc3e |
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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).`;
|
||||||
|
|||||||
Reference in New Issue
Block a user