From 7ee3c01e4491660171c284eed39854309fe3a786 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 22:35:00 -0400 Subject: [PATCH] Idiot lifts the moment its march turns impossible The all-carried check ran only on the victim's own step, so a curse whose last floor treasure was hauled off mid-turn kept its grip until the idiot happened to move. The lift check now also runs at the pickup that empties the floor, and at attach for a victim already standing on their chest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 36 ++++++++++++------- packages/engine/test/expansion-combat.test.ts | 28 +++++++++++++++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index bfecb2d..36c406d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2575,6 +2575,8 @@ const CARD_EFFECTS: Record 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); + // Already standing on their own treasure: the march is over as it begins. + liftIdiotIfSatisfied(ctx.state, ctx.events, ctx.defender); }, }, "big-man": { @@ -4400,19 +4402,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { state.turn.actionsEnded = true; springSlimeTrap(state, events, p); } - // IDIOT lifts when the victim reaches their own treasure. - if (sustainedOn(state, p.id, "idiot").length > 0) { - const onOwn = state.treasures.some( - (t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position), - ); - const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position); - if (onOwn || allCarried) { - for (const fx of sustainedOn(state, p.id, "idiot")) { - events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id }); - } - state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id)); - } - } + liftIdiotIfSatisfied(state, events, p); // Armed ambushes may spring on this step. checkAmbushes(state, events, p, { movedFrom: from }); @@ -4561,6 +4551,22 @@ function castingBlocked( * treasure on their own home, or drop their own treasure underfoot for an * instant cure. */ +/** IDIOT lifts when the march is done — the victim stands on their own + * treasure — or turns unsatisfiable: every one of their treasures is in + * someone's arms, leaving nothing to stand on. */ +function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerState): void { + if (sustainedOn(state, p.id, "idiot").length === 0) return; + const onOwn = state.treasures.some( + (t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position), + ); + const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position); + if (!onOwn && !allCarried) return; + for (const fx of sustainedOn(state, p.id, "idiot")) { + events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id }); + } + state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id)); +} + function idiotBlocked(state: GameState, playerId: PlayerId): string | null { if (sustainedOn(state, playerId, "idiot").length > 0) { return "What am I doing here...? (you can do nothing but head for your treasure)"; @@ -6400,6 +6406,10 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { const events: GameEvent[] = [ { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, ]; + // This grab may have lifted the owner's IDIOT: with every one of their + // treasures now carried, there is nothing left to march to. + const cursedOwner = state.players.find((q) => q.id === t.owner); + if (cursedOwner) liftIdiotIfSatisfied(state, events, cursedOwner); // WARD: "you may play at that time (out of turn) this card on him" — // literally: the grab hangs while the owner decides. const owner = state.players.find((q) => q.id === t.owner); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 37bc917..da343fd 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -165,6 +165,34 @@ describe("expansion combat cards", () => { } }); + it("idiot lifts the moment the victim's last floor treasure is carried off", () => { + let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic"] }); + while (state.turn.round < 2) { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + const { attacker, defender } = faceOff(state); + const third = state.players.find((p) => p.id !== attacker && p.id !== defender)!; + const id = giveCard(state, attacker, "idiot"); + state = castAt(state, attacker, defender, id); + expect(sustainedOn(state, defender, "idiot").length).toBe(1); + + // A third wizard hauls off one of the victim's chests: one destination + // remains, so the march goes on. + const [first, second] = state.treasures.filter((t) => t.owner === defender); + const hauler = state.players.find((p) => p.id === third.id)!; + first!.carriedBy = hauler.id; + first!.position = null; + hauler.carriedTreasureId = first!.id; + expect(sustainedOn(state, defender, "idiot").length).toBe(1); + + // The caster takes the last one off the floor — nothing left to march + // to, and the curse lifts on the spot. + const att = state.players.find((p) => p.id === attacker)!; + att.position = { ...second!.position! }; + state = must(state, attacker, { type: "pickUpTreasure", treasureId: second!.id }); + expect(sustainedOn(state, defender, "idiot").length).toBe(0); + }); + it("idiot forbids item handling and punches but allows counteractions", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],