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
+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");