diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 35a287c..c68c5b8 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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 = { + 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 = { 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. */ diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index eded878..fe6032c 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -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"], diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e2bed1e..6bb5f57 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index afa21c8..267f99d 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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; + /** Seats the server itself plays: temperament, tier, and secrecy. */ + bots: Map; } const rooms = new Map(); @@ -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); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 6a3f71d..76cebde 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -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; } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 55b5d6d..6bd71c4 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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(null); /** A discard-pile card enlarged above the pile. */ @@ -1051,11 +1052,16 @@ {#if net.you === net.hostId} {#if net.players.length < 6} - ⚙ seat an automaton: - - - - + ⚙ seat a + + + + + {/if}