From 2d88b4ab40d0d684198e528f214ec21624561af9 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 20:13:14 -0400 Subject: [PATCH] Card wave 3: terrain, thrown objects, drag, and control spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/board.ts | 35 +- packages/engine/src/game.ts | 517 ++++++++++++++++++++++++++++- packages/engine/src/view.ts | 17 + packages/engine/test/wave3.test.ts | 334 +++++++++++++++++++ packages/web/src/App.svelte | 117 ++++++- packages/web/src/Board.svelte | 36 +- 6 files changed, 1029 insertions(+), 27 deletions(-) create mode 100644 packages/engine/test/wave3.test.ts diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index edf4367..6510000 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -8,7 +8,7 @@ import boardsData from "../data/boards.json"; export type Cell = { readonly x: number; readonly y: number }; 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 interface SectorPlacement { @@ -252,17 +252,40 @@ export function stepTarget( /** * 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 - * exactly through a corner adjacent to a wall) counts as blocked — strict - * reading; revisit against FAQ rulings if needed. LOS through wraparound - * openings is not yet modeled (TODO). + * wall/door/firewall edges the segment crosses and by any `blockedCells` + * (solid stone, thornbushes) it passes through. Grazing a wall endpoint + * (passing exactly through a corner adjacent to a wall) counts as blocked — + * strict reading; revisit against FAQ rulings if needed. LOS through + * wraparound openings is not yet modeled (TODO). */ -export function hasLineOfSight(board: AssembledBoard, from: Cell, to: Cell): boolean { +export function hasLineOfSight( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): boolean { if (cellKey(from) === cellKey(to)) return true; // Centers of cells: (x + 0.5, 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; + 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)) { if (state === "open") continue; // Reconstruct the wall segment for this edge. diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6860c20..1b70f35 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -76,6 +76,16 @@ export interface SustainedEffect { remainingTurns: number; /** Per-card scratch (e.g. SLOW's turn parity counter). */ data: Record; + /** 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 { @@ -131,6 +141,14 @@ export interface GameState { doorStates: Record; /** Door edges unlocked until the end of the current turn. */ openDoorEdges: string[]; + /** Walls/firewalls conjured during play (dispellable), by edge key. */ + createdEdges: Record; + /** Square-filling creations, by cell key. */ + squareContents: Record; + /** Object cards lying on the floor, by cell key. */ + groundObjects: Record; + /** The last spell card each player used (for REUSE SPELL). */ + lastSpellUsed: Record; players: PlayerState[]; treasures: TreasureState[]; 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)); } +/** LOS including square-filling blockers (stone, thornbushes). */ +export function gameLos(state: GameState, from: Cell, to: Cell): boolean { + const blockers: Record = {}; + 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 @@ -191,6 +228,19 @@ export type GameEvent = | { type: "handRevealed"; player: PlayerId; to: PlayerId } | { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] } | { 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: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorsRelocked"; count: number } @@ -249,6 +299,8 @@ export type Command = | { type: "counteract"; instanceId: string } | { type: "pass" } | { type: "pickUpTreasure" } + | { type: "pickUpObject"; instanceId: string } + | { type: "dropObject"; instanceId: string } | { type: "dropTreasure" } | { type: "discard"; instanceIds: string[] } | { type: "endTurn"; draw: number }; @@ -263,6 +315,8 @@ export type CommandResult = type AttackEffect = { kind: "attack"; requiresLos?: boolean; + /** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */ + physical?: boolean; /** Attacker must share the target's square (WIZARDBLADE). */ sameSquare?: boolean; baseDamage: (numberValue: number | null, params: CastParams | null) => number; @@ -416,10 +470,12 @@ const CARD_EFFECTS: Record const cell = cmd.params?.cell; if (!cell) return "teleport opponent needs a destination cell"; 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; }, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; + if (isLockedInPlace(ctx.state, ctx.defender.id)) return; const to = ctx.stack.params!.cell!; const from = ctx.defender.position; ctx.defender.position = to; @@ -435,6 +491,7 @@ const CARD_EFFECTS: Record baseDamage: () => 0, onResolved: (ctx) => { 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; ctx.attacker.position = ctx.defender.position; ctx.defender.position = a; @@ -561,6 +618,7 @@ const CARD_EFFECTS: Record 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"; state.edgeOverrides[key] = "wall"; + state.createdEdges[key] = true; events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); return null; }, @@ -577,6 +635,7 @@ const CARD_EFFECTS: Record if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall"; state.edgeOverrides[key] = "open"; delete state.doorStates[key]; + delete state.createdEdges[key]; events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" }); for (const c of [cell, neighbor(cell, side)]) { for (const p of state.players) { @@ -643,9 +702,11 @@ const CARD_EFFECTS: Record // ... your movement ends after you play it." resolve: (state, events, caster, cmd) => { 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 view = boardView(state); 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) { return "teleport reaches at most four spaces"; } @@ -700,8 +761,328 @@ const CARD_EFFECTS: Record 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, + 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 @@ -873,6 +1254,10 @@ export function createGame(config: GameConfig): { state: GameState; events: Game edgeOverrides: {}, doorStates: {}, openDoorEdges: [], + createdEdges: {}, + squareContents: {}, + groundObjects: {}, + lastSpellUsed: {}, players, treasures, sustained: [], @@ -937,6 +1322,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm case "counteract": return err("nothing to counteract"); case "pass": return err("nothing to pass on"); 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 "discard": return doDiscard(state, playerId, command.instanceIds); 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"); const mover = activePlayer(prev); 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 p = activePlayer(state); const view = boardView(state); const target = stepTarget(view, p.position, direction); + const events: GameEvent[] = []; + const misted = isMisted(state, p.id); const from = p.position; let via: "step" | "warp" | "passWall"; + let crossedFirewall = false; if (target.kind === "blocked") { const key = edgeKey(p.position, direction); const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); - // A locked door that has been unlocked or de-locked is passable. + if (!view.cells[cellKey(dest)]) return err("blocked"); if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) { - if (!view.cells[cellKey(dest)]) return err("blocked"); p.position = dest; via = "step"; - } else if (edge === "wall" && p.passWallCharges > 0 && view.cells[cellKey(dest)]) { - // PASS THROUGH WALL: one charge, one wall. + } else if (edge === "firewall") { + // "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.position = dest; via = "passWall"; @@ -1006,12 +1405,30 @@ function doMove(prev: GameState, direction: Side): CommandResult { 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++; - return { - ok: true, - state, - events: [{ type: "moved", player: p.id, from, to: p.position, direction, via }], - }; + events.push({ type: "moved", player: p.id, from, to: p.position, direction, via }); + + if (crossedFirewall) { + 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 { @@ -1063,6 +1480,20 @@ function castingBlocked(state: GameState, playerId: PlayerId): string | 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 { const pre = attackPreconditions(prev); if (pre) return err(pre); @@ -1075,6 +1506,11 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult { if (cellKey(target.position) !== cellKey(attacker.position)) { 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.stack = { @@ -1224,9 +1660,15 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { 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"); } + // 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) { const problem = effect.validate(state, cmd); if (problem) return err(problem); @@ -1250,10 +1692,11 @@ function doCast(prev: GameState, cmd: Extract): Comma amplifyFactor: 2 ** mods.amplifies.length, extendFactor: mods.extend ? 2 : 1, params: cmd.params ?? null, - kind: "spell", + kind: effect.physical ? "physical" : "spell", counters: [], waitingOn: target.id, }; + state.lastSpellUsed[caster.id] = inHand.cardId; const events: GameEvent[] = [{ type: "spellCast", caster: caster.id, @@ -1293,6 +1736,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.keepInHand) { 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); if (result) return err(result); // unreachable after preview return { ok: true, state, events }; @@ -1512,13 +1958,17 @@ function knockBack( } const from = defender.position; + if (isLockedInPlace(state, defender.id)) return; let moved = 0; const view = boardView(state); for (let i = 0; i < squares; i++) { const step = stepTarget(view, defender.position, dir); if (step.kind === "blocked") break; + const content = state.squareContents[cellKey(step.to)]; + if (content?.kind === "stone") break; defender.position = step.to; moved++; + if (content?.kind === "thornbush") break; // tangled in the thorns } if (moved > 0) { 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 { const state = clone(prev); const p = activePlayer(state); @@ -1708,6 +2197,12 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi s.remainingTurns--; if (s.remainingTurns <= 0) { 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; } } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index d5e08de..59aa9f0 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -9,6 +9,8 @@ import { type CastStack, type GameState, type PlayerId, + type SquareContent, + type SustainedEffect, type TreasureState, type TurnState, } from "./game"; @@ -23,6 +25,7 @@ export interface PlayerPublicView { carriedTreasureId: string | null; lostTurns: number; extraTurns: number; + displayed: CardInstance[]; } export interface GameView { @@ -41,6 +44,12 @@ export interface GameView { /** Cards on the stack are face-up: the whole exchange is public. */ stack: CastStack | null; pendingDiscard: PlayerId | null; + /** Duration spells in play (public knowledge). */ + sustained: SustainedEffect[]; + squareContents: Record; + groundObjects: Record; + doorStates: Record; + openDoorEdges: string[]; } export function viewFor(state: GameState, playerId: PlayerId): GameView { @@ -62,6 +71,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { carriedTreasureId: p.carriedTreasureId, lostTurns: p.lostTurns, extraTurns: p.extraTurns, + displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)), })), yourHand: you ? [...you.hand] : [], treasures: state.treasures.map((t) => ({ ...t })), @@ -69,5 +79,12 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { discardCount: state.discard.length, stack: state.stack, 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], }; } diff --git a/packages/engine/test/wave3.test.ts b/packages/engine/test/wave3.test.ts new file mode 100644 index 0000000..7d4fba2 --- /dev/null +++ b/packages/engine/test/wave3.test.ts @@ -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> = {}, +): 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); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 60242ca..0edd02e 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,7 +1,7 @@