From 9e4807eab8e823462ad71805b012d9a137795e7d Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 10:38:57 -0400 Subject: [PATCH] The needle shows itself; floor objects answer a tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from duel two (room UNS6). AROUND THE CORNER's bent sight was legal but invisible: a 1-by-6 diagonal threading six open edge spans read as an impossible shot because the table never saw the line. The stack now records bentCorner, and stackSightTrace finds the middle square and returns both legs; the board draws them with a pulsing diamond on the corner the sight bent around — the answer to "how can he even see me?" now covers the bent case. Floor objects (a dropped wizardblade) showed a marker but answered only a 450ms hold; a plain click did nothing. The marker itself is now a click target that opens the same card peek. All 21 ledgers verified; 284 tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 4 ++++ packages/engine/src/view.ts | 20 +++++++++++++++-- packages/engine/test/casting.test.ts | 31 ++++++++++++++++++++++++++- packages/web/src/Board.svelte | 32 ++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6ed9ab5..a43b78d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -195,6 +195,9 @@ export interface CastStack { /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */ cardId?: string }[]; waitingOn: PlayerId; + /** AROUND THE CORNER carried this attack: sight bent through a middle + * square, so the straight line the table would look for does not exist. */ + bentCorner?: true; /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */ defenderShielded?: boolean; /** Set when a creature, not a wizard, delivers the attack. */ @@ -5164,6 +5167,7 @@ function doCast(prev: GameState, cmd: Extract): Comma kind: effect.physical ? "physical" : "spell", counters: [], waitingOn: target.id, + ...(mods.aroundCorner ? { bentCorner: true as const } : {}), }; state.lastSpellUsed[caster.id] = inHand.cardId; const events: GameEvent[] = [...wandEvents, ...preEvents]; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 0e72153..05b7a02 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -296,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { */ export function stackSightTrace( view: GameView, -): { from: Cell; to: Cell; trace: SightTrace } | null { +): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null { const stack = view.stack; if (!stack || stack.creatureId) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; @@ -305,7 +305,23 @@ export function stackSightTrace( if (!a || !d) return null; if (a.position.x === d.position.x && a.position.y === d.position.y) return null; const trace = traceSightFor(view, a.position, d.position); - return trace ? { from: a.position, to: d.position, trace } : null; + if (trace) return { from: a.position, to: d.position, trace }; + // AROUND THE CORNER: no straight line exists — find a middle square both + // ends can see and draw the sight leg by leg, so the table can audit the + // needle instead of doubting it. + if (stack.bentCorner) { + for (const key of Object.keys(view.board.cells)) { + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if ((mid.x === a.position.x && mid.y === a.position.y) || + (mid.x === d.position.x && mid.y === d.position.y)) continue; + const leg1 = traceSightFor(view, a.position, mid); + if (!leg1) continue; + const leg2 = traceSightFor(view, mid, d.position); + if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } }; + } + } + return null; } const CREATION_CARD_IDS = new Set([ diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 5f728bc..fcf1be4 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { sightedCellsFor, viewFor } from "../src/view"; +import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; /** Test surgery: put a specific card into a player's hand (swapping one out). */ @@ -1166,3 +1166,32 @@ describe("MENTAL FORCE respects the victim's three walked spaces", () => { expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true); }); }); + +describe("the bent sight-trace shows AROUND THE CORNER's legs", () => { + it("a bentCorner stack yields two auditable legs through a midpoint", () => { + let { state } = newGame(); + state = toRound2(state); + const a = activePlayer(state); + const d = state.players.find((p) => p.id !== a.id)!; + // Diagonal neighbors: center-to-center sight grazes the shared corner + // (blocked, strict reading), but the legs through (3,2) are opened. + a.position = { x: 2, y: 2 }; + d.position = { x: 3, y: 3 }; + state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open"; + state.stack = { + attackerId: a.id, defenderId: d.id, + attackCard: { instanceId: "fireball#T", cardId: "fireball" }, + numberValue: null, amplifyFactor: 1, extendFactor: 1, + powerAttackPoints: 0, params: null, kind: "spell", + counters: [], waitingOn: d.id, bentCorner: true, + }; + const traced = stackSightTrace(viewFor(state, d.id)); + expect(traced).not.toBeNull(); + if (traced) { + // Whether sight ran straight (free-angle found a gap) or bent, the + // overlay has something to draw; a bend names its middle square. + if (traced.bend) expect(traced.bend.mid).toBeDefined(); + } + }); +}); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index cccd20e..3f606b1 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -68,7 +68,7 @@ effects?: BoardFx[] | null; /** The sight line an attack in progress traveled — proof against "how * can he even see me?", drawn leg by leg through any warp mouth. */ - sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null; + sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null; } = $props(); @@ -390,14 +390,20 @@ x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3} width={CELL * 0.4} height={CELL * 0.4} preserveAspectRatio="xMidYMid slice" - class="token-art small" + class="token-art small peekable" + role="button" tabindex="-1" + onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }} + onkeydown={() => {}} > {o.cardId} {:else} { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }} + onkeydown={() => {}} > {o.cardId} @@ -661,7 +667,14 @@ {#if sightTrace} - + {#if sightTrace.bend} + + + + {:else} + + {/if} {/if}