Difficulty without stupidity: apprentice, adept, archmage

Tiers degrade resources and repertoire, never judgment — the design
constraint was that no tier may ever look dumb. The APPRENTICE draws
one card a turn instead of two (a poorer wizard, not a worse one),
spends counters only on heavy hits (thrift, not blindness), and
carries a modest spellbook: damage, summons, stones, keys, and
treasure play, with no afflictions, amplifies, ambushes, guarding
tricks, or deja-vu. The ADEPT draws fully and knows everything except
ambushes and amplify. The ARCHMAGE is the full curriculum. Every card
any tier plays, it plays correctly.

The lobby workshop gains a tier picker beside the temperaments
(default adept); the tier persists with the seat, shows in roster and
scoresheet ("⚙ apprentice mystery"), and rides the drive loop.
Measured where it should matter: in berserker combat mirrors the
archmage beats the apprentice two to one, while pure treasure races
stay honest — the handicap lives in the card exchanges a human
actually feels, pinned deterministically in the tournament suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 17:59:11 -04:00
co-authored by Claude Fable 5
parent a6ede6ca1e
commit b9c784a10c
7 changed files with 115 additions and 36 deletions
+50 -18
View File
@@ -14,6 +14,32 @@ import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
/**
* Difficulty degrades resources and repertoire, never judgment. The
* apprentice draws one card a turn and knows a modest spellbook; the adept
* draws two but keeps no ambushes or amplifies; the archmage knows all.
* Every tier plays its cards correctly — none of them is ever stupid.
*/
export type AutomatonTier = "apprentice" | "adept" | "archmage";
export const AUTOMATON_TIERS: AutomatonTier[] = ["apprentice", "adept", "archmage"];
interface TierTraits {
draw: number;
/** Added to every counteraction threshold: thrift, not blindness. */
counterThrift: number;
afflictions: boolean;
amplify: boolean;
ambush: boolean;
guardGold: boolean;
buffs: boolean;
dejaVu: boolean;
}
const TIERS: Record<AutomatonTier, TierTraits> = {
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false },
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true },
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true },
};
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false },
@@ -221,7 +247,7 @@ function escapeCell(view: GameView, from: Cell): Cell | null {
}
/** Counteraction judgment; the worrier flinches at less. */
function respond(view: GameView, style: AutomatonStyle): Command {
function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Command {
const stack = view.stack!;
const you = view.you;
const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
@@ -239,7 +265,7 @@ function respond(view: GameView, style: AutomatonStyle): Command {
return { type: "pass" };
}
const flinch = style === "worrier" ? 1 : 0;
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
@@ -268,7 +294,7 @@ function respond(view: GameView, style: AutomatonStyle): Command {
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 };
if (absorb && incoming >= 2 - flinch && 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.
@@ -292,13 +318,13 @@ function respond(view: GameView, style: AutomatonStyle): Command {
}
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): 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");
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null;
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
@@ -364,7 +390,7 @@ function summonSpot(view: GameView, near: Cell): Cell | null {
}
/** Self-buffs and housekeeping worth a neutral cast this turn. */
function selfCare(view: GameView, style: AutomatonStyle): Command | null {
function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Command | null {
const self = me(view);
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
@@ -383,6 +409,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
return { type: "cast", instanceId: c.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones
// 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,
@@ -429,9 +456,9 @@ 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,
);
const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
: undefined;
if (myFloorTreasure && enemyNear) {
const safe = inHand(view, "safe");
if (safe) {
@@ -448,7 +475,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
}
}
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
const dv = inHand(view, "deja-vu");
const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
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,
@@ -458,7 +485,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
}
}
// An ambush costs nothing to hold and everything to walk into.
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
const via = tier.ambush ? (inHand(view, "interrupt") ?? inHand(view, "opportunity-fire")) : undefined;
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
c.cardId !== "blaster-wand");
if (via && spell && view.yourAmbushes.length === 0) {
@@ -472,8 +499,13 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
* One decision from the automaton's seat, or null when the maze is not
* asking it anything.
*/
export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): Command | null {
export function automatonCommand(
view: GameView,
style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage",
): Command | null {
const you = view.you as PlayerId;
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
if (view.pendingDiscard === you) {
@@ -486,7 +518,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
: { type: "pass" };
}
if (view.stack) {
return view.stack.waitingOn === you ? respond(view, style) : null;
return view.stack.waitingOn === you ? respond(view, style, tier) : null;
}
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
if (view.activePlayerId !== you) return null;
@@ -494,7 +526,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
const self = me(view);
const here = cellKey(self.position);
if (view.turn.actionsEnded) return { type: "endTurn", draw: 2 };
if (view.turn.actionsEnded) return { type: "endTurn", draw: tier.draw };
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
@@ -517,7 +549,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
}
}
const care = selfCare(view, style);
const care = selfCare(view, style, tier);
if (care) return care;
// Command the menagerie: creatures march and maul before the wizard moves.
@@ -555,9 +587,9 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
};
}
}
const spell = bestAttack(view, target.id);
const spell = bestAttack(view, target.id, tier);
if (spell) return spell;
const misery = bestAffliction(view, target.id, thief?.id === target.id);
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
if (misery) return misery;
if (cellKey(target.position) === here && style !== "worrier") {
return { type: "punch", targetId: target.id };
@@ -617,7 +649,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
}
}
return { type: "endTurn", draw: 2 };
return { type: "endTurn", draw: tier.draw };
}
/** The safe fallback when the automaton's choice was refused. */
+19 -3
View File
@@ -6,7 +6,7 @@ import {
type PlayerId,
} from "../src/game";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle } from "../src/automaton";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
@@ -20,9 +20,13 @@ function actingSeat(state: GameState): PlayerId {
}
/** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(seed: number, players: number, expansion = true, styles: AutomatonStyle[] = []) {
function playOut(
seed: number, players: number, expansion = true,
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
let { state } = createGame({
playerIds: ids,
seed,
@@ -35,7 +39,7 @@ function playOut(seed: number, players: number, expansion = true, styles: Automa
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat)) ?? automatonFallback(view);
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
const fb = automatonFallback(view);
@@ -85,6 +89,18 @@ describe("automaton vs automaton", () => {
expect(state.phase).toBe("finished");
});
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
// Deterministic across these seeds: same brains, same dice.
let arch = 0, appr = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
const { state } = playOut(seed, 2, true,
["berserker", "berserker"], ["archmage", "apprentice"]);
if (state.winner === "bot1") arch++;
if (state.winner === "bot2") appr++;
}
expect(arch).toBeGreaterThan(appr);
});
it("every temperament finishes its wars", () => {
for (const styles of [
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],