diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index 78cda55..96fe91c 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -480,3 +480,34 @@ export function sightBetween( return hasLineOfSight(board, from, to, blockedCells) || hasWarpLineOfSight(board, from, to, blockedCells); } + +/** + * AROUND THE CORNER's full reach: "around one corner (up to 180 degrees)". + * A hairpin around the end of a wall pivots IN the wall's gap, not at any + * cell's center — so try the midpoint of every open edge as the bend point, + * with a clear straight segment from caster to pivot and pivot to target. + */ +export function bentSightThroughGap( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): boolean { + const fx = from.x + 0.5, fy = from.y + 0.5; + const tx = to.x + 0.5, ty = to.y + 0.5; + const skip = [cellKey(from), cellKey(to)]; + for (const key of Object.keys(board.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + const c = { x, y }; + for (const side of ["E", "S"] as Side[]) { + if (!board.cells[cellKey(neighbor(c, side))]) continue; + if (edgeState(board, c, side) !== "open") continue; + const [px, py] = side === "E" ? [x + 1, y + 0.5] : [x + 0.5, y + 1]; + if (segmentClear(board, fx, fy, px, py, blockedCells, skip) && + segmentClear(board, px, py, tx, ty, blockedCells, skip)) { + return true; + } + } + } + return false; +} diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index d2e6dca..b274f72 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -24,6 +24,7 @@ import { type Warp, hasLineOfSight, sightBetween, + bentSightThroughGap, neighbor, stepTarget, opposite, @@ -483,8 +484,10 @@ function casterLos( return sightThroughOneEdge(board, from, to, blockers); } -/** 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 { +/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees + * the target — or, for the full 180-degree hairpin, the sight line doubles + * back around a wall's end, pivoting in the open gap itself. */ +function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean { if (casterLos(state, caster, from, to)) return true; const view = boardView(state); for (const key of Object.keys(view.cells)) { @@ -494,6 +497,41 @@ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, ev return true; } } + const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id))); + return bentSightThroughGap(board, from, to, losBlockers(state)); +} + +/** Sight for a square-aimed cast. "You may cast any line of sight spell + * around one corner (up to 180 degrees)" — the attachment bends every + * L.O.S. spell, creations and utilities included, not just attacks. */ +function castSight( + state: GameState, + caster: PlayerState, + cmd: Extract, + to: Cell, +): boolean { + if (gameLos(state, caster.position, to, caster.id)) return true; + return !!cmd.aroundCornerInstanceId && bentLos(state, caster, caster.position, to); +} + +/** Sight for an edge-aimed cast (walls, doors), bend-aware like castSight. */ +function castSightEdge( + state: GameState, + caster: PlayerState, + cmd: Extract, + board: AssembledBoard, + cell: Cell, + side: Side, +): boolean { + if (losToEdge(board, caster.position, cell, side)) return true; + if (!cmd.aroundCornerInstanceId) return false; + for (const key of Object.keys(board.cells)) { + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if (casterLos(state, caster, caster.position, mid) && losToEdge(board, mid, cell, side)) { + return true; + } + } return false; } @@ -1376,7 +1414,7 @@ const CARD_EFFECTS: Record 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, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, target.position)) return "no line of sight"; // Effectively permanent: broken by the caster attacking the target. attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS); return null; @@ -1475,7 +1513,7 @@ const CARD_EFFECTS: Record if (farKey && (view.edges[farKey] ?? "open") !== "open") { return "the far mouth of that warp is sealed"; } - if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight"; const ignite = (k: string, at: { cell: Cell; side: Side }) => { state.edgeOverrides[k] = "firewall"; state.createdEdges[k] = true; @@ -1511,7 +1549,7 @@ const CARD_EFFECTS: Record if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { return "the wave must span a corridor between two spaces"; } - if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + if (!castSightEdge(state, caster, cmd, view, 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. waveFromEdge(state, events, cell, side, 2, "waterwall"); @@ -1526,13 +1564,13 @@ const CARD_EFFECTS: Record 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"; + if (!castSightEdge(state, caster, cmd, boardView(state), 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"; + if (!castSightEdge(state, caster, cmd, view, cmd.target.cell, cmd.target.side)) return "no line of sight"; const was = view.edges[key]; delete state.edgeOverrides[key]; delete state.createdEdges[key]; @@ -1544,14 +1582,14 @@ const CARD_EFFECTS: Record const key = cellKey(cmd.target.cell); const creature = creatureAt(state, cmd.target.cell); if (creature) { - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight"; destroyCreature(state, events, creature, "dispel creation"); events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind }); return null; } const content = state.squareContents[key]; if (!content) return "nothing created there"; - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); return null; @@ -1568,7 +1606,7 @@ const CARD_EFFECTS: Record 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, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, 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); @@ -1577,7 +1615,7 @@ const CARD_EFFECTS: Record } if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, 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) { @@ -1644,8 +1682,8 @@ const CARD_EFFECTS: Record 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", + // Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId. + resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone", }, ugly: { kind: "neutral", @@ -1678,7 +1716,7 @@ const CARD_EFFECTS: Record 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"; + if (!castSightEdge(state, caster, cmd, view, 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; @@ -1750,7 +1788,7 @@ const CARD_EFFECTS: Record const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); if (!creature) return "no such monster"; if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster"; - if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, creature.position)) return "no line of sight"; const boost = cmd.params?.boost === "movement" ? "movement" : "life"; if (boost === "movement") creature.movesPerTurn *= 2; else creature.maxDamage *= 2; @@ -1841,7 +1879,7 @@ const CARD_EFFECTS: Record const key = edgeKey(cell, side); const view = boardView(state); if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall"; - if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight"; state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.edgeOverrides[key] = "open"; events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); @@ -1917,7 +1955,7 @@ const CARD_EFFECTS: Record if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") { return "a door goes into a stone wall or an open corridor"; } - if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "door"; state.createdEdges[key] = true; events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); @@ -1952,7 +1990,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to glue down"; - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight"; state.gluedCells[key] = true; // "a duration equal to twice the NUMBER card played" const turns = magnitude.duration * 2; @@ -1976,7 +2014,7 @@ const CARD_EFFECTS: Record (state.groundObjects[key] ?? []).length > 0 || state.treasures.some((t) => t.position && cellKey(t.position) === key); if (!hasObject) return "there is nothing there to lock up"; - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight"; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id }; events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); return null; @@ -1994,7 +2032,7 @@ const CARD_EFFECTS: Record if (ka === kb) return "pick two different squares"; if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe"; - if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, a) || !castSight(state, caster, cmd, b)) return "no line of sight"; const itemsA = state.groundObjects[ka] ?? []; const itemsB = state.groundObjects[kb] ?? []; const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); @@ -2026,7 +2064,7 @@ const CARD_EFFECTS: Record const current = view.edges[key]; const meltable = current === "wall" || current === "door"; if (!meltable) return "that is not a stone wall"; - if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight"; state.edgeOverrides[key] = "open"; delete state.doorStates[key]; delete state.createdEdges[key]; @@ -2057,7 +2095,7 @@ const CARD_EFFECTS: Record if (cmd.target?.kind === "cell") { const key = cellKey(cmd.target.cell); if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; - if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight"; delete state.squareContents[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); // "Solid stone block turns into a WATERWALL with a range and damage of 4." @@ -2526,7 +2564,7 @@ const CARD_EFFECTS: Record const aim = cmd.target.cell; const view = boardView(state); if (!view.cells[cellKey(aim)]) return "off the board"; - if (!casterLos(state, caster, caster.position, aim)) return "no line of sight"; + if (!castSight(state, caster, cmd, aim)) return "no line of sight"; state.turn.attackUsed = true; const clampToBoard = (c: Cell): Cell => { @@ -2758,7 +2796,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect { if (!view.cells[cellKey(at)]) return "off the board"; if (state.squareContents[cellKey(at)]) return "that square is blocked"; if (creatureAt(state, at)) return "a creature is already there"; - if (!casterLos(state, caster, caster.position, at)) return "no line of sight"; + if (!castSight(state, caster, cmd, at)) return "no line of sight"; state.turn.attackUsed = true; spawnCreature(state, events, kind, caster.id, at); return null; @@ -2810,7 +2848,7 @@ function emptySquareTarget( if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) { return "the warp shimmers there — nothing can form on it"; } - if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight"; + if (!castSight(state, caster, cmd, cell)) return "no line of sight"; return cell; } @@ -4907,7 +4945,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { return err("you must be in the same square"); } - if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) { + if (effect.requiresLos && !(mods.aroundCorner + ? bentLos(state, caster, caster.position, creature.position) + : casterLos(state, caster, caster.position, creature.position))) { return err("no line of sight to the creature"); } const wandEvents: GameEvent[] = []; @@ -4943,7 +4983,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (cmd.target?.kind === "cell" && state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { const cell = cmd.target.cell; - if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) { + if (effect.requiresLos && !(mods.aroundCorner + ? bentLos(state, caster, caster.position, cell) + : casterLos(state, caster, caster.position, cell))) { return err("no line of sight to the slime"); } const wandEvents: GameEvent[] = []; @@ -5062,7 +5104,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const preEvents: GameEvent[] = []; if (effect.requiresLos) { const sighted = mods.aroundCorner - ? bentLos(state, caster, caster.position, target.position, preEvents) + ? bentLos(state, caster, caster.position, target.position) : casterLos(state, caster, caster.position, target.position); if (!sighted) return err("no line of sight to the target"); } @@ -5183,7 +5225,9 @@ function doCast(prev: GameState, cmd: Extract): Comma } // Neutral: validate on a preview clone before consuming any cards. - const events: GameEvent[] = [{ + const events: GameEvent[] = []; + if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id }); + events.push({ type: "spellCast", caster: caster.id, card: inHand, @@ -5193,7 +5237,7 @@ function doCast(prev: GameState, cmd: Extract): Comma from: caster.position, target: cmd.target?.kind === "player" ? cmd.target.playerId : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, - }]; + }); const preview = clone(state); const problem = (effect as NeutralEffect).resolve( preview, [], activePlayer(preview), cmd, mods.magnitude, diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 05b7a02..88b28ca 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,7 +2,7 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; +import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { cardDef, type CardInstance } from "./cards"; import { boardView, @@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set { return out; } +/** + * Sight with an AROUND THE CORNER attached: every square straightly sighted, + * plus every square visible from one of those — the caster looks to a middle + * cell and the spell turns there, mirroring the engine's bentLos. + */ +export function bentSightedCellsFor(view: GameView): Set { + const out = sightedCellsFor(view); + const me = view.players.find((p) => p.id === view.you); + if (!me) return out; + const mids = [...out].map((k) => { + const [x, y] = k.split(",").map(Number) as [number, number]; + return { x, y }; + }); + const { board, blockers } = sightBasis(view); + for (const key of Object.keys(board.cells)) { + if (out.has(key)) continue; + const [x, y] = key.split(",").map(Number) as [number, number]; + const cell = { x, y }; + if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) || + bentSightThroughGap(board, me.position, cell, blockers)) { + out.add(key); + } + } + return out; +} + /** The board-as-seen and sight blockers this view's sight rules run against. */ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record } { // Held-open doors are open doorways to every eye. Beyond them, the @@ -337,12 +363,12 @@ const SUMMON_CARD_IDS = new Set([ * aid — mirroring the engine's own validation from the viewer's knowledge. * Null = this card's eligibility is not modeled; light everything. */ -export function eligibleCellsFor(view: GameView, cardId: string): Set | null { +export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set | null { const me = view.players.find((p) => p.id === view.you); if (!me) return null; const cells = Object.keys(view.board.cells); const key = (x: number, y: number) => `${x},${y}`; - const sighted = sightedCellsFor(view); + const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view); if (CREATION_CARD_IDS.has(cardId)) { // emptySquareTarget: on the board, unoccupied by content, home, wizard, diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index d43f69d..0d7e5f0 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src"; import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; +import { newGame, newExpansionGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; describe("around the corner", () => { it("bends line of sight past a wall that blocks a straight cast", () => { @@ -44,6 +44,130 @@ describe("around the corner", () => { state = must(state, defender.id, { type: "pass" }); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10); }); + + it("hairpins 180 degrees around a wall's end — the pivot sits in the gap", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + const view0 = boardView(state); + // Carve a 2x2 block: attacker at A, defender at C directly north behind a + // wall that ends beside the B/D gap. C is sealed on every other side, so + // no cell-centered bend reaches it — only the pivot in the gap itself. + // +---+---+ C = target D = gap's far cell + // # C D # A = caster B = gap's near cell + // +###+ + + // A B + let A: Cell | null = null; + for (const k of Object.keys(view0.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + if (view0.cells[`${x + 1},${y}`] && view0.cells[`${x},${y - 1}`] && view0.cells[`${x + 1},${y - 1}`]) { + A = { x, y }; + break; + } + } + expect(A).not.toBeNull(); + const B = { x: A!.x + 1, y: A!.y }; + const C = { x: A!.x, y: A!.y - 1 }; + const D = { x: A!.x + 1, y: A!.y - 1 }; + state.edgeOverrides[edgeKey(A!, "N")] = "wall"; // the wall to double back around + state.edgeOverrides[edgeKey(A!, "E")] = "open"; + state.edgeOverrides[edgeKey(B, "N")] = "open"; // the gap past the wall's end + state.edgeOverrides[edgeKey(C, "E")] = "open"; + state.edgeOverrides[edgeKey(C, "N")] = "wall"; + state.edgeOverrides[edgeKey(C, "W")] = "wall"; + state.edgeOverrides[edgeKey(D, "E")] = "wall"; + attacker.position = { ...A! }; + defender.position = { ...C }; + + const fb = giveCard(state, attacker.id, "fireball", "F", 0); + giveCard(state, attacker.id, "around-the-corner", "ATC", 1); + const straight = applyCommand(state, attacker.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, + }); + expect(straight.ok).toBe(false); + + const lifeBefore = defender.life; + 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).toBeLessThan(lifeBefore); + }); + + // A cell out of straight sight but reachable with one bend, clear enough + // to create on: no contents, home, wizard, treasure, object, or warp. + function hiddenEmptyCell(state: ReturnType["state"]): Cell { + const caster = activePlayer(state); + const view = boardView(state); + 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, caster.position, cell, caster.id)) continue; + if (state.squareContents[key]) continue; + if (view.homes.some((h) => cellKey(h) === key)) continue; + if (state.players.some((p) => p.alive && cellKey(p.position) === key)) continue; + if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) continue; + if ((state.groundObjects[key] ?? []).length > 0) 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, caster.position, mid, caster.id) && gameLos(state, mid, cell, caster.id)) { + return cell; + } + } + } + throw new Error("no hidden-but-bendable cell on this board"); + } + + it("bends a neutral spell too — SAFE locks up a treasure around the corner", () => { + let { state } = newExpansionGame(); + state = toRound2(state); + const caster = activePlayer(state); + const hidden = hiddenEmptyCell(state); + const gold = state.treasures.find((t) => t.position)!; + gold.position = { ...hidden }; + + const safe = giveCard(state, caster.id, "safe", "S", 0); + giveCard(state, caster.id, "around-the-corner", "ATC", 1); + const straight = applyCommand(state, caster.id, { + type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: hidden }, + }); + expect(straight.ok).toBe(false); + if (!straight.ok) expect(straight.error).toBe("no line of sight"); + + const bent = applyCommand(state, caster.id, { + type: "cast", instanceId: safe.instanceId, aroundCornerInstanceId: "around-the-corner#ATC", + target: { kind: "cell", cell: hidden }, + }); + if (!bent.ok) throw new Error(`bent SAFE refused: ${bent.error}`); + expect(bent.state.squareContents[cellKey(hidden)]?.kind).toBe("safe"); + expect(bent.events.some((e) => e.type === "castAroundCorner")).toBe(true); + // Both cards spent: the bend is not free. + const after = bent.state.players.find((p) => p.id === caster.id)!; + expect(after.hand.some((c) => c?.cardId === "around-the-corner")).toBe(false); + }); + + it("bends a creation — THORNBUSH grows on a square out of straight sight", () => { + let { state } = newGame(); + state = toRound2(state); + const caster = activePlayer(state); + const hidden = hiddenEmptyCell(state); + + const bush = giveCard(state, caster.id, "thornbush", "TB", 0); + giveCard(state, caster.id, "around-the-corner", "ATC", 1); + const straight = applyCommand(state, caster.id, { + type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell: hidden }, + }); + expect(straight.ok).toBe(false); + + state = must(state, caster.id, { + type: "cast", instanceId: bush.instanceId, aroundCornerInstanceId: "around-the-corner#ATC", + target: { kind: "cell", cell: hidden }, + }); + expect(state.squareContents[cellKey(hidden)]?.kind).toBe("thornbush"); + }); }); describe("blind", () => { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 2e8aa5a..875311a 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -14,7 +14,7 @@ import { prefs, savePrefs } from "./prefs.svelte"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; + import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine"; net.connect(); @@ -1172,8 +1172,9 @@ if (!view || !selectedDef || !yourMoment) return null; if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future if (!me) return null; + const bent = attachedMods.some((m) => m.cardId === "around-the-corner"); if (CELL_CARDS.has(selectedCard!.cardId)) { - return eligibleCellsFor(view, selectedCard!.cardId); + return eligibleCellsFor(view, selectedCard!.cardId, bent); } if (selectedDef.adjacent === true) { const { x, y } = me.position; @@ -1184,19 +1185,7 @@ ); } if (selectedDef.los !== true) return null; - const sighted = sightedCellsFor(view); - if (attachedMods.some((m) => m.cardId === "around-the-corner")) { - const widened = new Set(sighted); - for (const key of Object.keys(view.board.cells)) { - if (widened.has(key)) continue; - const [cx, cy] = key.split(",").map(Number); - if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) { - widened.add(key); - } - } - return widened; - } - return sighted; + return bent ? bentSightedCellsFor(view) : sightedCellsFor(view); }); /** Creatures awaiting your orders this turn (yours + any democratic monster). */