diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index aab1e59..5c9046c 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -2,30 +2,29 @@ // the same information a human seat receives — and returns one command per // consultation; the server keeps consulting until the maze stops asking. // It never throws: when in doubt it passes, discards, or ends its turn. +// +// Temperaments color its judgment: the HUNTER marches for treasure, the +// BERSERKER for blood, the WORRIER for the shadows between the two. import { cardDef, type CardInstance } from "./cards"; import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board"; import { sightedCellsFor, type GameView } from "./view"; import type { Command, PlayerId } from "./game"; -/** Damage attacks simple enough for clockwork: id -> flat damage dealt. */ -const SIMPLE_ATTACKS: Record = { - fireball: 5, - "sudden-death": 10, - powerthrust: 2, - dagger: 1, - "large-rock": 2, +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 = { + 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 }, + "large-rock": { base: 2, perNumber: false }, }; -/** Cards the automaton happily discards to churn its hand. */ -function discardValue(c: CardInstance): number { - const def = cardDef(c.cardId); - if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 8; - if (SIMPLE_ATTACKS[c.cardId] != null) return 7; - if (def.cardType === "number") return 4 + (def.value ?? 0); - if (def.cardType === "attack") return 3; - return 2; // situational neutrals go first -} +const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]); function me(view: GameView) { return view.players.find((p) => p.id === view.you)!; @@ -35,14 +34,45 @@ function livingEnemies(view: GameView) { return view.players.filter((p) => p.alive && p.id !== view.you); } -/** BFS over walkable steps; returns the first direction of a shortest path. */ -function firstStepToward(view: GameView, from: Cell, goals: Set): Side | null { +function numbersInHand(view: GameView): CardInstance[] { + return view.yourHand + .filter((c) => cardDef(c.cardId).cardType === "number") + .sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0)); +} + +/** Cards the automaton happily discards to churn its hand, worst first. */ +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)) return 8; + if (ATTACKS[c.cardId] != null) return 7; + if (def.cardType === "number") return 4 + (def.value ?? 0); + if (def.cardType === "attack") return 3; + return 2; // situational neutrals go first +} + +interface PathResult { + dir: Side; + distance: number; +} + +/** BFS over walkable steps toward the nearest goal; hazard-shy, enemy-shy optional. */ +function pathToward( + view: GameView, + from: Cell, + goals: Set, + avoidNearEnemies: boolean, +): PathResult | null { if (goals.size === 0 || goals.has(cellKey(from))) return null; + const enemyCells = livingEnemies(view).map((p) => p.position); + const nearEnemy = (c: Cell) => + enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2); const cameBy = new Map(); const seen = new Set([cellKey(from)]); let frontier: Cell[] = [from]; let found: string | null = null; - for (let depth = 0; depth < 60 && frontier.length > 0 && !found; depth++) { + let depth = 0; + for (; depth < 60 && frontier.length > 0 && !found; depth++) { const next: Cell[] = []; for (const c of frontier) { for (const dir of SIDES) { @@ -54,11 +84,10 @@ function firstStepToward(view: GameView, from: Cell, goals: Set): Side | seen.add(k); cameBy.set(k, { prev: cellKey(c), dir }); if (goals.has(k)) { found = k; break; } - // The automaton refuses to path THROUGH hazards, but will end on one - // only if it is the goal itself. const hazard = view.squareContents[k]?.kind; if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || hazard === "rosebush" || hazard === "slime") continue; + if (avoidNearEnemies && nearEnemy(t.to)) continue; next.push(t.to); } if (found) break; @@ -69,12 +98,12 @@ function firstStepToward(view: GameView, from: Cell, goals: Set): Side | let cursor = found; for (;;) { const hop = cameBy.get(cursor)!; - if (hop.prev === cellKey(from)) return hop.dir; + if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth }; cursor = hop.prev; } } -/** The treasure squares worth marching for, best first. */ +/** The treasure squares worth marching for. */ function treasureGoals(view: GameView): Set { const self = me(view); const goals = new Set(); @@ -85,8 +114,6 @@ function treasureGoals(view: GameView): Set { for (const t of view.treasures) { if (!t.position || t.carriedBy) continue; if (t.owner === view.you) continue; - // A treasure already delivered to one of MY... any floor treasure of an - // enemy is worth taking; skip ones resting on my own home (already won). if (cellKey(t.position) === cellKey(self.home)) continue; goals.add(cellKey(t.position)); } @@ -94,8 +121,7 @@ function treasureGoals(view: GameView): Set { } function overLimit(view: GameView): number { - const limit = 7; - return Math.max(0, view.yourHand.length - limit); + return Math.max(0, view.yourHand.length - 7); } function worstCards(view: GameView, n: number): string[] { @@ -105,38 +131,88 @@ function worstCards(view: GameView, n: number): string[] { .map((c) => c.instanceId); } -/** What the automaton does when the maze wants a response from it. */ -function respond(view: GameView): Command { +/** Counteraction judgment; the worrier flinches at less. */ +function respond(view: GameView, style: AutomatonStyle): Command { const stack = view.stack!; if (stack.defenderId !== view.you) return { type: "pass" }; - const incoming = stack.attackCard ? (SIMPLE_ATTACKS[stack.attackCard.cardId] ?? 2) : 1; + const flinch = style === "worrier" ? 1 : 0; + const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null; + const incoming = stack.attackCard + ? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) + : 1; const hand = view.yourHand; const find = (id: string) => hand.find((c) => c.cardId === id); if (stack.kind === "spell") { const shield = find("full-shield"); - if (shield && incoming >= 3) return { type: "counteract", instanceId: shield.instanceId }; + if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId }; const reflect = find("full-reflection"); - if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId }; + if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId }; } const blunt = find("blunt"); - if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId }; + if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; return { type: "pass" }; } +/** The best attack available against a visible target, numbers 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; + let best: { cmd: Command; damage: number } | null = null; + for (const c of view.yourHand) { + const atk = ATTACKS[c.cardId]; + if (!atk) continue; + const withNumber = atk.perNumber && biggest; + const damage = atk.base + (withNumber ? biggestValue : 0); + if (damage <= 0) continue; + if (!best || damage > best.damage) { + best = { + damage, + cmd: { + type: "cast", instanceId: c.instanceId, + target: { kind: "player", playerId: targetId }, + ...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), + }, + }; + } + } + return best?.cmd ?? null; +} + +/** An empty, sighted square near the target for a summoned creature. */ +function summonSpot(view: GameView, near: Cell): Cell | null { + const sighted = sightedCellsFor(view); + let best: { cell: Cell; d: number } | null = null; + for (const k of sighted) { + if (view.squareContents[k]) continue; + const [x, y] = k.split(",").map(Number) as [number, number]; + if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue; + if (view.board.homes.some((h) => h.x === x && h.y === y)) continue; + const d = Math.abs(x - near.x) + Math.abs(y - near.y); + if (!best || d < best.d) best = { cell: { x, y }, d }; + } + return best?.cell ?? null; +} + /** * One decision from the automaton's seat, or null when the maze is not * asking it anything. */ -export function automatonCommand(view: GameView): Command | null { +export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): Command | null { const you = view.you as PlayerId; if (view.phase !== "playing") return null; if (view.pendingDiscard === you) { return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; } - if (view.chaosPending && view.chaosPending.queue[0] === you) return { type: "pass" }; + if (view.chaosPending && view.chaosPending.queue[0] === you) { + const shield = view.yourHand.find((c) => c.cardId === "full-shield"); + return style === "worrier" && shield + ? { type: "counteract", instanceId: shield.instanceId } + : { type: "pass" }; + } if (view.stack) { - return view.stack.waitingOn === you ? respond(view) : null; + return view.stack.waitingOn === you ? respond(view, style) : null; } if (view.outOfTurnWindow?.playerId === you) return { type: "pass" }; if (view.activePlayerId !== you) return null; @@ -156,34 +232,70 @@ export function automatonCommand(view: GameView): Command | null { if (prize) return { type: "pickUpTreasure" }; } - // One attack per turn: the nearest visible enemy eats the best simple spell. + // Command the menagerie: creatures march and maul before the wizard moves. + for (const c of view.creatures) { + if (c.controllerId !== you || c.justCreated) continue; + const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position)); + if (enemyHere && !c.attackUsed && + (c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) { + return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id }; + } + if (c.movementUsed < c.movesPerTurn) { + const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); + const hunt = pathToward(view, c.position, enemyCells, false); + if (hunt) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir }; + } + } + + // One attack per turn. if (!view.turn.attackUsed && view.turn.round > 1) { const sighted = sightedCellsFor(view); const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position))); if (visible.length > 0) { - const target = visible[0]!; - const spells = view.yourHand - .filter((c) => SIMPLE_ATTACKS[c.cardId] != null) - .sort((a, b) => SIMPLE_ATTACKS[b.cardId]! - SIMPLE_ATTACKS[a.cardId]!); - if (spells.length > 0) { - return { - type: "cast", instanceId: spells[0]!.instanceId, - target: { kind: "player", playerId: target.id }, - }; + const target = visible.sort((a, b) => a.life - b.life)[0]!; + const spell = bestAttack(view, target.id); + if (spell) return spell; + // The berserker summons help where the hunter saves the card. + if (cellKey(target.position) === here && style !== "worrier") { + return { type: "punch", targetId: target.id }; + } + } + // No shot at a wizard: raise a creature to do the walking. + const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId)); + if (summon && livingEnemies(view).length > 0) { + const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position; + const spot = summonSpot(view, near); + if (spot) { + return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } }; } - if (cellKey(target.position) === here) return { type: "punch", targetId: target.id }; } } - // March toward the objective. + // March. The berserker hunts wizards over gold; the worrier keeps its + // distance; the hunter goes where the treasure is. if (view.turn.movementUsed < view.turn.movementAllowance) { - const goals = treasureGoals(view); - const dir = firstStepToward(view, self.position, goals); - if (dir) return { type: "move", direction: dir }; - // No treasure path: close on the nearest enemy for a brawl. + const gold = treasureGoals(view); const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); - const hunt = firstStepToward(view, self.position, enemyCells); - if (hunt) return { type: "move", direction: hunt }; + const objectives = style === "berserker" && !me(view).carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; + const path = pathToward(view, self.position, objectives, style === "worrier"); + const fallbackPath = path ?? pathToward(view, self.position, objectives, false) ?? + pathToward(view, self.position, enemyCells, false); + if (fallbackPath) { + // A number card closes the gap when the goal is just out of stride. + const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; + if (!view.turn.numberPlayedForMovement && fallbackPath.distance > movesLeft) { + const numbers = numbersInHand(view); + const helper = numbers.find( + (n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= fallbackPath.distance, + ) ?? (fallbackPath.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined); + if (helper) { + return { type: "playNumberForMovement", instanceId: helper.instanceId }; + } + } + return { type: "move", direction: fallbackPath.dir }; + } } return { type: "endTurn", draw: 2 }; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 64587ea..eded878 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 } from "../src/automaton"; +import { automatonCommand, automatonFallback, type AutomatonStyle } from "../src/automaton"; /** Whose input does the maze want right now? */ function actingSeat(state: GameState): PlayerId { @@ -20,8 +20,9 @@ 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) { +function playOut(seed: number, players: number, expansion = true, styles: AutomatonStyle[] = []) { const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`); + const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"])); let { state } = createGame({ playerIds: ids, seed, @@ -34,7 +35,7 @@ function playOut(seed: number, players: number, expansion = true) { while (state.phase === "playing" && commands < CAP) { const seat = actingSeat(state); const view = viewFor(state, seat); - const cmd = automatonCommand(view) ?? automatonFallback(view); + const cmd = automatonCommand(view, styleOf.get(seat)) ?? automatonFallback(view); let r = applyCommand(state, seat, cmd); if (!r.ok) { const fb = automatonFallback(view); @@ -83,4 +84,17 @@ describe("automaton vs automaton", () => { const { state } = playOut(7, 4); expect(state.phase).toBe("finished"); }); + + it("every temperament finishes its wars", () => { + for (const styles of [ + ["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"], + ] as AutomatonStyle[][]) { + let finished = 0; + for (const seed of [3, 17, 29]) { + const { state } = playOut(seed, 2, true, styles); + if (state.phase === "finished") finished++; + } + expect(finished).toBeGreaterThanOrEqual(2); + } + }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ec814e6..45f6d10 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -172,7 +172,7 @@ function roomInfo(room: Room) { hostId: room.hostId, started: room.state !== null, colors: Object.fromEntries(room.colorChoices), - bots: [...room.bots], + bots: Object.fromEntries(room.bots), }; } @@ -282,7 +282,7 @@ 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); + const result = addAutomaton(room, typeof msg.style === "string" ? msg.style : 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 e6537a7..f5dfecc 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -17,7 +17,12 @@ import { } from "@wizwar/engine"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; -import { automatonCommand, automatonFallback } from "@wizwar/engine"; +import { + automatonCommand, + automatonFallback, + AUTOMATON_STYLES, + type AutomatonStyle, +} from "@wizwar/engine"; export interface LoggedCommand { seq: number; @@ -42,8 +47,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. */ - bots: Set; + /** Seats the server itself plays, and each one's temperament. */ + bots: Map; } const rooms = new Map(); @@ -101,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { log: [], events: [], chat: [], - bots: new Set(), + bots: new Map(), }; rooms.set(room.id, room); recordRoom(room); @@ -237,14 +242,17 @@ 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): { name: PlayerId } | { error: string } { +export function addAutomaton(room: Room, styleWanted?: 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)); if (!name) return { error: "the workshop is empty" }; + const style: AutomatonStyle = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle) + ? (styleWanted as AutomatonStyle) + : AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!; room.players.push(name); - room.bots.add(name); - appendLine(room.id, { kind: "join", name, bot: true }); + room.bots.set(name, style); + appendLine(room.id, { kind: "join", name, bot: true, style }); return { name }; } @@ -269,7 +277,7 @@ 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) ?? automatonFallback(view); + const cmd = automatonCommand(view, room.bots.get(seat)) ?? automatonFallback(view); let r = runCommand(room, seat, cmd); if ("error" in r) { r = runCommand(room, seat, automatonFallback(view)); @@ -480,13 +488,13 @@ export function loadPersistedRooms(): void { log: [], events: [], chat: [], - bots: new Set(), + bots: new Map(), }; for (const line of lines.slice(1)) { if (line.kind === "join") { if (line.bot) { room.players.push(line.name); - room.bots.add(line.name); + room.bots.set(line.name, (line.style as AutomatonStyle) ?? "hunter"); } else { const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); if (!joinHash) throw new Error("join line has no token"); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index c28fdd6..f63d107 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -26,6 +26,8 @@ export interface JoinLine { token?: string; /** An automaton seat: no token; the server plays it. */ bot?: true; + /** The automaton's temperament. */ + style?: string; } export interface StartLine { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index deb224b..31bc322 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1028,7 +1028,7 @@ {:else} {/if} - {p}{p === net.hostId ? " — host" : ""}{net.roomBots.includes(p) ? " ⚙" : chosen === undefined ? " — choosing…" : ""} + {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} {/each} @@ -1050,7 +1050,12 @@ {#if net.you === net.hostId} {#if net.players.length < 6} - + + ⚙ seat an automaton: + + + + {/if}