Card wave 3: terrain, thrown objects, drag, and control spells

Terrain layer: FILL SQUARE WITH STONE (impassable, blocks LOS via new
cell-blocking sight checks), THORNBUSH (enter = 1 damage + turn ends +
next turn lost; no attacking in or into a bush), WALL OF FIRE (new
firewall edge state — passable for 4 magical damage, blocks LOS,
expires with its duration), WATERWALL (instant wave: players within
two spaces washed back two, 1 damage per blocked space), and DISPEL
CREATION with provenance tracking (only conjured walls/fire/stone/
bushes dispel — printed maze is safe). Objects: DAGGER (3) and LARGE
ROCK (2) are physical throws Full Shield cannot stop; they land on the
floor and anyone may pick them up (ending their turn's actions, hand
limit enforced); DROP OBJECT forces a named object or carried treasure
to the ground; DRAG pulls floor objects, treasures, or players
straight toward the caster. Control: LOCK IN PLACE (no moving or
being moved — teleports, swaps, knockbacks and drags all respect it),
BUDDY (a pact the caster breaks by attacking), MIST-BODY (through
walls and doors, cannot attack or be attacked, still burns in
firewalls), REUSE SPELL (retrieve your last spell). Client renders
terrain, firewalls, and ground objects, with cell/edge/two-stage
targeting and card-name inputs. 40 cards implemented; 63 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 20:13:14 -04:00
co-authored by Claude Fable 5
parent 67743dd17e
commit 2d88b4ab40
6 changed files with 1029 additions and 27 deletions
+29 -6
View File
@@ -8,7 +8,7 @@ import boardsData from "../data/boards.json";
export type Cell = { readonly x: number; readonly y: number }; export type Cell = { readonly x: number; readonly y: number };
export type Side = "N" | "S" | "E" | "W"; export type Side = "N" | "S" | "E" | "W";
export type EdgeState = "open" | "wall" | "door"; export type EdgeState = "open" | "wall" | "door" | "firewall";
export type Rotation = 0 | 90 | 180 | 270; export type Rotation = 0 | 90 | 180 | 270;
export interface SectorPlacement { export interface SectorPlacement {
@@ -252,17 +252,40 @@ export function stepTarget(
/** /**
* Line of sight from the center of `from` to the center of `to`, blocked by * Line of sight from the center of `from` to the center of `to`, blocked by
* wall/door edges the segment crosses. Grazing a wall endpoint (passing * wall/door/firewall edges the segment crosses and by any `blockedCells`
* exactly through a corner adjacent to a wall) counts as blocked — strict * (solid stone, thornbushes) it passes through. Grazing a wall endpoint
* reading; revisit against FAQ rulings if needed. LOS through wraparound * (passing exactly through a corner adjacent to a wall) counts as blocked —
* openings is not yet modeled (TODO). * strict reading; revisit against FAQ rulings if needed. LOS through
* wraparound openings is not yet modeled (TODO).
*/ */
export function hasLineOfSight(board: AssembledBoard, from: Cell, to: Cell): boolean { export function hasLineOfSight(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
if (cellKey(from) === cellKey(to)) return true; if (cellKey(from) === cellKey(to)) return true;
// Centers of cells: (x + 0.5, y + 0.5). // Centers of cells: (x + 0.5, y + 0.5).
const x0 = from.x + 0.5, y0 = from.y + 0.5; const x0 = from.x + 0.5, y0 = from.y + 0.5;
const x1 = to.x + 0.5, y1 = to.y + 0.5; const x1 = to.x + 0.5, y1 = to.y + 0.5;
if (blockedCells) {
for (const key of Object.keys(blockedCells)) {
const [bx, by] = key.split(",").map(Number) as [number, number];
if ((bx === from.x && by === from.y) || (bx === to.x && by === to.y)) continue;
// The sight line is blocked if it crosses any side of the solid cell.
const sides: [number, number, number, number][] = [
[bx, by, bx + 1, by],
[bx, by + 1, bx + 1, by + 1],
[bx, by, bx, by + 1],
[bx + 1, by, bx + 1, by + 1],
];
if (sides.some(([ax, ay, cx, cy]) => segmentsIntersect(x0, y0, x1, y1, ax, ay, cx, cy))) {
return false;
}
}
}
for (const [key, state] of Object.entries(board.edges)) { for (const [key, state] of Object.entries(board.edges)) {
if (state === "open") continue; if (state === "open") continue;
// Reconstruct the wall segment for this edge. // Reconstruct the wall segment for this edge.
+506 -11
View File
@@ -76,6 +76,16 @@ export interface SustainedEffect {
remainingTurns: number; remainingTurns: number;
/** Per-card scratch (e.g. SLOW's turn parity counter). */ /** Per-card scratch (e.g. SLOW's turn parity counter). */
data: Record<string, number>; data: Record<string, number>;
/** For edge-bound spells (WALL OF FIRE): the edge to clean up on expiry. */
edge?: string;
}
/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */
export interface SquareContent {
kind: "stone" | "thornbush";
/** Damage taken so far; thornbushes die at 5. Stone is indestructible. */
damage: number;
createdBy: PlayerId;
} }
export interface TurnState { export interface TurnState {
@@ -131,6 +141,14 @@ export interface GameState {
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. */
openDoorEdges: string[]; openDoorEdges: string[];
/** Walls/firewalls conjured during play (dispellable), by edge key. */
createdEdges: Record<string, true>;
/** Square-filling creations, by cell key. */
squareContents: Record<string, SquareContent>;
/** Object cards lying on the floor, by cell key. */
groundObjects: Record<string, CardInstance[]>;
/** The last spell card each player used (for REUSE SPELL). */
lastSpellUsed: Record<PlayerId, string>;
players: PlayerState[]; players: PlayerState[];
treasures: TreasureState[]; treasures: TreasureState[];
sustained: SustainedEffect[]; sustained: SustainedEffect[];
@@ -155,6 +173,25 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId)); return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
} }
/** LOS including square-filling blockers (stone, thornbushes). */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
const blockers: Record<string, true> = {};
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
return hasLineOfSight(boardView(state), from, to, blockers);
}
function inThornbush(state: GameState, p: PlayerState): boolean {
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
}
function isMisted(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "mist-body").length > 0;
}
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "lock-in-place").length > 0;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Events // Events
@@ -191,6 +228,19 @@ export type GameEvent =
| { type: "handRevealed"; player: PlayerId; to: PlayerId } | { type: "handRevealed"; player: PlayerId; to: PlayerId }
| { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] } | { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number }
| { type: "firewallExpired"; edge: string }
| { type: "firewallBurned"; player: PlayerId }
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: "stone" | "thornbush" }
| { type: "creationDispelled"; caster: PlayerId; what: string }
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
| { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean }
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { 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 }
@@ -249,6 +299,8 @@ export type Command =
| { type: "counteract"; instanceId: string } | { type: "counteract"; instanceId: string }
| { type: "pass" } | { type: "pass" }
| { type: "pickUpTreasure" } | { type: "pickUpTreasure" }
| { type: "pickUpObject"; instanceId: string }
| { type: "dropObject"; instanceId: string }
| { type: "dropTreasure" } | { type: "dropTreasure" }
| { type: "discard"; instanceIds: string[] } | { type: "discard"; instanceIds: string[] }
| { type: "endTurn"; draw: number }; | { type: "endTurn"; draw: number };
@@ -263,6 +315,8 @@ export type CommandResult =
type AttackEffect = { type AttackEffect = {
kind: "attack"; kind: "attack";
requiresLos?: boolean; requiresLos?: boolean;
/** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */
physical?: boolean;
/** Attacker must share the target's square (WIZARDBLADE). */ /** Attacker must share the target's square (WIZARDBLADE). */
sameSquare?: boolean; sameSquare?: boolean;
baseDamage: (numberValue: number | null, params: CastParams | null) => number; baseDamage: (numberValue: number | null, params: CastParams | null) => number;
@@ -416,10 +470,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const cell = cmd.params?.cell; const cell = cmd.params?.cell;
if (!cell) return "teleport opponent needs a destination cell"; if (!cell) return "teleport opponent needs a destination cell";
if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board"; if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
return null; return null;
}, },
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params!.cell!; const to = ctx.stack.params!.cell!;
const from = ctx.defender.position; const from = ctx.defender.position;
ctx.defender.position = to; ctx.defender.position = to;
@@ -435,6 +491,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
baseDamage: () => 0, baseDamage: () => 0,
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id) || isLockedInPlace(ctx.state, ctx.attacker.id)) return;
const a = ctx.attacker.position; const a = ctx.attacker.position;
ctx.attacker.position = ctx.defender.position; ctx.attacker.position = ctx.defender.position;
ctx.defender.position = a; ctx.defender.position = a;
@@ -561,6 +618,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line"; if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line";
state.edgeOverrides[key] = "wall"; state.edgeOverrides[key] = "wall";
state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
return null; return null;
}, },
@@ -577,6 +635,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall";
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
delete state.doorStates[key]; delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" }); events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
for (const c of [cell, neighbor(cell, side)]) { for (const c of [cell, neighbor(cell, side)]) {
for (const p of state.players) { for (const p of state.players) {
@@ -643,9 +702,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// ... your movement ends after you play it." // ... your movement ends after you play it."
resolve: (state, events, caster, cmd) => { resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell"; if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell";
if (isLockedInPlace(state, caster.id)) return "you are locked in place";
const to = cmd.target.cell; const to = cmd.target.cell;
const view = boardView(state); const view = boardView(state);
if (!view.cells[cellKey(to)]) return "destination is off the board"; if (!view.cells[cellKey(to)]) return "destination is off the board";
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
if (wallIgnoringDistance(view, caster.position, to) > 4) { if (wallIgnoringDistance(view, caster.position, to) > 4) {
return "teleport reaches at most four spaces"; return "teleport reaches at most four spaces";
} }
@@ -700,8 +761,328 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
return null; return null;
}, },
}, },
"mist-body": {
kind: "neutral",
resolve: (state, events, caster, _cmd, magnitude) => {
attachSustained(state, events, "mist-body", caster.id, caster.id, magnitude.duration);
return null;
},
},
// --- More attacks ---------------------------------------------------------
"lock-in-place": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
buddy: {
kind: "neutral",
// "Opponent will not attack you unless you attack first. This is
// permanent until you attack." Neutral, LOS per card.
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "player") return "buddy targets a player";
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, 1_000_000_000);
return null;
},
},
dagger: {
kind: "attack",
requiresLos: true,
physical: true,
keepInHand: false,
// "You may throw it. Does three points physical damage. ... Retrievable
// by anyone after it is thrown."
baseDamage: () => 3,
onResolved: (ctx) => { landThrownObject(ctx, "dagger"); },
},
"large-rock": {
kind: "attack",
requiresLos: true,
physical: true,
baseDamage: () => 2,
onResolved: (ctx) => { landThrownObject(ctx, "large-rock"); },
},
"drop-object": {
kind: "attack",
requiresLos: true,
baseDamage: () => 0,
validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the object to drop"),
onResolved: (ctx) => {
if (ctx.fullyStopped) return;
const wanted = ctx.stack.params!.cardId!;
if (wanted === "treasure") {
if (!ctx.defender.carriedTreasureId) return;
const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!;
t.carriedBy = null;
t.position = ctx.defender.position;
ctx.defender.carriedTreasureId = null;
ctx.events.push({
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
at: ctx.defender.position, onHomeOf: null,
});
return;
}
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
if (idx === -1) return;
const [card] = ctx.defender.hand.splice(idx, 1);
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
const key = cellKey(ctx.defender.position);
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card!];
ctx.events.push({
type: "objectDropped", player: ctx.defender.id, card: card!,
at: ctx.defender.position, forced: true,
});
},
},
// --- Terrain --------------------------------------------------------------
"fill-square-with-stone": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind: "stone", damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "stone" });
return null;
},
},
thornbush: {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const problem = emptySquareTarget(state, cmd, caster);
if (typeof problem === "string") return problem;
state.squareContents[cellKey(problem)] = { kind: "thornbush", damage: 0, createdBy: caster.id };
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "thornbush" });
return null;
},
},
"wall-of-fire": {
kind: "neutral",
// Neutral use: a burning barrier for [duration] turns. (Counteraction
// use vs WATERBOLT: TODO.)
resolve: (state, events, caster, cmd, magnitude) => {
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
const { cell, side } = cmd.target;
const view = boardView(state);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the fire must span a corridor between two spaces";
}
const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "firewall";
state.createdEdges[key] = true;
const fx: SustainedEffect = {
id: `fx-${state.nextEffectId++}`,
cardId: "wall-of-fire",
casterId: caster.id,
targetId: caster.id,
remainingTurns: Math.max(1, magnitude.duration),
data: {},
edge: key,
};
state.sustained.push(fx);
events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns });
return null;
},
},
waterwall: {
kind: "neutral",
// "The moment you create it, it collapses, washing away any player within
// two spaces back two spaces (including the caster)."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
const { cell, side } = cmd.target;
const view = boardView(state);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the wave must span a corridor between two spaces";
}
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
// The two sides of the edge, and the push directions away from it.
const a = cell;
const b = neighbor(cell, side);
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
const pushes: { start: Cell; dir: Side }[] = [
{ start: a, dir: away(side) },
{ start: b, dir: side },
];
for (const { start, dir } of pushes) {
// Players on the two cells extending away from the edge on this side.
let probe = start;
for (let dist = 0; dist < 2; dist++) {
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(probe)) continue;
washBack(state, events, p, dir);
}
probe = neighbor(probe, dir);
}
}
checkVictory(state, events);
return null;
},
},
"dispel-creation": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
const view = boardView(state);
if (cmd.target?.kind === "edge") {
const key = edgeKey(cmd.target.cell, cmd.target.side);
if (!state.createdEdges[key]) return "that is not a created thing";
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
const was = view.edges[key];
delete state.edgeOverrides[key];
delete state.createdEdges[key];
state.sustained = state.sustained.filter((s) => s.edge !== key);
events.push({ type: "creationDispelled", caster: caster.id, what: was === "firewall" ? "wall of fire" : "created wall" });
return null;
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null;
}
return "dispel targets a created wall, fire, stone, or bush";
},
},
drag: {
kind: "neutral",
// "Drags any moveable object within L.O.S. towards you ..." (and, per the
// rulebook's Objects section, players can be DRAGged too).
resolve: (state, events, caster, cmd) => {
if (cmd.target?.kind === "player") {
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position;
dragToward(state, target, caster.position);
events.push({ type: "objectDragged", caster: caster.id, what: target.id, from, to: target.position });
return null;
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) {
const card = objects[objects.length - 1]!;
objects.pop();
if (objects.length === 0) delete state.groundObjects[key];
const destKey = cellKey(caster.position);
state.groundObjects[destKey] = [...(state.groundObjects[destKey] ?? []), card];
events.push({ type: "objectDragged", caster: caster.id, what: card.cardId, from: cmd.target.cell, to: caster.position });
return null;
}
if (treasure) {
const from = treasure.position!;
treasure.position = { ...caster.position };
events.push({ type: "objectDragged", caster: caster.id, what: treasure.id, from, to: caster.position });
checkVictory(state, events);
return null;
}
return "nothing to drag there";
}
return "drag targets an object square or a player";
},
},
"reuse-spell": {
kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but
// not the NUMBER card)."
resolve: (state, events, caster) => {
const lastId = state.lastSpellUsed[caster.id];
if (!lastId || lastId === "reuse-spell") return "no spell to retrieve";
// The most recent copy of that card in the discard pile is yours.
for (let i = state.discard.length - 1; i >= 0; i--) {
if (state.discard[i]!.cardId === lastId) {
const [card] = state.discard.splice(i, 1);
caster.hand.push(card!);
events.push({ type: "spellReused", player: caster.id, card: card! });
if (caster.hand.length > HAND_LIMIT) state.pendingDiscard = caster.id;
delete state.lastSpellUsed[caster.id];
return null;
}
}
return "that spell is no longer in the discard pile";
},
},
}; };
/** Thrown weapons land in the target's square, whatever the counters did. */
function landThrownObject(ctx: ResolutionContext, cardId: string): void {
const card = ctx.stack.attackCard!;
const key = cellKey(ctx.defender.position);
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card];
// The card was discarded on cast; move it from the discard to the floor.
const di = ctx.state.discard.findIndex((c) => c.instanceId === card.instanceId);
if (di !== -1) ctx.state.discard.splice(di, 1);
ctx.events.push({ type: "objectThrown", attacker: ctx.attacker.id, cardId, landedAt: ctx.defender.position });
}
/** Validate a cell target for square-filling creations. */
function emptySquareTarget(
state: GameState,
cmd: Extract<Command, { type: "cast" }>,
caster: PlayerState,
): Cell | string {
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
const cell = cmd.target.cell;
const key = cellKey(cell);
const view = boardView(state);
if (!view.cells[key]) return "off the board";
if (state.squareContents[key]) return "that square is occupied";
if (view.homes.some((h) => cellKey(h) === key)) return "you cannot create on a home base";
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
if (!gameLos(state, caster.position, cell)) return "no line of sight";
return cell;
}
/** WATERWALL: push a player 2 spaces along dir; 1 damage per blocked space. */
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
if (isLockedInPlace(state, p.id)) return;
const view = boardView(state);
const from = p.position;
let moved = 0;
for (let i = 0; i < 2; i++) {
const step = stepTarget(view, p.position, dir);
if (step.kind === "blocked") break;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
p.position = step.to;
moved++;
}
const blockedSpaces = 2 - moved;
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
if (blockedSpaces > 0) {
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null);
}
}
/** DRAG a player straight toward the caster, stopping at walls. */
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
const view = boardView(state);
for (let guard = 0; guard < 20; guard++) {
if (cellKey(target.position) === cellKey(dest)) return;
const dx = dest.x - target.position.x;
const dy = dest.y - target.position.y;
let dir: Side;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else dir = dy > 0 ? "S" : "N";
const step = stepTarget(view, target.position, dir);
if (step.kind === "blocked") return;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") return;
target.position = step.to;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Effect helpers // Effect helpers
@@ -873,6 +1254,10 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
edgeOverrides: {}, edgeOverrides: {},
doorStates: {}, doorStates: {},
openDoorEdges: [], openDoorEdges: [],
createdEdges: {},
squareContents: {},
groundObjects: {},
lastSpellUsed: {},
players, players,
treasures, treasures,
sustained: [], sustained: [],
@@ -937,6 +1322,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "counteract": return err("nothing to counteract"); case "counteract": return err("nothing to counteract");
case "pass": return err("nothing to pass on"); case "pass": return err("nothing to pass on");
case "pickUpTreasure": return doPickUpTreasure(state); case "pickUpTreasure": return doPickUpTreasure(state);
case "pickUpObject": return doPickUpObject(state, command.instanceId);
case "dropObject": return doDropObject(state, command.instanceId);
case "dropTreasure": return doDropTreasure(state); case "dropTreasure": return doDropTreasure(state);
case "discard": return doDiscard(state, playerId, command.instanceIds); case "discard": return doDiscard(state, playerId, command.instanceIds);
case "endTurn": return doEndTurn(state, command.draw); case "endTurn": return doEndTurn(state, command.draw);
@@ -976,25 +1363,37 @@ function doMove(prev: GameState, direction: Side): CommandResult {
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
const mover = activePlayer(prev); const mover = activePlayer(prev);
if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa"); if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa");
if (isLockedInPlace(prev, mover.id)) return err("you are locked in place");
const state = clone(prev); const state = clone(prev);
const p = activePlayer(state); const p = activePlayer(state);
const view = boardView(state); const view = boardView(state);
const target = stepTarget(view, p.position, direction); const target = stepTarget(view, p.position, direction);
const events: GameEvent[] = [];
const misted = isMisted(state, p.id);
const from = p.position; const from = p.position;
let via: "step" | "warp" | "passWall"; let via: "step" | "warp" | "passWall";
let crossedFirewall = false;
if (target.kind === "blocked") { if (target.kind === "blocked") {
const key = edgeKey(p.position, direction); const key = edgeKey(p.position, direction);
const edge = view.edges[key] ?? "open"; const edge = view.edges[key] ?? "open";
const dest = neighbor(p.position, direction); const dest = neighbor(p.position, direction);
// A locked door that has been unlocked or de-locked is passable.
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
if (!view.cells[cellKey(dest)]) return err("blocked"); if (!view.cells[cellKey(dest)]) return err("blocked");
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
p.position = dest; p.position = dest;
via = "step"; via = "step";
} else if (edge === "wall" && p.passWallCharges > 0 && view.cells[cellKey(dest)]) { } else if (edge === "firewall") {
// PASS THROUGH WALL: one charge, one wall. // "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY.
p.position = dest;
via = "step";
crossedFirewall = true;
} else if (misted && (edge === "wall" || edge === "door")) {
// MIST-BODY passes through anything but solid stone.
p.position = dest;
via = "passWall";
} else if (edge === "wall" && p.passWallCharges > 0) {
p.passWallCharges--; p.passWallCharges--;
p.position = dest; p.position = dest;
via = "passWall"; via = "passWall";
@@ -1006,12 +1405,30 @@ function doMove(prev: GameState, direction: Side): CommandResult {
via = target.kind; via = target.kind;
} }
// Square contents at the destination.
const content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone");
state.turn.movementUsed++; state.turn.movementUsed++;
return { events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
ok: true,
state, if (crossedFirewall) {
events: [{ type: "moved", player: p.id, from, to: p.position, direction, via }], events.push({ type: "firewallBurned", player: p.id });
}; applyDamage(state, events, p, 4, "wall of fire", null);
checkVictory(state, events);
}
// THORNBUSH: "his turn ends, he loses his following turn, and he takes one
// point of physical damage from thorns."
if (content?.kind === "thornbush" && p.alive) {
events.push({ type: "enteredThornbush", player: p.id, at: p.position });
applyDamage(state, events, p, 1, "thorns", null);
p.lostTurns++;
state.turn.actionsEnded = true;
checkVictory(state, events);
}
return { ok: true, state, events };
} }
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult { function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
@@ -1063,6 +1480,20 @@ function castingBlocked(state: GameState, playerId: PlayerId): string | null {
return null; return null;
} }
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
// BUDDY: "Opponent will not attack you unless you attack first."
const buddy = state.sustained.find(
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
);
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
return null;
}
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);
@@ -1075,6 +1506,11 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
if (cellKey(target.position) !== cellKey(attacker.position)) { if (cellKey(target.position) !== cellKey(attacker.position)) {
return err("you must be in the same square to punch"); return err("you must be in the same square to punch");
} }
const bushOrMist = attackBlockedByStatus(state, attacker, target);
if (bushOrMist) return err(bushOrMist);
state.sustained = state.sustained.filter(
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
);
state.turn.attackUsed = true; state.turn.attackUsed = true;
state.stack = { state.stack = {
@@ -1224,9 +1660,15 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !hasLineOfSight(boardView(state), caster.position, target.position)) { const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock);
if (effect.requiresLos && !gameLos(state, caster.position, target.position)) {
return err("no line of sight to the target"); return err("no line of sight to the target");
} }
// Attacking someone breaks any BUDDY pact you swore to them.
state.sustained = state.sustained.filter(
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
);
if (effect.validate) { if (effect.validate) {
const problem = effect.validate(state, cmd); const problem = effect.validate(state, cmd);
if (problem) return err(problem); if (problem) return err(problem);
@@ -1250,10 +1692,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
amplifyFactor: 2 ** mods.amplifies.length, amplifyFactor: 2 ** mods.amplifies.length,
extendFactor: mods.extend ? 2 : 1, extendFactor: mods.extend ? 2 : 1,
params: cmd.params ?? null, params: cmd.params ?? null,
kind: "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
}; };
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", type: "spellCast",
caster: caster.id, caster: caster.id,
@@ -1293,6 +1736,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.keepInHand) { if (effect.keepInHand) {
events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
} }
if (cardDef(inHand.cardId).cardType !== "object" && inHand.cardId !== "reuse-spell") {
state.lastSpellUsed[caster.id] = inHand.cardId;
}
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude); const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
if (result) return err(result); // unreachable after preview if (result) return err(result); // unreachable after preview
return { ok: true, state, events }; return { ok: true, state, events };
@@ -1512,13 +1958,17 @@ function knockBack(
} }
const from = defender.position; const from = defender.position;
if (isLockedInPlace(state, defender.id)) return;
let moved = 0; let moved = 0;
const view = boardView(state); const view = boardView(state);
for (let i = 0; i < squares; i++) { for (let i = 0; i < squares; i++) {
const step = stepTarget(view, defender.position, dir); const step = stepTarget(view, defender.position, dir);
if (step.kind === "blocked") break; if (step.kind === "blocked") break;
const content = state.squareContents[cellKey(step.to)];
if (content?.kind === "stone") break;
defender.position = step.to; defender.position = step.to;
moved++; moved++;
if (content?.kind === "thornbush") break; // tangled in the thorns
} }
if (moved > 0) { if (moved > 0) {
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved }); events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
@@ -1606,6 +2056,45 @@ function doPickUpTreasure(prev: GameState): CommandResult {
}; };
} }
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const p = activePlayer(state);
const key = cellKey(p.position);
const here = state.groundObjects[key] ?? [];
const idx = here.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return err("that object is not here");
const [card] = here.splice(idx, 1);
if (here.length === 0) delete state.groundObjects[key];
p.hand.push(card!);
// "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT."
state.turn.actionsEnded = true;
if (p.hand.length > HAND_LIMIT) state.pendingDiscard = p.id;
return {
ok: true,
state,
events: [{ type: "objectPickedUp", player: p.id, card: card!, at: p.position }],
};
}
function doDropObject(prev: GameState, instanceId: string): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
const card = p.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
if (cardDef(card.cardId).cardType !== "object") return err("only objects can be dropped");
takeFromHand(p, instanceId);
const key = cellKey(p.position);
state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card];
return {
ok: true,
state,
events: [{ type: "objectDropped", player: p.id, card, at: p.position, forced: false }],
};
}
function doDropTreasure(prev: GameState): CommandResult { function doDropTreasure(prev: GameState): CommandResult {
const state = clone(prev); const state = clone(prev);
const p = activePlayer(state); const p = activePlayer(state);
@@ -1708,6 +2197,12 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
s.remainingTurns--; s.remainingTurns--;
if (s.remainingTurns <= 0) { if (s.remainingTurns <= 0) {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId }); events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
continue; continue;
} }
} }
+17
View File
@@ -9,6 +9,8 @@ import {
type CastStack, type CastStack,
type GameState, type GameState,
type PlayerId, type PlayerId,
type SquareContent,
type SustainedEffect,
type TreasureState, type TreasureState,
type TurnState, type TurnState,
} from "./game"; } from "./game";
@@ -23,6 +25,7 @@ export interface PlayerPublicView {
carriedTreasureId: string | null; carriedTreasureId: string | null;
lostTurns: number; lostTurns: number;
extraTurns: number; extraTurns: number;
displayed: CardInstance[];
} }
export interface GameView { export interface GameView {
@@ -41,6 +44,12 @@ export interface GameView {
/** Cards on the stack are face-up: the whole exchange is public. */ /** Cards on the stack are face-up: the whole exchange is public. */
stack: CastStack | null; stack: CastStack | null;
pendingDiscard: PlayerId | null; pendingDiscard: PlayerId | null;
/** Duration spells in play (public knowledge). */
sustained: SustainedEffect[];
squareContents: Record<string, SquareContent>;
groundObjects: Record<string, CardInstance[]>;
doorStates: Record<string, "jammed" | "removed">;
openDoorEdges: string[];
} }
export function viewFor(state: GameState, playerId: PlayerId): GameView { export function viewFor(state: GameState, playerId: PlayerId): GameView {
@@ -62,6 +71,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
carriedTreasureId: p.carriedTreasureId, carriedTreasureId: p.carriedTreasureId,
lostTurns: p.lostTurns, lostTurns: p.lostTurns,
extraTurns: p.extraTurns, extraTurns: p.extraTurns,
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
})), })),
yourHand: you ? [...you.hand] : [], yourHand: you ? [...you.hand] : [],
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
@@ -69,5 +79,12 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
discardCount: state.discard.length, discardCount: state.discard.length,
stack: state.stack, stack: state.stack,
pendingDiscard: state.pendingDiscard, pendingDiscard: state.pendingDiscard,
sustained: state.sustained.map((s) => ({ ...s })),
squareContents: { ...state.squareContents },
groundObjects: Object.fromEntries(
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
),
doorStates: { ...state.doorStates },
openDoorEdges: [...state.openDoorEdges],
}; };
} }
+334
View File
@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
/** An empty visible cell adjacent to the player (not home, no treasure). */
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
describe("terrain", () => {
it("fill square with stone blocks movement and line of sight", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const fs = giveCard(state, me.id, "fill-square-with-stone");
state = must(state, me.id, {
type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell },
});
expect(applyCommand(state, me.id, { type: "move", direction: spot.side }).ok).toBe(false);
// LOS straight through the stone is blocked.
const beyond = neighbor(spot.cell, spot.side);
if (boardView(state).cells[cellKey(beyond)]) {
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
}
});
it("thornbush entry costs a life point, the turn, and the next turn", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const tb = giveCard(state, me.id, "thornbush");
state = must(state, me.id, {
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot.cell },
});
state = must(state, me.id, { type: "move", direction: spot.side });
const p = state.players.find((p) => p.id === me.id)!;
expect(p.life).toBe(14);
expect(p.lostTurns).toBe(1);
expect(state.turn.actionsEnded).toBe(true);
});
it("wizards in a thornbush cannot attack or be attacked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
// Test surgery: plant a bush and stand the defender in it.
state.squareContents[cellKey(d.position)] = { kind: "thornbush", damage: 0, createdBy: attacker };
const fb = giveCard(state, attacker, "fireball");
const refused = applyCommand(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
expect(refused.ok).toBe(false);
});
it("wall of fire burns crossers and expires with its duration", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const key = edgeKey(me.position, spot.side);
const wof = giveCard(state, me.id, "wall-of-fire");
giveCard(state, me.id, "number-2", "N", 1);
state = must(state, me.id, {
type: "cast", instanceId: wof.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBe("firewall");
// Walking through it hurts.
state = must(state, me.id, { type: "move", direction: spot.side });
expect(state.players.find((p) => p.id === me.id)!.life).toBe(11);
// Duration 2: expires at the start of the caster's second following turn.
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[key]).toBe("firewall"); // 1 turn left
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[key]).toBeUndefined();
});
it("dispel creation removes a created wall", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const key = edgeKey(me.position, spot.side);
const cw = giveCard(state, me.id, "create-wall");
state = must(state, me.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBe("wall");
const dc = giveCard(state, me.id, "dispel-creation");
state = must(state, me.id, {
type: "cast", instanceId: dc.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBeUndefined();
// But a printed (original) wall cannot be dispelled.
const dc2 = giveCard(state, me.id, "dispel-creation", "T2");
const view = boardView(state);
const wallEntry = Object.entries(view.edges).find(([, s]) => s === "wall")!;
const [kind, coords] = wallEntry[0].split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: dc2.instanceId,
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
});
expect(refused.ok).toBe(false);
});
});
describe("objects", () => {
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const dagger = giveCard(state, attacker, "dagger");
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: dagger.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(12); // full shield "does not stop any physical attack"
const floor = state.groundObjects[cellKey(d.position)] ?? [];
expect(floor.some((c) => c.cardId === "dagger")).toBe(true);
// Anyone may pick it up — and doing so ends the turn's actions.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "pickUpObject", instanceId: dagger.instanceId });
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
expect(state.turn.actionsEnded).toBe(true);
});
it("blunt halves a thrown rock's physical damage", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const rock = giveCard(state, attacker, "large-rock");
giveCard(state, defender, "blunt", "B", 0);
state = must(state, attacker, {
type: "cast", instanceId: rock.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "blunt#B" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // ceil(2/2)=1
});
it("drop object forces a named object to the floor; drag pulls a treasure home", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
giveCard(state, defender, "dagger", "D2", 0);
const dobj = giveCard(state, attacker, "drop-object");
state = castAt(state, attacker, defender, dobj, { params: { cardId: "dagger" } });
const dPos = state.players.find((p) => p.id === defender)!.position;
expect((state.groundObjects[cellKey(dPos)] ?? []).some((c) => c.cardId === "dagger")).toBe(true);
// Drag an enemy treasure across open floor toward the caster.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "endTurn", draw: 0 });
const enemyTreasure = state.treasures.find((t) => t.owner === defender && t.position)!;
const me2 = state.players.find((p) => p.id === attacker)!;
// Stand adjacent-ish to the treasure with clear LOS: same square works.
me2.position = { ...enemyTreasure.position! };
const drag = giveCard(state, attacker, "drag");
state = must(state, attacker, {
type: "cast", instanceId: drag.instanceId,
target: { kind: "cell", cell: enemyTreasure.position! },
});
const t = state.treasures.find((tr) => tr.id === enemyTreasure.id)!;
expect(cellKey(t.position!)).toBe(cellKey(state.players.find((p) => p.id === attacker)!.position));
});
});
describe("control effects", () => {
it("lock in place stops moving and being moved", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lip = giveCard(state, attacker, "lock-in-place");
giveCard(state, attacker, "number-3", "N", 1);
state = castAt(state, attacker, defender, lip, { numberInstanceIds: ["number-3#N"] });
expect(sustainedOn(state, defender, "lock-in-place").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
// Teleport Opponent fizzles against a locked target.
const tpo = giveCard(state, attacker, "teleport-opponent");
const before = state.players.find((p) => p.id === defender)!.position;
const anywhere = state.board.homes.find((h) => cellKey(h) !== cellKey(before))!;
state = castAt(state, attacker, defender, tpo, { params: { cell: anywhere } });
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(before));
});
it("buddy prevents attacks until the caster breaks the pact", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const buddy = giveCard(state, attacker, "buddy");
state = must(state, attacker, {
type: "cast", instanceId: buddy.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, attacker, { type: "endTurn", draw: 0 });
// The defender cannot bring themselves to attack the caster.
const fb = giveCard(state, defender, "fireball", "F", 0);
const refused = applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
});
expect(refused.ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
// The caster attacks first: the pact breaks; next turn the defender may.
const fb2 = giveCard(state, attacker, "fireball", "F2", 0);
state = castAt(state, attacker, defender, fb2);
expect(state.sustained.some((s) => s.cardId === "buddy")).toBe(false);
});
it("mist body passes through walls but cannot attack or be attacked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const mist = giveCard(state, defender, "mist-body", "M", 0);
giveCard(state, defender, "number-3", "N", 1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, {
type: "cast", instanceId: mist.instanceId, numberInstanceIds: ["number-3#N"],
});
// Misted wizard walks through a wall if one is adjacent.
const view = boardView(state);
const d = state.players.find((p) => p.id === defender)!;
for (const side of SIDES) {
const k = edgeKey(d.position, side);
if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(d.position, side))]) {
state = must(state, defender, { type: "move", direction: side });
break;
}
}
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
const dd = state.players.find((p) => p.id === defender)!;
state.players.find((p) => p.id === attacker)!.position = { ...dd.position };
const refused = applyCommand(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
expect(refused.ok).toBe(false);
});
it("reuse spell retrieves the last spell you cast", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const cw = giveCard(state, me.id, "create-wall");
state = must(state, me.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
const ru = giveCard(state, me.id, "reuse-spell");
state = must(state, me.id, { type: "cast", instanceId: ru.instanceId });
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
});
});
+110 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { net } from "./net.svelte"; import { net } from "./net.svelte";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import { cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -16,6 +16,12 @@
let attachedNumber = $state<CardInstance | null>(null); let attachedNumber = $state<CardInstance | null>(null);
/** Waterbolt split. */ /** Waterbolt split. */
let wbDamage = $state(0); let wbDamage = $state(0);
/** teleport-opponent: player chosen, waiting for the destination cell. */
let pendingCellFor = $state<string | null>(null);
/** card-erasure / drop-object: the named card. */
let nameInput = $state("");
/** power-run points. */
let runPoints = $state(1);
/** Cards marked for discard. */ /** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set()); let discardSelection = $state<Set<string>>(new Set());
@@ -25,8 +31,20 @@
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null); const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const edgeSelectMode = $derived( const EDGE_CARDS = new Set([
selectedCard?.cardId === "create-wall" || selectedCard?.cardId === "destroy-wall", "create-wall", "destroy-wall", "wall-of-fire", "waterwall",
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
]);
const CELL_CARDS = new Set([
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
]);
const SELF_CARDS = new Set([
"speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell",
]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
const cellSelectMode = $derived(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
); );
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1); const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
@@ -34,6 +52,19 @@
selectedCard = null; selectedCard = null;
attachedNumber = null; attachedNumber = null;
wbDamage = 0; wbDamage = 0;
pendingCellFor = null;
nameInput = "";
}
/** Map a typed card name to its id (case-insensitive). */
function nameToCardId(name: string): string | null {
const wanted = name.trim().toLowerCase();
if (!wanted) return null;
if (wanted === "treasure") return "treasure";
for (const def of allCardDefs()) {
if (def.name.toLowerCase() === wanted || def.id === wanted) return def.id;
}
return null;
} }
function selectCard(card: CardInstance) { function selectCard(card: CardInstance) {
@@ -66,17 +97,51 @@
clearSelection(); clearSelection();
return; return;
} }
// Self-targeting / untargeted spells cast immediately. // Instant untargeted spells cast immediately; duration self-spells wait
if (card.cardId === "speed") { // so a number card can be attached (cast via the button in the hint bar).
if (card.cardId === "speed" || card.cardId === "pass-through-wall" || card.cardId === "reuse-spell") {
net.command({ type: "cast", instanceId: card.instanceId }); net.command({ type: "cast", instanceId: card.instanceId });
clearSelection(); clearSelection();
} }
} }
function castSelfWithNumber() {
if (!selectedCard) return;
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
net.command(cmd);
clearSelection();
}
function castPowerRun() {
if (!selectedCard) return;
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
clearSelection();
}
function clickCell(cell: { x: number; y: number }) { function clickCell(cell: { x: number; y: number }) {
if (!view || !isYourTurn) return; if (!view || !isYourTurn) return;
if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen.
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "player", playerId: pendingCellFor },
params: { cell },
});
clearSelection();
return;
}
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell },
});
clearSelection();
return;
}
const me = view.players.find((p) => p.id === view.you)!; const me = view.players.find((p) => p.id === view.you)!;
// A cell click is a move if the cell is one legal step away. // A cell click is a move if the cell is one legal step away (the server
// also lets doors/walls pass when unlocked/misted — try the direction).
for (const side of SIDES) { for (const side of SIDES) {
const t = stepTarget(view.board, me.position, side); const t = stepTarget(view.board, me.position, side);
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) { if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
@@ -84,6 +149,16 @@
return; return;
} }
} }
// Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
// and pass-through-wall are resolved server-side.
for (const side of SIDES) {
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (cellKey(n) === cellKey(cell)) {
net.command({ type: "move", direction: side });
return;
}
}
} }
function clickEdge(cell: { x: number; y: number }, side: Side) { function clickEdge(cell: { x: number; y: number }, side: Side) {
@@ -107,15 +182,24 @@
} }
return; return;
} }
if (selectedCard.cardId === "teleport-opponent") {
pendingCellFor = playerId;
return; // next: click the destination cell
}
const cmd: Parameters<typeof net.command>[0] = { const cmd: Parameters<typeof net.command>[0] = {
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "player", playerId }, target: { kind: "player", playerId },
}; };
if (attachedNumber) cmd.numberInstanceId = attachedNumber.instanceId; if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
if (selectedCard.cardId === "waterbolt") { if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage }; cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
} }
if (NAMED_CARDS.has(selectedCard.cardId)) {
const id = nameToCardId(nameInput);
if (!id) return; // needs a card name typed first
cmd.params = { cardId: id };
}
net.command(cmd); net.command(cmd);
clearSelection(); clearSelection();
} }
@@ -245,6 +329,25 @@
<label>damage <input type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label> <label>damage <input type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
(knockback {numberTotal - wbDamage}) (knockback {numberTotal - wbDamage})
{/if} {/if}
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
— then click the target wizard
{/if}
{#if selectedCard?.cardId === "power-run"}
<label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label>
<button onclick={castPowerRun}>Run!</button>
{/if}
{#if selectedCard && SELF_CARDS.has(selectedCard.cardId)}
<button onclick={castSelfWithNumber}>
Cast{attachedNumber ? ` with the ${numberTotal}` : " (duration 1)"}
</button>
{/if}
{#if pendingCellFor}
— now click the destination square for {pendingCellFor}
{/if}
{#if selectedCard && CELL_CARDS.has(selectedCard.cardId)}
— click a square on the board
{/if}
<button class="link" onclick={clearSelection}>cancel</button> <button class="link" onclick={clearSelection}>cancel</button>
</div> </div>
{/if} {/if}
+33 -3
View File
@@ -101,19 +101,45 @@
{/if} {/if}
{/each} {/each}
<!-- walls & doors --> <!-- terrain: solid stone and thornbushes -->
{#each Object.entries(view.squareContents) as [key, content] (key)}
{@const sx = Number(key.split(",")[0])}
{@const sy = Number(key.split(",")[1])}
{#if content.kind === "stone"}
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
{:else}
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36} class="bush" />
{/if}
{/each}
<!-- ground objects -->
{#each Object.entries(view.groundObjects) as [key, objects] (key)}
{@const gx = Number(key.split(",")[0])}
{@const gy = Number(key.split(",")[1])}
{#each objects as o, i (o.instanceId)}
<rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object"
>
<title>{o.cardId}</title>
</rect>
{/each}
{/each}
<!-- walls & doors & firewalls -->
{#each edges as e (`${e.kind}:${e.x},${e.y}`)} {#each edges as e (`${e.kind}:${e.x},${e.y}`)}
{@const cls = e.state === "door" ? "door" : e.state === "firewall" ? "firewall" : "wall"}
{#if e.kind === "V"} {#if e.kind === "V"}
<rect <rect
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2} x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
width={WALL} height={CELL + WALL} width={WALL} height={CELL + WALL}
class={e.state === "door" ? "door" : "wall"} class={cls}
/> />
{:else} {:else}
<rect <rect
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2} x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
width={CELL + WALL} height={WALL} width={CELL + WALL} height={WALL}
class={e.state === "door" ? "door" : "wall"} class={cls}
/> />
{/if} {/if}
{/each} {/each}
@@ -178,6 +204,10 @@
.floor:hover { fill: #f2ecda; } .floor:hover { fill: #f2ecda; }
.wall { fill: #4a4438; } .wall { fill: #4a4438; }
.door { fill: #8b5a2b; } .door { fill: #8b5a2b; }
.firewall { fill: #e0442a; }
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; } .home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
.treasure { stroke: #111; stroke-width: 1.2; } .treasure { stroke: #111; stroke-width: 1.2; }
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; } .warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }