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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 22:35:00 -04:00
co-authored by Claude Fable 5
parent dd6ba59220
commit 7ee3c01e44
2 changed files with 51 additions and 13 deletions
+23 -13
View File
@@ -2575,6 +2575,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
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);
@@ -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"],