2 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 805fb09782 Chaos aims at no one
The client always offered CHAOS from the hint bar without a target,
and the engine refused it: "attack spells target a player". Not this
one — cast bare, it sweeps every other living wizard into the pile,
each offered their FULL SHIELD sit-out in turn order from the caster.
No stack forms; full shields only ever excluded a player from the
storm, so the queue is the whole defense. The older player-targeted
form stays accepted, so stored games replay unchanged. The scramble
joins the chronicle's notable events — a turn that redeals every hand
earns its eye.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:35:12 -04:00
Eric WagonerandClaude Fable 5 168e57ab91 The thief-chase serves the blow, not the nearness
An automaton beside the wizard carrying its gold shuttled on and off
their square until every move was spent — the chase goal flipped
between "the thief's cell" and "the gold" with each step, because it
keyed on whether the thief stood underfoot. The chase now claims the
march only while the blow it exists to deliver is still live (an
attack in hand, the turn's attack unspent, casting open): those gates
hold still as the clockwork walks, so the goal cannot flip mid-march.
Caught with the strike live, it stands its ground; otherwise the gold
has its legs for the turn. A regression walks the exact scene and
forbids the A-B-A shuttle. The bot-vs-bot harness also learns to
route WARD's pending moment to the treasure's owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:35:12 -04:00
5 changed files with 105 additions and 7 deletions
+13 -7
View File
@@ -1374,19 +1374,25 @@ export function automatonCommand(
const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!)));
// A thief already underfoot is not a destination — with the chase
// moot, march on the real goals (deliver, grab) instead of standing
// on them forever.
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
// The chase exists to deliver a blow, so it claims the march only
// while that blow is still live: an attack in hand, this turn's
// attack unspent, casting not yet closed. Those gates hold still as
// the clockwork walks — a goal keyed to the thief's nearness flips
// underfoot and shuttles the walker on and off their square. Caught
// with the strike still live, it stands its ground (a goal underfoot
// ends the march); otherwise the gold has its legs for the turn.
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive;
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: thiefAfar
? new Set([cellKey(thiefAfar.position)])
: chasing
? 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" && !thiefAfar, canUnlock }) ??
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards,
+22
View File
@@ -4933,6 +4933,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
return { ok: true, state, events: events2 };
}
// CHAOS aims at no one: cast bare, it sweeps every other living
// wizard into the pile, each offered their FULL SHIELD sit-out in
// turn order. (Full shields only exclude; nothing in the set cancels
// a zero-point storm, so the queue is the whole defense.) The older
// player-targeted form is still accepted below, so stored games
// replay unchanged.
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
consumeCast(state, caster, inHand, mods, false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
state.chaosPending = { casterId: caster.id, excluded: [], queue };
finishChaosIfReady(state, events);
return { ok: true, state, events };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
+40
View File
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.wardPending?.ownerId ??
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
@@ -735,3 +736,42 @@ describe("the clockwork flees the dread", () => {
}
});
});
describe("the thief-chase holds one goal per turn", () => {
it("a clockwork beside its thief never shuttles on and off their square", () => {
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
// Round 2, thief's turn burned; the bot acts with a full allowance.
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const thief = state.players.find((p) => p.id === "thief")!;
const bot = state.players.find((p) => p.id === "bot")!;
// The thief carries the bot's gold and stands one step away.
const mine = state.treasures.find((t) => t.owner === "bot")!;
mine.carriedBy = "thief";
mine.position = null;
thief.position = { x: 2, y: 4 };
bot.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land.
bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never);
const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
if (cmd.type === "endTurn") break;
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
}
// No step may return to the square just departed: A-B-A is the shuttle.
for (let i = 2; i < visited.length; i++) {
expect(visited[i]).not.toBe(visited[i - 2]);
}
});
});
+29
View File
@@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => {
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
const shielder = others[0]!;
giveCard(state, shielder, "full-shield", "S", 0);
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
// No stack: the queue opens at once, in turn order from the caster.
expect(state.stack).toBeNull();
expect(state.chaosPending?.queue.length).toBe(2);
const first = state.chaosPending!.queue[0]!;
state = first === shielder
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
: must(state, first, { type: "pass" });
const second = state.chaosPending!.queue[0]!;
state = second === shielder
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
: must(state, second, { type: "pass" });
expect(state.chaosPending).toBeNull();
// The shielded hand rode out the storm untouched.
expect(shielderHand()).toEqual(kept);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = threeGame();
state = toRound2(state);
+1
View File
@@ -226,6 +226,7 @@ const NOTABLE_EVENTS = new Set([
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
"handsScrambled",
]);
const SEAT_KEY = "wizwar-seat";