The clockwork reads the whole deck

Every playable card now has a place in the automaton's mind. The
damage table completes: lightning blast, wizardblade, and power drain
scale with numbers (and are skipped without one), disease bites in
the shared square, the dagger's true three points corrected. A new
affliction book casts the miseries — blind, slow, medusa, no-spell,
lock-in-place, walking dead, slow death, idiot, thought steal,
go-away — with a number for the duration, and WEAKNESS is saved for
whoever carries stolen gold. AMPLIFY doubles the heavy spells.

The counteraction ladder runs the full rack: REVERSE eats the big
blasts, ABSORB SPELL steals the good ones, FULL SHIELD, FULL
REFLECTION, REMOVE CURSE refuses afflictions at the door, REFLECTION
halves, ABSORB soaks the mid-range, BLUNT, EMPATHY out of berserker
spite, teleport escapes, shieldstone numbers.

Housekeeping: every stone hits the table the turn it is drawn, GIFT
FROM ABOVE is cashed instantly, curses get scrubbed with REMOVE
CURSE, LIFESAVER goes up at three players, a SAFE or GLUE guards
floor gold with enemies near, and DEJA-VU pulls the best attack back
from the discard when the hand runs dry of violence.

Table talk verified live ("ACQUISITION COMPLETE." mid-probe) — the
silence was timing, not plumbing — and the remark odds rise to half.
Three-way tournaments tell true stories now: the hunter wins on
gold, the worrier survives to win by attrition, and the berserker
learns that aggression in a three-way gets you buried first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 17:41:12 -04:00
co-authored by Claude Fable 5
parent 3eda686e29
commit a6ede6ca1e
2 changed files with 144 additions and 19 deletions
+143 -18
View File
@@ -15,18 +15,46 @@ export type AutomatonStyle = "hunter" | "berserker" | "worrier";
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false },
"sudden-death": { base: 10, perNumber: false },
powerthrust: { base: 2, perNumber: true },
waterbolt: { base: 0, perNumber: true },
dagger: { base: 1, perNumber: false },
waterbolt: { base: 0, perNumber: true, needsNumber: true },
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
wizardblade: { base: 0, perNumber: true, needsNumber: true },
"power-drain": { base: 0, perNumber: true, needsNumber: true },
dagger: { base: 3, perNumber: false },
"large-rock": { base: 2, perNumber: false },
"blaster-wand": { base: 3, perNumber: false },
disease: { base: 3, perNumber: false, sameSquare: true },
};
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
const UNLOCKS = ["master-key", "pick-lock"];
/**
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
* number for the duration where one helps.
*/
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
blind: { withNumber: true },
slow: { withNumber: true },
"no-spell": { withNumber: true },
medusa: { withNumber: true },
"lock-in-place": { withNumber: true },
"walking-dead": { withNumber: false },
"slow-death": { withNumber: false },
idiot: { withNumber: false },
weakness: { withNumber: false, vsCarrier: true },
"go-away": { withNumber: true },
"thought-steal": { withNumber: false },
};
/** Every stone earns its place on the table the moment it is drawn. */
const STONES = new Set([
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
]);
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
function me(view: GameView) {
return view.players.find((p) => p.id === view.you)!;
@@ -50,10 +78,12 @@ function numbersInHand(view: GameView): CardInstance[] {
function discardValue(c: CardInstance): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null) return 7;
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
c.cardId === "ward" || c.cardId === "drop-object") return 6;
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
c.cardId === "glue") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
@@ -210,18 +240,42 @@ function respond(view: GameView, style: AutomatonStyle): Command {
}
const flinch = style === "worrier" ? 1 : 0;
const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
const incoming = stack.attackCard
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
if (stack.kind === "spell") {
// REVERSE turns the biggest blasts into a meal.
const reverse = find("reverse");
if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell");
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield");
if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId };
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
const reflect = find("full-reflection");
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door.
if (affliction) {
const cleanse = find("remove-curse");
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
}
const half = find("reflection");
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
}
const absorb = find("absorb");
if (absorb && incoming >= 2 && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
const blunt = find("blunt");
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite.
const empathy = find("empathy");
if (empathy && style === "berserker" && incoming >= 2) {
return { type: "counteract", instanceId: empathy.instanceId };
}
// Nothing to block with: teleport clear of the big ones.
const tp = find("teleport");
if (tp && incoming >= 4 - flinch) {
@@ -237,22 +291,30 @@ function respond(view: GameView, style: AutomatonStyle): Command {
return { type: "pass" };
}
/** The best attack available against a visible target, numbers included. */
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
const numbers = numbersInHand(view);
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = inHand(view, "amplify");
let best: { cmd: Command; damage: number } | null = null;
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
if (atk.sameSquare && !together) continue;
// A wand needs its charges set by a number on first use.
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if (uncharged && !biggest) continue;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest;
const damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
if (damage <= 0) continue;
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
const amplified = amplify && damage >= 4 && !isWand &&
c.cardId !== "dagger" && c.cardId !== "large-rock";
if (amplified) damage *= 2;
if (!best || damage > best.damage) {
best = {
damage,
@@ -260,6 +322,7 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | null {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
}
@@ -267,6 +330,24 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | null {
return best?.cmd ?? null;
}
/** An affliction worth casting when no damage lands, mid number attached. */
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
for (const c of view.yourHand) {
const aff = AFFLICTIONS[c.cardId];
if (!aff) continue;
if (aff.vsCarrier && !isThief) continue;
if (aff.withNumber && !mid) continue;
return {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
};
}
return null;
}
/** An empty, sighted square near the target for a summoned creature. */
function summonSpot(view: GameView, near: Cell): Cell | null {
const sighted = sightedCellsFor(view);
@@ -293,15 +374,28 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
return { type: "cast", instanceId: speed.instanceId };
}
// Free life is free.
const gift = inHand(view, "gift-from-above");
if (gift) return { type: "cast", instanceId: gift.instanceId };
// Every stone goes on the table the turn it is drawn.
for (const c of view.yourHand) {
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
return { type: "cast", instanceId: c.instanceId };
}
}
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
);
const cleanse = inHand(view, "remove-curse");
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
// Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
// The ward guards the gold while its owner is away robbing yours.
if (inHand(view, "ward") && !view.yourWardArmed) {
return { type: "armWard", armed: true };
}
// A shieldstone on the table turns spare numbers into armor.
const stone = inHand(view, "shieldstone");
if (stone && !self.displayed.some((c) => c.cardId === "shieldstone")) {
return { type: "cast", instanceId: stone.instanceId };
}
const enemyNear = livingEnemies(view).some(
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
);
@@ -334,6 +428,35 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
}
}
}
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = view.treasures.find(
(t) => t.owner === view.you && t.position && !t.carriedBy,
);
if (myFloorTreasure && enemyNear) {
const safe = inHand(view, "safe");
if (safe) {
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
}
const glue = inHand(view, "glue");
const midN = numbers[Math.floor(numbers.length / 2)];
if (glue && midN) {
return {
type: "cast", instanceId: glue.instanceId,
target: { kind: "cell", cell: myFloorTreasure.position! },
numberInstanceIds: [midN.instanceId],
};
}
}
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
const dv = inHand(view, "deja-vu");
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
const buried = [...view.discardPile].reverse().find(
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
);
if (buried) {
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
}
}
// An ambush costs nothing to hold and everything to walk into.
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
@@ -434,6 +557,8 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
}
const spell = bestAttack(view, target.id);
if (spell) return spell;
const misery = bestAffliction(view, target.id, thief?.id === target.id);
if (misery) return misery;
if (cellKey(target.position) === here && style !== "worrier") {
return { type: "punch", targetId: target.id };
}