IDIOT trusts the feet and yields to a jailbreak drop (rules rev 37)

The per-step steering recalculated greedily, ping-ponging between routes
that tie at a corner and able to wall the victim off from the cure; rev 37
retires it — the march is the player's to honor, and the automatons steer
themselves. DROP OBJECT that shakes the victim's OWN treasure out of a
thief's arms is now an aid, not an attack: while carried, that treasure
cannot even be stood upon, so the curse could be unsatisfiable. Older
ledgers replay steered and strict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 11:11:25 -04:00
co-authored by Claude Fable 5
parent 6adcbe23b5
commit 118bfd236b
6 changed files with 185 additions and 15 deletions
+27 -1
View File
@@ -1323,13 +1323,39 @@ export function automatonCommand(
} }
} }
// IDIOT's curse: the maze no longer forces the feet (rev 37), so the
// clockwork honors the march to its own gold itself — and when a thief
// carries that gold, DROP OBJECT (the rev-37 carve-out) shakes it onto
// the floor where it can finally be stood upon.
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
if (cursedIdiot && view.deckRev >= 37 && view.turn.round > 1 && !view.turn.attackUsed) {
const dropper = inHand(view, "drop-object");
if (dropper) {
const sighted = sightedCellsFor(view);
const thiefOfGold = livingEnemies(view).find((p) =>
sighted.has(cellKey(p.position)) &&
view.treasures.some((t) => t.owner === view.you && t.carriedBy === p.id));
if (thiefOfGold) {
return {
type: "cast", instanceId: dropper.instanceId,
target: { kind: "player", playerId: thiefOfGold.id }, params: { cardId: "treasure" },
};
}
}
}
// March. The thief-chase outranks everything; the berserker hunts wizards // March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold. // over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) { if (view.turn.movementUsed < view.turn.movementAllowance) {
const gold = treasureGoals(view); const gold = treasureGoals(view);
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const canUnlock = UNLOCKS.some((id) => inHand(view, id)); const canUnlock = UNLOCKS.some((id) => inHand(view, id));
const objectives = thief const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!)));
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: thief
? new Set([cellKey(thief.position)]) ? new Set([cellKey(thief.position)])
: style === "berserker" && !self.carriedTreasureId : style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold) ? (enemyCells.size > 0 ? enemyCells : gold)
+20 -3
View File
@@ -3790,7 +3790,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const events: GameEvent[] = []; const events: GameEvent[] = [];
// IDIOT: every move heads for the nearest of their own treasures. // IDIOT: every move heads for the nearest of their own treasures.
if (sustainedOn(state, p.id, "idiot").length > 0) { // Rev 37 retires the steering: a greedy per-step recalculation ping-pongs
// between routes that tie at a corner and can wall the victim off from
// the cure entirely. The march is the player's to honor (like FEAR's
// duty); the automatons steer themselves. Older ledgers replay steered.
if ((state.config.deckRev ?? 1) < 37 && sustainedOn(state, p.id, "idiot").length > 0) {
const steered = idiotSteer(state, p); const steered = idiotSteer(state, p);
if (steered) direction = steered; if (steered) direction = steered;
} }
@@ -4169,7 +4173,7 @@ const IDIOT_AIDS = new Set([
function castingBlocked( function castingBlocked(
state: GameState, playerId: PlayerId, state: GameState, playerId: PlayerId,
opts?: { counteraction?: boolean; cardId?: string }, opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: string },
): string | null { ): string | null {
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa"; 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, "no-spell").length > 0) return "No Spell — you cannot cast";
@@ -4177,8 +4181,17 @@ function castingBlocked(
// needed)" — and per the FAQ, spells that aid the march to the treasure. // needed)" — and per the FAQ, spells that aid the march to the treasure.
if (sustainedOn(state, playerId, "idiot").length > 0 && if (sustainedOn(state, playerId, "idiot").length > 0 &&
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) { !opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
// Rev 37: DROP OBJECT that shakes YOUR OWN treasure out of a thief's
// arms serves the march — while carried, that treasure cannot even be
// stood upon, so without this the curse can be unsatisfiable.
const freesOwnGold = (state.config.deckRev ?? 1) >= 37 &&
opts?.cardId === "drop-object" && opts.pickedCardId === "treasure" &&
opts.targetPlayerId != null &&
state.treasures.some((t) => t.owner === playerId && t.carriedBy === opts.targetPlayerId);
if (!freesOwnGold) {
return "What am I doing here...? (you can do nothing but head for your treasure)"; return "What am I doing here...? (you can do nothing but head for your treasure)";
} }
}
return null; return null;
} }
@@ -4605,7 +4618,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// NO SPELL cast on you." // NO SPELL cast on you."
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand; const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
if (isSpell) { if (isSpell) {
const castBlock = castingBlocked(state, caster.id, { cardId: inHand.cardId }); const castBlock = castingBlocked(state, caster.id, {
cardId: inHand.cardId,
targetPlayerId: cmd.target?.kind === "player" ? cmd.target.playerId : undefined,
pickedCardId: cmd.params?.cardId,
});
if (castBlock) return err(castBlock); if (castBlock) return err(castBlock);
} else if (sustainedOn(state, caster.id, "medusa").length > 0) { } else if (sustainedOn(state, caster.id, "medusa").length > 0) {
return err("you are paralyzed by Medusa"); return err("you are paralyzed by Medusa");
+60
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
applyCommand, applyCommand,
createGame, createGame,
sustainedOn,
type GameState, type GameState,
type PlayerId, type PlayerId,
} from "../src/game"; } from "../src/game";
@@ -223,6 +224,65 @@ describe("the clockwork guards gold only within sight", () => {
}); });
}); });
describe("the clockwork honors IDIOT's march", () => {
// Rev 37 retired the engine's steering, so the duty falls to the brain.
function cursedBot() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 37 });
// Past round 1 (no combat) and around to the bot's turn.
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
state.sustained.push({
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
remainingTurns: 9999, data: {},
} as never);
return state;
}
it("marches to its own gold until the curse lifts", () => {
let state = cursedBot();
state.players.find((p) => p.id === "bot")!.hand = [];
// A walled route can cost more steps than one turn's allowance; the
// march may span turns (the foe just passes).
for (let i = 0; i < 30 && sustainedOn(state, "bot", "idiot").length > 0; i++) {
const seat = actingSeat(state);
const cmd = seat === "bot"
? automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
?? automatonFallback(viewFor(state, "bot"), "archmage")
: { type: "endTurn", draw: 0 } as const;
const r = applyCommand(state, seat, cmd);
if (!r.ok) throw new Error(r.error);
state = r.state;
}
expect(sustainedOn(state, "bot", "idiot").length).toBe(0);
});
it("shakes its gold from a thief's arms with DROP OBJECT", () => {
const state = cursedBot();
const bot = state.players.find((p) => p.id === "bot")!;
const thief = state.players.find((p) => p.id === "foe")!;
thief.position = { ...bot.position };
const own = state.treasures.find((t) => t.owner === "bot")!;
own.carriedBy = "foe";
own.position = null;
thief.carriedTreasureId = own.id;
bot.hand = [{ instanceId: "drop-object#T", cardId: "drop-object" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "drop-object#T",
target: { kind: "player", playerId: "foe" }, params: { cardId: "treasure" },
});
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
});
describe("a clogged hand gets shed, not hoarded", () => { describe("a clogged hand gets shed, not hoarded", () => {
it("the bot discards dead weight so the end-of-turn draw has room", () => { it("the bot discards dead weight so the end-of-turn draw has room", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 }); let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
@@ -198,6 +198,64 @@ describe("expansion combat cards", () => {
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true); expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
}); });
it("idiot at rev 37 no longer steers the march — the step goes where asked", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 37,
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const d = state.players.find((p) => p.id === defender)!;
const board = boardView(state);
const open = (["N", "S", "E", "W"] as const)
.filter((s) => stepTarget(board, d.position, s).kind === "step");
expect(open.length).toBeGreaterThanOrEqual(2);
// Plant the victim's own gold one step out one way; walk the other way.
// Pre-37 steering would have overridden the request and taken the gold.
const toward = stepTarget(board, d.position, open[0]!);
const away = stepTarget(board, d.position, open[1]!);
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = null;
own.position = toward.to;
state = must(state, defender, { type: "move", direction: open[1]! });
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
});
it("idiot at rev 37 permits DROP OBJECT that frees the victim's own treasure", () => {
for (const deckRev of [36, 37]) {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const thief = state.players.find((p) => p.id === attacker)!;
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = attacker;
own.position = null;
thief.carriedTreasureId = own.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// An attack for its own sake stays forbidden at every revision.
const fb = giveCard(state, defender, "fireball", "F", 0);
expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false);
// Shaking YOUR OWN gold out of the thief's arms serves the march —
// while carried it cannot be stood upon — but only from rev 37 on.
const dr = giveCard(state, defender, "drop-object", "D", 1);
const r = applyCommand(state, defender, {
type: "cast", instanceId: dr.instanceId,
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
});
expect(r.ok).toBe(deckRev >= 37);
}
});
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => { it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 36; const RULES_REV = 37;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+14 -5
View File
@@ -1808,11 +1808,20 @@
{/if} {/if}
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)} {#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
<div class="slip urgent"> <div class="slip urgent">
🥴 "What am I doing here…?" — IDIOT drives you to the nearest of {#if view.deckRev >= 37}
your own treasures. Spend your movement marching (the maze will 🥴 "What am I doing here…?" — IDIOT drives you to the nearest
steer your steps); you may cast only counteractions and spells of your own treasures. Spend your movement marching toward it;
that speed the trip. It lifts when you stand on your treasure. you may cast only counteractions, spells that speed the trip,
(The marching is yours to honor.) and Drop Object to shake your own treasure from a thief's
arms. It lifts when you stand on your treasure. (The marching
is yours to honor.)
{:else}
🥴 "What am I doing here…?" IDIOT drives you to the nearest
of your own treasures. Spend your movement marching (the maze
will steer your steps); you may cast only counteractions and
spells that speed the trip. It lifts when you stand on your
treasure.
{/if}
</div> </div>
{/if} {/if}
{#if actionsSpent} {#if actionsSpent}