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. */
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -173,7 +173,7 @@ function roomInfo(room: Room) {
|
||||
started: room.state !== null,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
[...room.bots].map(([name, b]) => [name, b.secret ? "mystery" : b.style]),
|
||||
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -331,7 +331,11 @@ wss.on("connection", (socket) => {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
|
||||
const result = addAutomaton(room, typeof msg.style === "string" ? msg.style : undefined);
|
||||
const result = addAutomaton(
|
||||
room,
|
||||
typeof msg.style === "string" ? msg.style : undefined,
|
||||
typeof msg.tier === "string" ? msg.tier : undefined,
|
||||
);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
automatonCommand,
|
||||
automatonFallback,
|
||||
AUTOMATON_STYLES,
|
||||
AUTOMATON_TIERS,
|
||||
type AutomatonStyle,
|
||||
type AutomatonTier,
|
||||
} from "@wizwar/engine";
|
||||
|
||||
export interface LoggedCommand {
|
||||
@@ -47,8 +49,8 @@ export interface Room {
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
/** Seats the server itself plays: temperament, and whether it is told. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean }>;
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -242,7 +244,11 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
|
||||
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
|
||||
|
||||
/** Seat a clockwork wizard (host's choice, before the game starts). */
|
||||
export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId } | { error: string } {
|
||||
export function addAutomaton(
|
||||
room: Room,
|
||||
styleWanted?: string,
|
||||
tierWanted?: string,
|
||||
): { name: PlayerId } | { error: string } {
|
||||
if (room.state) return { error: "the game has started" };
|
||||
if (room.players.length >= 6) return { error: "room is full" };
|
||||
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
|
||||
@@ -252,9 +258,12 @@ export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId
|
||||
? (styleWanted as AutomatonStyle)
|
||||
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||
const secret = !known; // the mystery machine keeps its mood to itself
|
||||
const tier: AutomatonTier = AUTOMATON_TIERS.includes(tierWanted as AutomatonTier)
|
||||
? (tierWanted as AutomatonTier)
|
||||
: "adept";
|
||||
room.players.push(name);
|
||||
room.bots.set(name, { style, secret });
|
||||
appendLine(room.id, { kind: "join", name, bot: true, style, ...(secret ? { secret: true } : {}) });
|
||||
room.bots.set(name, { style, secret, tier });
|
||||
appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
|
||||
return { name };
|
||||
}
|
||||
|
||||
@@ -279,7 +288,8 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const cmd = automatonCommand(view, room.bots.get(seat)?.style) ?? automatonFallback(view);
|
||||
const bot = room.bots.get(seat);
|
||||
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
|
||||
let r = runCommand(room, seat, cmd);
|
||||
if ("error" in r) {
|
||||
r = runCommand(room, seat, automatonFallback(view));
|
||||
@@ -499,6 +509,7 @@ export function loadPersistedRooms(): void {
|
||||
room.bots.set(line.name, {
|
||||
style: (line.style as AutomatonStyle) ?? "hunter",
|
||||
secret: line.secret === true,
|
||||
tier: (line.tier as AutomatonTier) ?? "archmage",
|
||||
});
|
||||
} else {
|
||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface JoinLine {
|
||||
bot?: true;
|
||||
/** The automaton's temperament. */
|
||||
style?: string;
|
||||
/** The automaton's difficulty tier. */
|
||||
tier?: string;
|
||||
/** A mystery machine: the temperament is not revealed to the table. */
|
||||
secret?: true;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
/** Leafing through the face-up discard pile. */
|
||||
let showDiscards = $state(false);
|
||||
let chatDraft = $state("");
|
||||
let botTier = $state("adept");
|
||||
/** Card whose official FAQ rulings are open. */
|
||||
let faqCardId = $state<string | null>(null);
|
||||
/** A discard-pile card enlarged above the pile. */
|
||||
@@ -1051,11 +1052,16 @@
|
||||
{#if net.you === net.hostId}
|
||||
{#if net.players.length < 6}
|
||||
<span class="bot-row">
|
||||
⚙ seat an automaton:
|
||||
<button class="stamp tiny" onclick={() => net.addBot("hunter")}>hunter</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("berserker")}>berserker</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("worrier")}>worrier</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot()}>mystery</button>
|
||||
⚙ seat a
|
||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
|
||||
<option value="apprentice">apprentice</option>
|
||||
<option value="adept">adept</option>
|
||||
<option value="archmage">archmage</option>
|
||||
</select>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
|
||||
</span>
|
||||
{/if}
|
||||
<label class="check">
|
||||
@@ -2012,6 +2018,14 @@
|
||||
.big-peek :global(.card:hover) { transform: scale(2.1); }
|
||||
.big-peek { display: flex; flex-direction: column; align-items: center; }
|
||||
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
|
||||
.tier-pick {
|
||||
background: #f6f0df;
|
||||
border: 1px solid #b3a687;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
color: #43331f;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
.bot-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -428,8 +428,8 @@ class Net {
|
||||
}
|
||||
|
||||
/** Ask the server how all our games are doing. */
|
||||
addBot(style?: string): void {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}) });
|
||||
addBot(style?: string, tier?: string): void {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
|
||||
Reference in New Issue
Block a user