The clockwork stops absorbing nothing
An automaton answered DROP OBJECT with an Absorb, then a Blunt — two counters drained against a spell that carries no points. The brain's price table listed only the classic damage spells; anything unknown was guessed at 2 points, which tripped every damage-counter rung. Utility attacks (dropping, dragging, swapping, peeking) now route past those rungs entirely: the bot passes, or spends a full stop when the effect actually threatens something — a shield for the treasure it carries, a worrier's refusal to be dragged. Stone Dead, Butt Head, Heave-Ho, and Illusionary Attack join the price table while the hood is up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8a460a0eed
commit
2950380e23
@@ -53,8 +53,18 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?:
|
|||||||
"large-rock": { base: 2, perNumber: false },
|
"large-rock": { base: 2, perNumber: false },
|
||||||
"blaster-wand": { base: 3, perNumber: false },
|
"blaster-wand": { base: 3, perNumber: false },
|
||||||
disease: { base: 3, perNumber: false, sameSquare: true },
|
disease: { base: 3, perNumber: false, sameSquare: true },
|
||||||
|
"butt-head": { base: 2, perNumber: false },
|
||||||
|
"heave-ho": { base: 2, perNumber: false },
|
||||||
|
"illusionary-attack": { base: 3, perNumber: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Attacks that carry no points: dropping, dragging, swapping, peeking.
|
||||||
|
* A damage counter drains against nothing — only a full stop denies them. */
|
||||||
|
const UTILITY_ATTACKS = new Set([
|
||||||
|
"drop-object", "teleport-opponent", "mental-force", "mental-swap", "swap",
|
||||||
|
"swap-meet", "card-erasure", "telepath", "sticky-wand", "shift-wand",
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
|
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
|
||||||
* number for the duration where one helps.
|
* number for the duration where one helps.
|
||||||
@@ -269,8 +279,22 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
const attackId = stack.attackCard?.cardId ?? null;
|
const attackId = stack.attackCard?.cardId ?? null;
|
||||||
const atk = attackId ? ATTACKS[attackId] : null;
|
const atk = attackId ? ATTACKS[attackId] : null;
|
||||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||||
|
|
||||||
|
if (attackId && UTILITY_ATTACKS.has(attackId)) {
|
||||||
|
const stopper = find("full-shield") ?? find("force-field");
|
||||||
|
const stakes =
|
||||||
|
(attackId === "drop-object" && me(view).carriedTreasureId != null) ||
|
||||||
|
((attackId === "teleport-opponent" || attackId === "mental-force" || attackId === "swap") &&
|
||||||
|
style === "worrier");
|
||||||
|
if (stopper && stakes) return { type: "counteract", instanceId: stopper.instanceId };
|
||||||
|
return { type: "pass" };
|
||||||
|
}
|
||||||
|
|
||||||
const incoming = stack.attackCard
|
const incoming = stack.attackCard
|
||||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
? attackId === "stone-dead"
|
||||||
|
// NUMBER times the stones in the victim's hand — our hand.
|
||||||
|
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
|
||||||
|
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||||
: 1;
|
: 1;
|
||||||
if (stack.kind === "spell") {
|
if (stack.kind === "spell") {
|
||||||
// REVERSE turns the biggest blasts into a meal.
|
// REVERSE turns the biggest blasts into a meal.
|
||||||
|
|||||||
@@ -116,3 +116,52 @@ describe("automaton vs automaton", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||||
|
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
|
||||||
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||||
|
// burn round 1
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
while (actingSeat(state) !== "human") {
|
||||||
|
state = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }).state;
|
||||||
|
}
|
||||||
|
const human = state.players.find((p: { id: string }) => p.id === "human")!;
|
||||||
|
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
|
||||||
|
bot.position = { ...human.position };
|
||||||
|
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||||
|
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||||
|
rig?.(state, "bot");
|
||||||
|
const r = applyCommand(state, "human", {
|
||||||
|
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
|
||||||
|
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||||
|
return r.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("passes on drop-object rather than absorbing nothing", () => {
|
||||||
|
const state = underAttack("drop-object", [
|
||||||
|
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||||
|
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||||
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||||
|
]);
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toEqual({ type: "pass" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shields a drop-object aimed at its carried treasure", () => {
|
||||||
|
const state = underAttack("drop-object", [
|
||||||
|
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||||
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||||
|
], (s, defender) => {
|
||||||
|
const d = s.players.find((p: { id: string }) => p.id === defender)!;
|
||||||
|
const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!;
|
||||||
|
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
||||||
|
});
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user