Idiot enforces its card (rev 23) + attack sight-line tracing

Rules rev 23 — IDIOT, per the card and FAQ:
- No handling items: pick up / drop of treasures and objects refused
  (dropping was the exploit: capturing a stolen treasure on your own home,
  or dropping your own treasure underfoot for an instant cure)
- No punching, no thrown dagger / large rock (attacks on players)
- No effect on a victim already carrying one of their own treasures
Ungated (permissive): counteractions are now castable while idiotized (the
one thing the card expressly allows — the gate wrongly blocked them), and
goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per
the FAQ's 'you could, however, destroy a wall'.

Sight tracing: while an LOS attack sits on the stack, the board draws the
line it traveled — straight when direct, leg by leg through both warp
mouths (with pulsing rings) when the maze's wraparound carried it. Engine
traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown
live and in replays. Verified against H3PC's cross-board Idiot cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 19:03:09 -04:00
co-authored by Claude Fable 5
parent 7372bfc5e8
commit cdeb3d5103
10 changed files with 290 additions and 24 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board";
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";
@@ -463,3 +463,40 @@ describe("junction alterations roll for their sector (rules rev 20)", () => {
expect(createdKeys.length).toBe(1);
});
});
describe("sight tracing", () => {
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
const board = boardView(state);
const cells = Object.keys(board.cells).map((k) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
let warped = 0;
for (const from of cells) {
for (const to of cells) {
const t = traceSight(board, from, to);
expect(t !== null).toBe(sightBetween(board, from, to));
if (!t) continue;
if (hasLineOfSight(board, from, to)) {
expect(t.kind).toBe("direct");
} else {
expect(t.kind).toBe("warp");
if (t.kind === "warp") {
warped++;
// Each crossing point lies on its mouth's one-cell rim segment.
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
};
expect(onRim(t.entry, t.mouthA)).toBe(true);
expect(onRim(t.exit, t.mouthB)).toBe(true);
}
}
}
}
expect(warped).toBeGreaterThan(0);
});
});