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:
co-authored by
Claude Fable 5
parent
6adcbe23b5
commit
118bfd236b
@@ -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 }) ??
|
||||
|
||||
@@ -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<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, { 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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user