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
+43 -2
View File
@@ -360,6 +360,41 @@ export function hasWarpLineOfSight(
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
return warpSightTrace(board, from, to, blockedCells) !== null;
}
/**
* How a sight line reached its target — the material for drawing it. A warp
* trace carries the two mouths and the exact rim-crossing points (in board
* coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the
* near leg to `entry` and the far leg from `exit`.
*/
export type SightTrace =
| { kind: "direct" }
| {
kind: "warp";
mouthA: { cell: Cell; side: Side };
mouthB: { cell: Cell; side: Side };
entry: { x: number; y: number };
exit: { x: number; y: number };
};
export function traceSight(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): SightTrace | null {
if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" };
return warpSightTrace(board, from, to, blockedCells);
}
function warpSightTrace(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): Extract<SightTrace, { kind: "warp" }> | null {
const DIR: Record<Side, { x: number; y: number }> = {
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
};
@@ -423,10 +458,16 @@ export function hasWarpLineOfSight(
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
[cellKey(to), cellKey(mouthB)])
) {
return true;
return {
kind: "warp",
mouthA: { cell: mouthA, side: sideA },
mouthB: { cell: mouthB, side: w.to.side },
entry: P,
exit: Pfar,
};
}
}
return false;
return null;
}
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
+57 -8
View File
@@ -2227,6 +2227,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return;
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
// FAQ: "If you happen to already be carrying one of your treasures
// when you are hit with this spell, it has no effect on you."
if ((ctx.state.config.deckRev ?? 1) >= 23) {
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
if (carried?.owner === ctx.defender.id) return;
}
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
},
},
@@ -3987,10 +3993,43 @@ function attackPreconditions(state: GameState): string | null {
return null;
}
function castingBlocked(state: GameState, playerId: PlayerId): string | null {
/**
* IDIOT's FAQ carves out spells that "help you in your goal" of reaching the
* treasure — barrier-removal, passage, and movement boosts. Attacks and
* everything aimed at other players stay forbidden.
*/
const IDIOT_AIDS = new Set([
"destroy-wall", "pass-through-wall", "teleport", "power-run", "mad-dash",
"speed", "mist-body", "flight", "exploding-door", "stone-to-water",
"create-door", "vampire", "werewolf",
]);
function castingBlocked(
state: GameState, playerId: PlayerId,
opts?: { counteraction?: boolean; cardId?: string },
): string | null {
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)";
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
// needed)" — and per the FAQ, spells that aid the march to the treasure.
if (sustainedOn(state, playerId, "idiot").length > 0 &&
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
return null;
}
/**
* IDIOT forbids handling items and attacking players, not just casting:
* "it does not allow you to attack players or pick up other items".
* Dropping is the sharp edge — an idiot could otherwise capture a stolen
* treasure on their own home, or drop their own treasure underfoot for an
* instant cure.
*/
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) {
return "What am I doing here...? (you can do nothing but head for your treasure)";
}
return null;
}
@@ -4131,7 +4170,7 @@ function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
}
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const pre = attackPreconditions(prev);
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (pre) return err(pre);
const state = clone(prev);
@@ -4348,11 +4387,16 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// NO SPELL cast on you."
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
if (isSpell) {
const castBlock = castingBlocked(state, caster.id);
const castBlock = castingBlocked(state, caster.id, { cardId: inHand.cardId });
if (castBlock) return err(castBlock);
} else if (sustainedOn(state, caster.id, "medusa").length > 0) {
return err("you are paralyzed by Medusa");
}
// Thrown objects are attacks on players — not among IDIOT's permitted aids.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const ib = idiotBlocked(state, caster.id);
if (ib) return err(ib);
}
const mods = gatherModifiers(caster, cmd);
if (typeof mods === "string") return err(mods);
@@ -4855,8 +4899,9 @@ function doCounteract(
const def = cardDef(card.cardId);
// "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA);
// NO SPELL blocks all spells too.
const castBlock = castingBlocked(state, playerId);
// NO SPELL blocks all spells too. IDIOT does not — counteractions are the
// one thing its victim is expressly allowed.
const castBlock = castingBlocked(state, playerId, { counteraction: true });
if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
@@ -5431,7 +5476,7 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
// --- Treasures ---------------------------------------------------------------
function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const blocked = requireActionsAvailable(prev);
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
@@ -5480,7 +5525,7 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
}
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
if (blocked) return err(blocked);
const state = clone(prev);
@@ -5523,6 +5568,8 @@ export function isMovableObject(cardId: string): boolean {
}
function doDropObject(prev: GameState, instanceId: string): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
const card = p.hand.find((c) => c.instanceId === instanceId);
@@ -5539,6 +5586,8 @@ function doDropObject(prev: GameState, instanceId: string): CommandResult {
}
function doDropTreasure(prev: GameState): CommandResult {
const ib = idiotBlocked(prev, activePlayer(prev).id);
if (ib) return err(ib);
const state = clone(prev);
const p = activePlayer(state);
if (!p.carriedTreasureId) return err("you are not carrying a treasure");
+44 -7
View File
@@ -2,8 +2,8 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { sightBetween, type AssembledBoard } from "./board";
import { type CardInstance } from "./cards";
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
LOS_BLOCKING_CONTENT,
@@ -173,6 +173,16 @@ export function sightedCellsFor(view: GameView): Set<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, 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<string, true> } {
// Held-open doors are open doorways to the eye (rules rev 15).
let board = view.board;
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
@@ -189,11 +199,38 @@ export function sightedCellsFor(view: GameView): Set<string> {
blockers[`${p.position.x},${p.position.y}`] = true;
}
}
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
return out;
return { board, blockers };
}
/**
* How one square sees another under this viewer's knowledge of the board
* (believed illusion walls block; held doors admit). Null when no sight
* exists — the renderer's material for drawing the line an attack traveled.
*/
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers);
}
/**
* The sight line behind the attack currently on the stack — the board's
* answer to "how can he even see me?". Null when nothing should draw:
* no stack, a creature's or physical attack, a non-LOS card, attacker and
* defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line).
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: 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;
const a = view.players.find((p) => p.id === stack.attackerId);
const d = view.players.find((p) => p.id === stack.defenderId);
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;
}
const CREATION_CARD_IDS = new Set([