From 118bfd236bc6217fd20c5417d27c66ccb8ae13b1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 21 Aug 2026 11:11:25 -0400 Subject: [PATCH] IDIOT trusts the feet and yields to a jailbreak drop (rules rev 37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/automaton.ts | 36 +++++++++-- packages/engine/src/game.ts | 25 ++++++-- packages/engine/test/automaton.test.ts | 60 +++++++++++++++++++ packages/engine/test/expansion-combat.test.ts | 58 ++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 19 ++++-- 6 files changed, 185 insertions(+), 15 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 5f88405..1c5a770 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1323,17 +1323,43 @@ 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 // over gold; the worrier keeps its distance; the hunter goes to the gold. if (view.turn.movementUsed < view.turn.movementAllowance) { const gold = treasureGoals(view); const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); const canUnlock = UNLOCKS.some((id) => inHand(view, id)); - const objectives = thief - ? new Set([cellKey(thief.position)]) - : style === "berserker" && !self.carriedTreasureId - ? (enemyCells.size > 0 ? enemyCells : gold) - : gold; + 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)]) + : style === "berserker" && !self.carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; const path = pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a3cca2e..c28f0b1 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -3790,7 +3790,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { const events: GameEvent[] = []; // 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); if (steered) direction = steered; } @@ -4169,7 +4173,7 @@ const IDIOT_AIDS = new Set([ function castingBlocked( state: GameState, playerId: PlayerId, - opts?: { counteraction?: boolean; cardId?: string }, + opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: 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"; @@ -4177,7 +4181,16 @@ function castingBlocked( // 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)"; + // 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 null; } @@ -4605,7 +4618,11 @@ function doCast(prev: GameState, cmd: Extract): 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, { 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); } else if (sustainedOn(state, caster.id, "medusa").length > 0) { return err("you are paralyzed by Medusa"); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 462f6db..4e20984 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, createGame, + sustainedOn, type GameState, type PlayerId, } 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", () => { 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 }); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 08c83de..7ddc118 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -198,6 +198,64 @@ describe("expansion combat cards", () => { 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", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 19a158b..b61357f 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** 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"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d107e44..57fc018 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1808,11 +1808,20 @@ {/if} {#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
- 🥴 "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. - (The marching is yours to honor.) + {#if view.deckRev >= 37} + 🥴 "What am I doing here…?" — IDIOT drives you to the nearest + of your own treasures. Spend your movement marching toward it; + you may cast only counteractions, spells that speed the trip, + 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}
{/if} {#if actionsSpent}