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:
co-authored by
Claude Fable 5
parent
a6ede6ca1e
commit
b9c784a10c
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user