From 1924114b6db1cecb1e8c043c477faaf23c3ef95d Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 20:29:09 -0400 Subject: [PATCH] Final wave: complete the 6th edition basic set (69/69 cards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hard six: AROUND THE CORNER attaches to any LOS attack and bends the sight line through one intermediate cell. BLIND victims lurch in die-rolled directions (bumping a wall costs the movement point, per the card) and their attacks fly wherever the die says — hitting whoever stands that way, or dissipating. UGLY drives every opponent in sight fleeing along shortest paths (breadth-first, die-broken ties) until they cannot see the caster. ILLUSION WALL is per-player reality: each opponent rolls 50/50 the first time it matters and the wall is real for believers forever — believers' views render it as a wall, the caster and those who saw through it get a ghostly dashed line, and Dispel Creation banishes it. ROTATE SECTOR turns a sector 90 degrees with every wall, door, wizard, treasure, object, firewall and alteration turning in place (the home star, being the exact center, never moves); RELOCATE SECTOR slides a sector anywhere that keeps all sectors adjacent, reassembling the map and recomputing wraparounds. Client: modifier attachment (Amplify/Add/Extend/Around The Corner), two-stage relocate, rotation direction toggle, dashed known-illusions. Every card in the 6th edition basic deck is now implemented. 81 tests passing. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 459 ++++++++++++++++++++++++++- packages/engine/src/view.ts | 19 +- packages/engine/test/casting.test.ts | 2 +- packages/engine/test/wave5.test.ts | 295 +++++++++++++++++ packages/web/src/App.svelte | 65 +++- packages/web/src/Board.svelte | 13 + packages/web/src/net.svelte.ts | 12 + 7 files changed, 844 insertions(+), 21 deletions(-) create mode 100644 packages/engine/test/wave5.test.ts diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a4d20d9..f2ee21f 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -17,6 +17,7 @@ import { type EdgeState, type Side, SIDES, + assembleBoard, cellKey, edgeKey, hasLineOfSight, @@ -123,6 +124,7 @@ export interface CastParams { cell?: Cell; cardId?: string; points?: number; + clockwise?: boolean; } export interface GameConfig { @@ -149,6 +151,8 @@ export interface GameState { groundObjects: Record; /** The last spell card each player used (for REUSE SPELL). */ lastSpellUsed: Record; + /** ILLUSION WALLs by edge key: real only for those who believe. */ + illusionWalls: Record }>; players: PlayerState[]; treasures: TreasureState[]; sustained: SustainedEffect[]; @@ -180,22 +184,105 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { return hasLineOfSight(boardView(state), from, to, blockers); } +/** Parse an edge key back into its north/west cell and side. */ +function parseEdgeKey(key: string): { cell: Cell; side: Side } { + const [kind, coords] = key.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + return { cell: { x, y }, side: kind === "V" ? "E" : "S" }; +} + +/** + * What does this player believe about an illusion wall? Rolls the 50% chance + * lazily the first time it matters ("when they gain L.O.S. to it"). + */ +function illusionBelief( + state: GameState, + events: GameEvent[], + playerId: PlayerId, + key: string, +): "believes" | "seesThrough" { + const wall = state.illusionWalls[key]!; + if (wall.createdBy === playerId) return "seesThrough"; + const known = wall.belief[playerId]; + if (known) return known; + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + const result = roll <= 2 ? "seesThrough" : "believes"; + wall.belief[playerId] = result; + events.push({ type: "illusionTested", player: playerId, edge: key, result }); + return result; +} + +/** The board as one player perceives it: believed illusions become walls. */ +function perceivedBoard( + state: GameState, + events: GameEvent[], + viewerId: PlayerId, + sightLine?: { from: Cell; to: Cell }, +): AssembledBoard { + const view = boardView(state); + const keys = Object.keys(state.illusionWalls); + if (keys.length === 0) return view; + const edges = { ...view.edges }; + for (const key of keys) { + const known = state.illusionWalls[key]!.belief[viewerId]; + const isCreator = state.illusionWalls[key]!.createdBy === viewerId; + if (isCreator || known === "seesThrough") continue; + if (known === "believes") { + edges[key] = "wall"; + continue; + } + // Untested: only roll if this sight line would actually cross it. + if (sightLine) { + const { cell, side } = parseEdgeKey(key); + const test = { ...view, edges: { [key]: "wall" as const } }; + void cell; void side; + const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to); + if (crossesIt) { + if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall"; + } + } else { + edges[key] = "wall"; // no sight context: treat as real until tested + } + } + return { ...view, edges }; +} + /** * LOS for a caster: VISIONSTONE lets its holder see through exactly one - * wall or door (of any type), at their option. + * wall or door (of any type); believed ILLUSION WALLs block them. */ -function casterLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean { - if (gameLos(state, from, to)) return true; - if (!displays(caster, "visionstone")) return false; - // Try ignoring each single blocking edge in turn. - const view = boardView(state); +function casterLos( + state: GameState, + caster: PlayerState, + from: Cell, + to: Cell, + events: GameEvent[] = [], +): boolean { + const board = perceivedBoard(state, events, caster.id, { from, to }); const blockers: Record = {}; for (const key of Object.keys(state.squareContents)) blockers[key] = true; - for (const key of Object.keys(view.edges)) { - if ((view.edges[key] ?? "open") === "open") continue; - const edges = { ...view.edges }; + if (hasLineOfSight(board, from, to, blockers)) return true; + if (!displays(caster, "visionstone")) return false; + for (const key of Object.keys(board.edges)) { + if ((board.edges[key] ?? "open") === "open") continue; + const edges = { ...board.edges }; delete edges[key]; - if (hasLineOfSight({ ...view, edges }, from, to, blockers)) return true; + if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true; + } + return false; +} + +/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ +function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean { + if (casterLos(state, caster, from, to, events)) return true; + const view = boardView(state); + for (const key of Object.keys(view.cells)) { + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) { + return true; + } } return false; } @@ -271,6 +358,14 @@ export type GameEvent = | { 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: "castAroundCorner"; caster: PlayerId } + | { type: "moveBumped"; player: PlayerId; direction: Side } + | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } + | { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell } + | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } + | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } + | { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } + | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell } | { 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 } @@ -323,6 +418,8 @@ export type Command = addInstanceId?: string; /** EXTEND card attached (doubles duration). */ extendInstanceId?: string; + /** AROUND THE CORNER card attached (bends this cast's line of sight). */ + aroundCornerInstanceId?: string; target?: CastTarget; params?: CastParams; } @@ -961,6 +1058,12 @@ const CARD_EFFECTS: Record const view = boardView(state); if (cmd.target?.kind === "edge") { const key = edgeKey(cmd.target.cell, cmd.target.side); + if (state.illusionWalls[key]) { + if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight"; + delete state.illusionWalls[key]; + events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" }); + return null; + } 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]; @@ -1054,6 +1157,80 @@ const CARD_EFFECTS: Record attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000); }, }, + blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, + "around-the-corner": { + kind: "neutral", + // Never cast alone — attached to an attack via aroundCornerInstanceId. + resolve: () => "attach Around The Corner to an attack instead of casting it alone", + }, + ugly: { + kind: "neutral", + // "All opponents in L.O.S. retreat as far away as necessary to avoid + // L.O.S., along the shortest path available." + resolve: (state, events, caster) => { + for (const opp of state.players) { + if (!opp.alive || opp.id === caster.id) continue; + if (!gameLos(state, caster.position, opp.position)) continue; + if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue; + retreatFromSight(state, events, opp, caster.position); + } + return null; + }, + }, + "illusion-wall": { + kind: "neutral", + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge"; + const { cell, side } = cmd.target; + const view = boardView(state); + if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { + return "the illusion must span two spaces on the board"; + } + const key = edgeKey(cell, side); + if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line"; + if (state.illusionWalls[key]) return "an illusion already shimmers there"; + if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + state.illusionWalls[key] = { createdBy: caster.id, belief: {} }; + events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } }); + return null; + }, + }, + "rotate-sector": { + kind: "neutral", + // "Allows you to rotate any one sector 90 degrees." + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "cell") return "click a square in the sector to rotate"; + const idx = sectorIndexAt(state.board, cmd.target.cell); + if (idx === -1) return "that is not on a sector"; + rotateSector(state, idx, cmd.params?.clockwise ?? true); + events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true }); + return null; + }, + }, + "relocate-sector": { + kind: "neutral", + // "Relocate (but not rotate) any one sector to any other area, so long as + // all sectors are still adjacent to at least one other." + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "cell") return "click the destination area"; + const fromCell = cmd.params?.cell; + if (!fromCell) return "pick the sector to move first"; + const idx = sectorIndexAt(state.board, fromCell); + if (idx === -1) return "that is not on a sector"; + const dest: Cell = { + x: Math.floor(cmd.target.cell.x / 5) * 5, + y: Math.floor(cmd.target.cell.y / 5) * 5, + }; + const fromOrigin = { ...state.board.placements[idx]!.origin }; + const problem = relocateSector(state, idx, dest); + if (problem) return problem; + events.push({ + type: "sectorRelocated", caster: caster.id, sectorIndex: idx, + from: fromOrigin, to: dest, + }); + return null; + }, + }, "reuse-spell": { kind: "neutral", // "You may retrieve any spell you use immediately after you use it (but @@ -1142,6 +1319,150 @@ function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Si } } +/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */ +function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void { + const view = boardView(state); + const start = p.position; + const seen = new Set([cellKey(start)]); + let frontier: Cell[] = [start]; + const safeAt = (c: Cell) => !gameLos(state, horror, c); + for (let depth = 0; depth < 60 && frontier.length > 0; depth++) { + const safe = frontier.filter(safeAt); + if (safe.length > 0) { + // Multiple equally short refuges: the die decides. + let choice = safe[0]!; + if (safe.length > 1) { + const [i, rngNext] = nextInt(state.rng, safe.length); + state.rng = rngNext; + choice = safe[i]!; + } + const from = p.position; + p.position = choice; + events.push({ type: "retreatedInHorror", player: p.id, from, to: choice }); + return; + } + const next: Cell[] = []; + for (const c of frontier) { + for (const side of SIDES) { + const step = stepTarget(view, c, side); + if (step.kind === "blocked") continue; + if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; + if (seen.has(cellKey(step.to))) continue; + seen.add(cellKey(step.to)); + next.push(step.to); + } + } + frontier = next; + } + // Nowhere to hide: they cower where they stand. +} + +/** Which placement contains this cell? */ +function sectorIndexAt(board: AssembledBoard, cell: Cell): number { + return board.placements.findIndex( + (p) => cell.x >= p.origin.x && cell.x < p.origin.x + 5 && cell.y >= p.origin.y && cell.y < p.origin.y + 5, + ); +} + +/** Remap every piece of coordinate-keyed state through cell/side transforms. */ +function remapState( + state: GameState, + inSector: (c: Cell) => boolean, + mapCell: (c: Cell) => Cell, + mapSide: (s: Side) => Side, +): void { + const mapEdgeKey = (key: string): string => { + const { cell, side } = parseEdgeKey(key); + const other = neighbor(cell, side); + if (!inSector(cell) || !inSector(other)) return key; // boundary: leave + return edgeKey(mapCell(cell), mapSide(side)); + }; + const remapRecord = (rec: Record, mapKey: (k: string) => string): Record => + Object.fromEntries(Object.entries(rec).map(([k, v]) => [mapKey(k), v])); + const mapCellKey = (key: string): string => { + const [x, y] = key.split(",").map(Number) as [number, number]; + const c = { x, y }; + return inSector(c) ? cellKey(mapCell(c)) : key; + }; + + state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey); + state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey); + state.doorStates = remapRecord(state.doorStates, mapEdgeKey); + state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); + state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey); + state.squareContents = remapRecord(state.squareContents, mapCellKey); + state.groundObjects = remapRecord(state.groundObjects, mapCellKey); + for (const fx of state.sustained) { + if (fx.edge) fx.edge = mapEdgeKey(fx.edge); + } + for (const p of state.players) { + if (inSector(p.position)) p.position = mapCell(p.position); + if (inSector(p.home)) p.home = mapCell(p.home); + } + for (const t of state.treasures) { + if (t.position && inSector(t.position)) t.position = mapCell(t.position); + } +} + +/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ +function rotateSector(state: GameState, index: number, clockwise: boolean): void { + const placement = state.board.placements[index]!; + const { x: ox, y: oy } = placement.origin; + const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5; + const mapCell = (c: Cell): Cell => { + const lx = c.x - ox, ly = c.y - oy; + return clockwise + ? { x: ox + (4 - ly), y: oy + lx } + : { x: ox + ly, y: oy + (4 - lx) }; + }; + const order: Side[] = ["N", "E", "S", "W"]; + const mapSide = (s: Side): Side => order[(order.indexOf(s) + (clockwise ? 1 : 3)) % 4]!; + + const newPlacements = state.board.placements.map((p, i) => + i === index + ? { ...p, rotation: (((p.rotation + (clockwise ? 90 : 270)) % 360) as 0 | 90 | 180 | 270) } + : p, + ); + const oldWarps = state.board.warps; + const rebuilt = assembleBoard(newPlacements); + rebuilt.warps = oldWarps; // openings are centered: rotation never moves them + state.board = rebuilt; + remapState(state, inSector, mapCell, mapSide); +} + +/** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */ +function relocateSector(state: GameState, index: number, dest: Cell): string | null { + const placements = state.board.placements; + const current = placements[index]!.origin; + if (dest.x === current.x && dest.y === current.y) return "the sector is already there"; + if (dest.x < 0 || dest.y < 0) return "the sector cannot go there"; + for (let i = 0; i < placements.length; i++) { + if (i === index) continue; + const o = placements[i]!.origin; + if (o.x === dest.x && o.y === dest.y) return "another sector is there"; + } + // "All sectors still adjacent to at least one other." + const origins = placements.map((p, i) => (i === index ? dest : p.origin)); + const adjacent = (a: Cell, b: Cell) => + (Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x); + for (let i = 0; i < origins.length; i++) { + if (!origins.some((o, j) => j !== i && adjacent(origins[i]!, o))) { + return "every sector must stay adjacent to at least one other"; + } + } + + const { x: ox, y: oy } = current; + const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5; + const dx = dest.x - ox, dy = dest.y - oy; + const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy }); + + const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p)); + // "Only opposite board edges connect" after a relocation: default pairings. + state.board = assembleBoard(newPlacements); + remapState(state, inSector, mapCell, (s) => s); + return null; +} + /** DRAG a player straight toward the caster, stopping at walls. */ function dragToward(state: GameState, target: PlayerState, dest: Cell): void { const view = boardView(state); @@ -1334,6 +1655,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game squareContents: {}, groundObjects: {}, lastSpellUsed: {}, + illusionWalls: {}, players, treasures, sustained: [], @@ -1443,9 +1765,32 @@ function doMove(prev: GameState, direction: Side): CommandResult { const state = clone(prev); const p = activePlayer(state); + const events: GameEvent[] = []; + + // BLIND: "must roll direction on D4 if attempting to move ... bumping into + // a wall counts as one space of movement. Reroll for each movement point." + if (sustainedOn(state, p.id, "blind").length > 0) { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + direction = SIDES[roll - 1]!; + } + + // A believed (or untested, when bumped) ILLUSION WALL blocks the believer. + { + const key = edgeKey(p.position, direction); + if (state.illusionWalls[key] && + illusionBelief(state, events, p.id, key) === "believes") { + if (sustainedOn(state, p.id, "blind").length > 0) { + state.turn.movementUsed++; + events.push({ type: "moveBumped", player: p.id, direction }); + return { ok: true, state, events }; + } + return err("blocked by wall"); + } + } + const view = boardView(state); const target = stepTarget(view, p.position, direction); - const events: GameEvent[] = []; const misted = isMisted(state, p.id); const from = p.position; @@ -1455,7 +1800,14 @@ function doMove(prev: GameState, direction: Side): CommandResult { const key = edgeKey(p.position, direction); const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); - if (!view.cells[cellKey(dest)]) return err("blocked"); + if (!view.cells[cellKey(dest)]) { + if (sustainedOn(state, p.id, "blind").length > 0) { + state.turn.movementUsed++; + events.push({ type: "moveBumped", player: p.id, direction }); + return { ok: true, state, events }; + } + return err("blocked"); + } if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) { p.position = dest; via = "step"; @@ -1473,6 +1825,11 @@ function doMove(prev: GameState, direction: Side): CommandResult { p.passWallCharges--; p.position = dest; via = "passWall"; + } else if (sustainedOn(state, p.id, "blind").length > 0) { + // Blind bump: the wasted lurch costs a movement point. + state.turn.movementUsed++; + events.push({ type: "moveBumped", player: p.id, direction }); + return { ok: true, state, events }; } else { return err(`blocked by ${target.by}`); } @@ -1613,6 +1970,7 @@ interface CastConsumables { amplifies: CardInstance[]; add: CardInstance | null; extend: CardInstance | null; + aroundCorner: CardInstance | null; magnitude: Magnitude; } @@ -1661,6 +2019,13 @@ function gatherModifiers( extend = c; } + let aroundCorner: CardInstance | null = null; + if (cmd.aroundCornerInstanceId) { + const c = find(cmd.aroundCornerInstanceId); + if (!c || c.cardId !== "around-the-corner") return "AROUND THE CORNER card not in hand"; + aroundCorner = c; + } + // POWERSTONE: "Add 1 to any NUMBER card played." const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0; const sum = numbers.length > 0 @@ -1673,6 +2038,7 @@ function gatherModifiers( amplifies, add, extend, + aroundCorner, magnitude: { numberValue: sum, power: (sum ?? 1) * amp, @@ -1694,7 +2060,7 @@ function consumeCast( takeFromHand(caster, card.instanceId); state.discard.push(card); } - for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend]) { + for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner]) { if (!c) continue; takeFromHand(caster, c.instanceId); state.discard.push(c); @@ -1745,8 +2111,12 @@ function doCast(prev: GameState, cmd: Extract): Comma } const statusBlock = attackBlockedByStatus(state, caster, target); if (statusBlock) return err(statusBlock); - if (effect.requiresLos && !casterLos(state, caster, caster.position, target.position)) { - return err("no line of sight to the target"); + const preEvents: GameEvent[] = []; + if (effect.requiresLos) { + const sighted = mods.aroundCorner + ? bentLos(state, caster, caster.position, target.position, preEvents) + : casterLos(state, caster, caster.position, target.position, preEvents); + if (!sighted) return err("no line of sight to the target"); } // Attacking someone breaks any BUDDY pact you swore to them. state.sustained = state.sustained.filter( @@ -1765,6 +2135,59 @@ function doCast(prev: GameState, cmd: Extract): Comma } } + // BLIND: casts at others fly in a rolled direction. "Misdirected spells + // go intended distance" — if the die disagrees with the true direction, + // the spell hits whoever lies that way, or dissipates. + let actualTarget = target; + if (sustainedOn(state, caster.id, "blind").length > 0 && + cellKey(target.position) !== cellKey(caster.position)) { + const dx = target.position.x - caster.position.x; + const dy = target.position.y - caster.position.y; + const intended: Side = + Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + const rolled = SIDES[roll - 1]!; + if (rolled !== intended) { + const along = state.players.find((p) => { + if (!p.alive || p.id === caster.id) return false; + const px = p.position.x - caster.position.x; + const py = p.position.y - caster.position.y; + const dirOf: Side | null = + Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null; + return dirOf === rolled && casterLos(state, caster, caster.position, p.position); + }); + consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); + state.turn.attackUsed = true; + state.lastSpellUsed[caster.id] = inHand.cardId; + const missEvents: GameEvent[] = [...preEvents, { + type: "attackMisdirected", attacker: caster.id, intended: target.id, + rolledDirection: rolled, newTarget: along?.id ?? null, + }]; + if (!along) return { ok: true, state, events: missEvents }; // dissipates + actualTarget = along; + state.stack = { + attackerId: caster.id, + defenderId: along.id, + attackCard: inHand, + numberValue: mods.magnitude.numberValue, + amplifyFactor: 2 ** mods.amplifies.length, + extendFactor: mods.extend ? 2 : 1, + params: cmd.params ?? null, + kind: effect.physical ? "physical" : "spell", + counters: [], + waitingOn: along.id, + }; + missEvents.push({ + type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, + numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, + from: caster.position, target: along.id, targetCell: along.position, + }); + return { ok: true, state, events: missEvents }; + } + } + void actualTarget; + consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); state.turn.attackUsed = true; state.stack = { @@ -1780,7 +2203,9 @@ function doCast(prev: GameState, cmd: Extract): Comma waitingOn: target.id, }; state.lastSpellUsed[caster.id] = inHand.cardId; - const events: GameEvent[] = [{ + const events: GameEvent[] = [...preEvents]; + if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id }); + events.push({ type: "spellCast", caster: caster.id, card: inHand, @@ -1790,7 +2215,7 @@ function doCast(prev: GameState, cmd: Extract): Comma from: caster.position, target: target.id, targetCell: target.position, - }]; + }); if (effect.keepInHand) { events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 59aa9f0..4a99edc 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -50,17 +50,33 @@ export interface GameView { groundObjects: Record; doorStates: Record; openDoorEdges: string[]; + /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ + knownIllusionEdges: string[]; } export function viewFor(state: GameState, playerId: PlayerId): GameView { const you = state.players.find((p) => p.id === playerId); + // Illusion walls render as real walls unless this viewer knows better. + const base = boardView(state); + const knownIllusionEdges: string[] = []; + let edges = base.edges; + for (const [key, wall] of Object.entries(state.illusionWalls)) { + const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough"; + if (knows) { + knownIllusionEdges.push(key); + } else { + if (edges === base.edges) edges = { ...base.edges }; + edges[key] = "wall"; + } + } + const board = edges === base.edges ? base : { ...base, edges }; return { you: playerId, phase: state.phase, winner: state.winner, turn: state.turn, activePlayerId: state.players[state.turn.activeIndex]!.id, - board: boardView(state), + board, players: state.players.map((p) => ({ id: p.id, position: p.position, @@ -86,5 +102,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { ), doorStates: { ...state.doorStates }, openDoorEdges: [...state.openDoorEdges], + knownIllusionEdges, }; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 25336f2..1ba92d1 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -292,7 +292,7 @@ describe("stack discipline", () => { it("unimplemented cards refuse to cast with a clear error", () => { let { state } = newGame(); const caster = activePlayer(state); - const card = giveCard(state, caster.id, "ugly"); + const card = giveCard(state, caster.id, "chaos"); // expansion1, unimplemented const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toMatch(/not implemented/); diff --git a/packages/engine/test/wave5.test.ts b/packages/engine/test/wave5.test.ts new file mode 100644 index 0000000..d57ba98 --- /dev/null +++ b/packages/engine/test/wave5.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + boardView, + gameLos, + sustainedOn, + viewFor, + type Command, + type GameState, + type PlayerId, +} from "../src"; +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 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("around the corner", () => { + it("bends line of sight past a wall that blocks a straight cast", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + // Find a hidden cell (no direct LOS) that IS reachable with one bend. + const view = boardView(state); + let hidden: Cell | null = null; + outer: for (const key of Object.keys(view.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + const cell = { x, y }; + if (gameLos(state, attacker.position, cell)) continue; + for (const midKey of Object.keys(view.cells)) { + const [mx, my] = midKey.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if (gameLos(state, attacker.position, mid) && gameLos(state, mid, cell)) { + hidden = cell; + break outer; + } + } + } + expect(hidden).not.toBeNull(); + defender.position = hidden!; + + const fb = giveCard(state, attacker.id, "fireball", "F", 0); + giveCard(state, attacker.id, "around-the-corner", "ATC", 1); + // Straight cast: refused. + const straight = applyCommand(state, attacker.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, + }); + expect(straight.ok).toBe(false); + // Bent cast: lands. + state = must(state, attacker.id, { + type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC", + target: { kind: "player", playerId: defender.id }, + }); + state = must(state, defender.id, { type: "pass" }); + expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10); + }); +}); + +describe("blind", () => { + it("blinded movement lurches in rolled directions and bumps cost movement", () => { + let { state } = newGame(7); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + const bl = giveCard(state, attacker.id, "blind"); + giveCard(state, attacker.id, "number-3", "N", 1); + state = must(state, attacker.id, { + type: "cast", instanceId: bl.instanceId, numberInstanceIds: ["number-3#N"], + target: { kind: "player", playerId: defender.id }, + }); + state = must(state, defender.id, { type: "pass" }); + expect(sustainedOn(state, defender.id, "blind").length).toBe(1); + state = must(state, attacker.id, { type: "endTurn", draw: 0 }); + + // Every move consumes exactly one movement point whether it lands or bumps. + const before = state.turn.movementUsed; + state = must(state, defender.id, { type: "move", direction: "N" }); + expect(state.turn.movementUsed).toBe(before + 1); + }); + + it("blinded casts fly in a rolled direction and can miss entirely", () => { + // Across seeds: a blinded caster aiming at a real target sometimes hits + // (roll matches), usually misses (attack dissipates, card still spent). + let hits = 0, misses = 0; + for (let seed = 1; seed <= 10; seed++) { + let { state } = newGame(seed); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + // Stand the defender one cell east-ish with LOS. + const spot = emptyNeighborCell(state, attacker.position); + defender.position = spot.cell; + state.sustained.push({ + id: "fx-test", cardId: "blind", casterId: defender.id, + targetId: attacker.id, remainingTurns: 3, data: {}, + }); + const fb = giveCard(state, attacker.id, "fireball", "F", 0); + const result = applyCommand(state, attacker.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, + }); + expect(result.ok).toBe(true); + if (!result.ok) continue; + const dLife = result.state.players.find((p) => p.id === defender.id)!; + if (result.state.stack) { + hits++; // the roll matched: attack proceeds normally + } else { + misses++; + expect(result.state.turn.attackUsed).toBe(true); // card spent anyway + } + void dLife; + } + expect(hits + misses).toBe(10); + expect(misses).toBeGreaterThan(0); + }); +}); + +describe("ugly", () => { + it("drives every visible opponent out of line of sight", () => { + let { state } = newGame(); + const caster = activePlayer(state); + const opp = state.players.find((p) => p.id !== caster.id)!; + opp.position = { ...caster.position }; // same square: definitely in LOS + const ug = giveCard(state, caster.id, "ugly"); + state = must(state, caster.id, { type: "cast", instanceId: ug.instanceId }); + const after = state.players.find((p) => p.id === opp.id)!; + expect(gameLos(state, state.players.find((p) => p.id === caster.id)!.position, after.position)).toBe(false); + }); +}); + +describe("illusion wall", () => { + function setupIllusion(seed = 42) { + let { state } = newGame(seed); + const caster = activePlayer(state); + const spot = emptyNeighborCell(state, caster.position); + const key = edgeKey(caster.position, spot.side); + const iw = giveCard(state, caster.id, "illusion-wall"); + state = must(state, caster.id, { + type: "cast", instanceId: iw.instanceId, + target: { kind: "edge", cell: caster.position, side: spot.side }, + }); + return { state, caster: caster.id, key, side: spot.side, cell: spot.cell }; + } + + it("the caster walks through their own illusion; others see a wall", () => { + const { state, caster, key, side } = setupIllusion(); + // Caster's view knows it's fake; the opponent's view shows a wall. + const casterView = viewFor(state, caster); + expect(casterView.knownIllusionEdges).toContain(key); + const other = state.players.find((p) => p.id !== caster)!.id; + const otherView = viewFor(state, other); + expect(otherView.board.edges[key]).toBe("wall"); + // And the caster can move through it freely. + const after = applyCommand(state, caster, { type: "move", direction: side }); + expect(after.ok).toBe(true); + }); + + it("opponents test the illusion when they bump it — some see through, some believe", () => { + let believed = 0, sawThrough = 0; + for (let seed = 1; seed <= 12; seed++) { + const { state, caster, side } = setupIllusion(seed); + const other = state.players.find((p) => p.id !== caster)!; + other.position = { ...state.players.find((p) => p.id === caster)!.position }; + let s = must(state, caster, { type: "endTurn", draw: 0 }); + const result = applyCommand(s, other.id, { type: "move", direction: side }); + if (result.ok) sawThrough++; + else believed++; + } + expect(believed + sawThrough).toBe(12); + expect(believed).toBeGreaterThan(0); + expect(sawThrough).toBeGreaterThan(0); + }); + + it("dispel creation removes an illusion wall", () => { + let { state, caster, key, side } = setupIllusion(); + const me = state.players.find((p) => p.id === caster)!; + const dc = giveCard(state, caster, "dispel-creation", "DC", 1); + state = must(state, caster, { + type: "cast", instanceId: dc.instanceId, + target: { kind: "edge", cell: me.position, side }, + }); + expect(state.illusionWalls[key]).toBeUndefined(); + }); +}); + +describe("sector manipulation", () => { + it("rotate sector turns walls and pieces together; the home stays centered", () => { + let { state } = newGame(); + const me = activePlayer(state); + const idx = state.board.placements.findIndex( + (p) => me.position.x >= p.origin.x && me.position.x < p.origin.x + 5 && + me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, + ); + const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length; + const homeBefore = { ...me.home }; + const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! })); + + const rs = giveCard(state, me.id, "rotate-sector"); + state = must(state, me.id, { + type: "cast", instanceId: rs.instanceId, + target: { kind: "cell", cell: me.position }, params: { clockwise: true }, + }); + const after = state.players.find((p) => p.id === me.id)!; + // Home star is the exact center: rotation cannot move it. + expect(cellKey(after.home)).toBe(cellKey(homeBefore)); + // The wizard stood on the home (center) at setup? They may have been + // anywhere; either way they remain inside the same sector. + const p = state.board.placements[idx]!; + expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x); + expect(after.position.x).toBeLessThan(p.origin.x + 5); + // Wall count is invariant under rotation. + const wallsAfter = Object.values(boardView(state).edges).filter((e) => e === "wall").length; + expect(wallsAfter).toBe(wallsBefore); + // Treasures rotated with the sector (diagonal flips to the other diagonal + // or stays, but they remain on the board inside the sector). + for (const t of state.treasures.filter((t) => t.owner === after.id)) { + expect(state.board.cells[cellKey(t.position!)]).toBe(true); + } + void treasuresBefore; + }); + + it("relocate sector slides everything and keeps adjacency", () => { + let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5 + const me = activePlayer(state); + const idx = state.board.placements.findIndex( + (p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, + ); + const myOrigin = state.board.placements[idx]!.origin; + const otherIdx = idx === 0 ? 1 : 0; + const otherOrigin = state.board.placements[otherIdx]!.origin; + + // Move my sector to the EAST side of the other sector (still adjacent). + const dest = { x: otherOrigin.x + 5, y: otherOrigin.y }; + const posBefore = { ...me.position }; + const rel = giveCard(state, me.id, "relocate-sector"); + state = must(state, me.id, { + type: "cast", instanceId: rel.instanceId, + target: { kind: "cell", cell: dest }, params: { cell: me.position }, + }); + const after = state.players.find((p) => p.id === me.id)!; + const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y; + expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy }); + expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y })); + expect(state.board.placements[idx]!.origin).toEqual(dest); + // The map reassembled: every treasure/wizard cell exists on the new board. + for (const t of state.treasures) { + if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true); + } + + // An island move is refused. + const rel2 = giveCard(state, me.id, "relocate-sector", "R2"); + const refused = applyCommand(state, me.id, { + type: "cast", instanceId: rel2.instanceId, + target: { kind: "cell", cell: { x: 40, y: 40 } }, params: { cell: after.position }, + }); + expect(refused.ok).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 65535d6..0e544eb 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -22,6 +22,12 @@ let nameInput = $state(""); /** power-run points. */ let runPoints = $state(1); + /** Modifier cards attached to the pending cast. */ + let attachedMods = $state([]); + /** relocate-sector: the sector picked up, awaiting its destination. */ + let pendingSectorFrom = $state<{ x: number; y: number } | null>(null); + /** rotate-sector direction. */ + let rotateCW = $state(true); /** Cards marked for discard. */ let discardSelection = $state>(new Set()); @@ -37,9 +43,11 @@ ]); const CELL_CARDS = new Set([ "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag", + "rotate-sector", "relocate-sector", ]); + const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); const SELF_CARDS = new Set([ - "speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell", + "invisible", "shrink", "mist-body", ]); const NAMED_CARDS = new Set(["card-erasure", "drop-object"]); const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); @@ -51,11 +59,23 @@ function clearSelection() { selectedCard = null; attachedNumber = null; + attachedMods = []; wbDamage = 0; pendingCellFor = null; + pendingSectorFrom = null; nameInput = ""; } + /** Fill the modifier fields of a cast command from the attachments. */ + function applyMods(cmd: Parameters[0] & { type: "cast" }) { + for (const m of attachedMods) { + if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId); + else if (m.cardId === "add") cmd.addInstanceId = m.instanceId; + else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId; + else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId; + } + } + /** Map a typed card name to its id (case-insensitive). */ function nameToCardId(name: string): string | null { const wanted = name.trim().toLowerCase(); @@ -85,6 +105,12 @@ wbDamage = numberTotal; return; } + if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) { + attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId) + ? attachedMods.filter((m) => m.instanceId !== card.instanceId) + : [...attachedMods, card]; + return; + } if (selectedCard?.instanceId === card.instanceId) { clearSelection(); return; @@ -100,7 +126,7 @@ // Instant untargeted spells (and stone displays) cast immediately; // duration self-spells wait so a number card can be attached. const INSTANT = new Set([ - "speed", "pass-through-wall", "reuse-spell", + "speed", "pass-through-wall", "reuse-spell", "ugly", "bloodstone", "brainstone", "powerstone", "shadowstone", "shieldstone", "soulstone", "speedstone", "visionstone", ]); @@ -136,6 +162,26 @@ clearSelection(); return; } + if (selectedCard?.cardId === "relocate-sector") { + if (!pendingSectorFrom) { + pendingSectorFrom = cell; // first click: the sector to move + return; + } + net.command({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "cell", cell }, params: { cell: pendingSectorFrom }, + }); + clearSelection(); + return; + } + if (selectedCard?.cardId === "rotate-sector") { + net.command({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "cell", cell }, params: { clockwise: rotateCW }, + }); + clearSelection(); + return; + } if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) { net.command({ type: "cast", instanceId: selectedCard.instanceId, @@ -197,6 +243,7 @@ target: { kind: "player", playerId }, }; if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId]; + applyMods(cmd); if (selectedCard.cardId === "waterbolt") { cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage }; } @@ -338,6 +385,20 @@ — then click the target wizard {/if} + {#if attachedMods.length > 0} + [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}] + {/if} + {#if selectedCard?.cardId === "rotate-sector"} + + — click a square in the sector to rotate + {/if} + {#if selectedCard?.cardId === "relocate-sector"} + {#if pendingSectorFrom} + — now click the destination area + {:else} + — click a square in the sector to move + {/if} + {/if} {#if selectedCard?.cardId === "power-run"} diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 7c3361f..78e2aab 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -144,6 +144,18 @@ {/if} {/each} + + {#each view.knownIllusionEdges as key (key)} + {@const kind = key.split(":")[0]} + {@const ix = Number(key.split(":")[1]?.split(",")[0])} + {@const iy = Number(key.split(":")[1]?.split(",")[1])} + {#if kind === "V"} + + {:else} + + {/if} + {/each} + {#each view.board.warps as w, i (i)}