diff --git a/.gitignore b/.gitignore index 86c783f..8d2e958 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .DS_Store data/ .claude/ +.playwright-mcp/ diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts new file mode 100644 index 0000000..c68c5b8 --- /dev/null +++ b/packages/engine/src/automaton.ts @@ -0,0 +1,663 @@ +// The automaton: a clockwork wizard. It plays from its own redacted view — +// 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, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board"; +import { sightedCellsFor, type GameView } from "./view"; +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 }, + "sudden-death": { base: 10, perNumber: false }, + powerthrust: { base: 2, perNumber: true }, + waterbolt: { base: 0, perNumber: true, needsNumber: true }, + "lightning-blast": { base: 0, perNumber: true, needsNumber: true }, + wizardblade: { base: 0, perNumber: true, needsNumber: true }, + "power-drain": { base: 0, perNumber: true, needsNumber: true }, + dagger: { base: 3, perNumber: false }, + "large-rock": { base: 2, perNumber: false }, + "blaster-wand": { base: 3, perNumber: false }, + disease: { base: 3, perNumber: false, sameSquare: true }, +}; + +/** + * Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a + * number for the duration where one helps. + */ +const AFFLICTIONS: Record = { + blind: { withNumber: true }, + slow: { withNumber: true }, + "no-spell": { withNumber: true }, + medusa: { withNumber: true }, + "lock-in-place": { withNumber: true }, + "walking-dead": { withNumber: false }, + "slow-death": { withNumber: false }, + idiot: { withNumber: false }, + weakness: { withNumber: false, vsCarrier: true }, + "go-away": { withNumber: true }, + "thought-steal": { withNumber: false }, +}; + +/** Every stone earns its place on the table the moment it is drawn. */ +const STONES = new Set([ + "bloodstone", "brainstone", "powerstone", "shadowstone", + "shieldstone", "soulstone", "speedstone", "visionstone", +]); + +const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]); +const UNLOCKS = ["master-key", "pick-lock", "remove-lock"]; + +function me(view: GameView) { + return view.players.find((p) => p.id === view.you)!; +} + +function livingEnemies(view: GameView) { + return view.players.filter((p) => p.alive && p.id !== view.you); +} + +function inHand(view: GameView, cardId: string): CardInstance | undefined { + return view.yourHand.find((c) => c.cardId === cardId); +} + +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) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8; + if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7; + if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" || + c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" || + c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" || + c.cardId === "glue") return 6; + 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; + /** A locked door stands on the first step of this path. */ + doorAhead?: { cell: Cell; side: Side }; +} + +/** + * BFS over walkable steps toward the nearest goal. Doors count as passable + * when the clockwork can unlock them; the first such door is reported so the + * key gets used before the boot. + */ +function pathToward( + view: GameView, + from: Cell, + goals: Set, + opts: { avoidNearEnemies?: boolean; canUnlock?: 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; + let depth = 0; + for (; depth < 60 && frontier.length > 0 && !found; depth++) { + const next: Cell[] = []; + for (const c of frontier) { + for (const dir of SIDES) { + const t = stepTarget(view.board, c, dir); + let to: Cell; + let viaDoor = false; + if (t.kind === "blocked") { + if (t.by !== "door") continue; + const k = edgeKey(c, dir); + const alreadyOpen = + view.openDoorEdges.includes(k) || view.doorStates[k] === "removed"; + if (!alreadyOpen && !opts.canUnlock) continue; + const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0), + y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) }; + if (!view.board.cells[cellKey(n)]) continue; + to = n; + viaDoor = !alreadyOpen; + } else { + to = t.to; + } + const k = cellKey(to); + if (seen.has(k)) continue; + if (view.squareContents[k]?.kind === "stone") continue; + seen.add(k); + cameBy.set(k, { prev: cellKey(c), dir, viaDoor }); + if (goals.has(k)) { found = k; break; } + const hazard = view.squareContents[k]?.kind; + if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || + hazard === "rosebush" || hazard === "slime") continue; + if (opts.avoidNearEnemies && nearEnemy(to)) continue; + next.push(to); + } + if (found) break; + } + frontier = next; + } + if (!found) return null; + let cursor = found; + for (;;) { + const hop = cameBy.get(cursor)!; + if (hop.prev === cellKey(from)) { + return { + dir: hop.dir, + distance: depth, + ...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}), + }; + } + cursor = hop.prev; + } +} + +/** The treasure squares worth marching for. */ +function treasureGoals(view: GameView): Set { + const self = me(view); + const goals = new Set(); + if (self.carriedTreasureId) { + goals.add(cellKey(self.home)); + return goals; + } + for (const t of view.treasures) { + if (!t.position || t.carriedBy) continue; + if (t.owner === view.you) continue; + if (cellKey(t.position) === cellKey(self.home)) continue; + goals.add(cellKey(t.position)); + } + return goals; +} + +/** The wizard making off with MY gold, if any. */ +function thiefOfMine(view: GameView) { + const carriers = new Set( + view.treasures.filter((t) => t.owner === view.you && t.carriedBy && t.carriedBy !== view.you) + .map((t) => t.carriedBy!), + ); + return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null; +} + +function overLimit(view: GameView): number { + return Math.max(0, view.yourHand.length - 7); +} + +function worstCards(view: GameView, n: number): string[] { + return [...view.yourHand] + .sort((a, b) => discardValue(a) - discardValue(b)) + .slice(0, n) + .map((c) => c.instanceId); +} + +/** A square up to four spaces away that maximizes distance from enemies. */ +function escapeCell(view: GameView, from: Cell): Cell | null { + const enemies = livingEnemies(view).map((p) => p.position); + let best: { cell: Cell; score: number } | null = null; + for (const k of Object.keys(view.board.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + const d = Math.abs(x - from.x) + Math.abs(y - from.y); + if (d === 0 || d > 4) continue; + if (view.squareContents[k]) continue; + const score = Math.min(...enemies.map((e) => Math.abs(e.x - x) + Math.abs(e.y - y)), 99); + if (!best || score > best.score) best = { cell: { x, y }, score }; + } + return best?.cell ?? null; +} + +/** Counteraction judgment; the worrier flinches at less. */ +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); + + if (stack.defenderId !== you) { + // Pressing the attack: ANTI-ANTI burns the counter that would stop us + // (the engine refuses it against escapes; the fallback pass covers that). + if (stack.attackerId === you) { + const last = [...stack.counters].reverse().find((c) => !c.nullified); + const anti = find("anti-anti"); + if (anti && last && last.card.cardId !== "teleport") { + return { type: "counteract", instanceId: anti.instanceId }; + } + } + return { type: "pass" }; + } + + 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; + const incoming = stack.attackCard + ? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) + : 1; + if (stack.kind === "spell") { + // REVERSE turns the biggest blasts into a meal. + const reverse = find("reverse"); + if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; + // ABSORB SPELL steals the good ones for later. + const absorbSpell = find("absorb-spell"); + if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId }; + const shield = find("full-shield"); + if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) { + return { type: "counteract", instanceId: shield.instanceId }; + } + const reflect = find("full-reflection"); + if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId }; + // A curse in flight is best refused at the door. + if (affliction) { + const cleanse = find("remove-curse"); + if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId }; + } + const half = find("reflection"); + if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId }; + } + const absorb = find("absorb"); + 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. + const empathy = find("empathy"); + if (empathy && style === "berserker" && incoming >= 2) { + return { type: "counteract", instanceId: empathy.instanceId }; + } + // Nothing to block with: teleport clear of the big ones. + const tp = find("teleport"); + if (tp && incoming >= 4 - flinch) { + const out = escapeCell(view, me(view).position); + if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; + } + // A displayed shieldstone lets numbers soak the small ones. + const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone"); + if (stoneShown && incoming >= 2) { + const num = numbersInHand(view)[0]; + if (num) return { type: "counteract", instanceId: num.instanceId }; + } + return { type: "pass" }; +} + +/** The best attack available against a visible target, numbers and amplify included. */ +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 = 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]; + if (!atk) continue; + if (atk.sameSquare && !together) continue; + // A wand needs its charges set by a number on first use. + const isWand = c.cardId === "blaster-wand"; + const uncharged = isWand && view.wandCharges[c.instanceId] == null; + if ((uncharged || atk.needsNumber) && !biggest) continue; + const withNumber = (atk.perNumber || uncharged) && biggest; + let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); + if (damage <= 0) continue; + // AMPLIFY doubles the heavy hitters (spells only, not thrown things). + const amplified = amplify && damage >= 4 && !isWand && + c.cardId !== "dagger" && c.cardId !== "large-rock"; + if (amplified) damage *= 2; + if (!best || damage > best.damage) { + best = { + damage, + cmd: { + type: "cast", instanceId: c.instanceId, + target: { kind: "player", playerId: targetId }, + ...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), + ...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), + }, + }; + } + } + return best?.cmd ?? null; +} + +/** An affliction worth casting when no damage lands, mid number attached. */ +function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null { + const numbers = numbersInHand(view); + const mid = numbers[Math.floor(numbers.length / 2)]; + for (const c of view.yourHand) { + const aff = AFFLICTIONS[c.cardId]; + if (!aff) continue; + if (aff.vsCarrier && !isThief) continue; + if (aff.withNumber && !mid) continue; + return { + type: "cast", instanceId: c.instanceId, + target: { kind: "player", playerId: targetId }, + ...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}), + }; + } + return 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; +} + +/** Self-buffs and housekeeping worth a neutral cast this turn. */ +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)]; + + // Extra turns are always good turns. + const speed = inHand(view, "speed"); + if (speed && view.turn.round > 1 && !view.turn.actionsEnded) { + return { type: "cast", instanceId: speed.instanceId }; + } + // Free life is free. + const gift = inHand(view, "gift-from-above"); + if (gift) return { type: "cast", instanceId: gift.instanceId }; + // Every stone goes on the table the turn it is drawn. + for (const c of view.yourHand) { + if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) { + 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, + ); + const cleanse = inHand(view, "remove-curse"); + if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId }; + // Three or more wizards: LIFESAVER takes elimination off the table. + const lifesaver = inHand(view, "lifesaver"); + if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId }; + // The ward guards the gold while its owner is away robbing yours. + if (inHand(view, "ward") && !view.yourWardArmed) { + return { type: "armWard", armed: true }; + } + const enemyNear = livingEnemies(view).some( + (p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3, + ); + // The berserker grows; the worrier fades. + if (style === "berserker" && enemyNear && mid) { + const big = inHand(view, "big-man"); + if (big) { + return { type: "cast", instanceId: big.instanceId, numberInstanceIds: [mid.instanceId] }; + } + } + if (style === "worrier" && enemyNear && mid) { + for (const id of ["invisible", "mist-body", "shrink"]) { + const buff = inHand(view, id); + if (buff) { + return { type: "cast", instanceId: buff.instanceId, numberInstanceIds: [mid.instanceId] }; + } + } + // Or slam a wall in the pursuer's face. + const wall = inHand(view, "create-wall"); + if (wall) { + const foe = livingEnemies(view).sort((a, b) => + (Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) - + (Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0]!; + const dx = foe.position.x - self.position.x; + const dy = foe.position.y - self.position.y; + const side: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0 + ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; + if ((view.board.edges[edgeKey(self.position, side)] ?? "open") === "open") { + return { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: self.position, side } }; + } + } + } + // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down. + 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) { + return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } }; + } + const glue = inHand(view, "glue"); + const midN = numbers[Math.floor(numbers.length / 2)]; + if (glue && midN) { + return { + type: "cast", instanceId: glue.instanceId, + target: { kind: "cell", cell: myFloorTreasure.position! }, + numberInstanceIds: [midN.instanceId], + }; + } + } + // Empty of violence: DEJA-VU pulls the best attack back from the pile. + 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, + ); + if (buried) { + return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } }; + } + } + // An ambush costs nothing to hold and everything to walk into. + 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) { + const trigger: AmbushTrigger = style === "worrier" ? { kind: "near" } : { kind: "treasure" }; + return { type: "setAmbush", instanceId: via.instanceId, trigger, spellInstanceId: spell.instanceId }; + } + return 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", + 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) { + return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; + } + if (view.chaosPending && view.chaosPending.queue[0] === you) { + const shield = inHand(view, "full-shield"); + return style === "worrier" && shield + ? { type: "counteract", instanceId: shield.instanceId } + : { type: "pass" }; + } + if (view.stack) { + return view.stack.waitingOn === you ? respond(view, style, tier) : null; + } + if (view.outOfTurnWindow?.playerId === you) return { type: "pass" }; + if (view.activePlayerId !== you) return null; + + const self = me(view); + const here = cellKey(self.position); + + 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" }; + if (!self.carriedTreasureId) { + const prize = view.treasures.find( + (t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && + here !== cellKey(self.home), + ); + if (prize) return { type: "pickUpTreasure" }; + } + + // Wounded clockwork teleports clear of visible hunters. + if (self.life <= 5) { + const sighted = sightedCellsFor(view); + const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position))); + const tp = inHand(view, "teleport"); + if (hunted && tp) { + const out = escapeCell(view, self.position); + if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } }; + } + } + + const care = selfCare(view, style, tier); + if (care) return care; + + // 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, {}); + if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir }; + } + } + + const thief = thiefOfMine(view); + + // One attack per turn: the thief of my gold dies first, then the weakest. + 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 = thief && visible.some((p) => p.id === thief.id) + ? thief + : visible.sort((a, b) => a.life - b.life)[0]!; + // DROP OBJECT shakes my treasure out of the thief's hands. + if (thief && target.id === thief.id) { + const dob = inHand(view, "drop-object"); + if (dob) { + return { + type: "cast", instanceId: dob.instanceId, + target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, + }; + } + } + const spell = bestAttack(view, target.id, tier); + if (spell) return spell; + 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 }; + } + } + // 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 } }; + } + } + } + + // March. The thief-chase outranks everything; the berserker hunts wizards + // over gold; the worrier keeps its distance; the hunter goes to the gold. + if (view.turn.movementUsed < view.turn.movementAllowance) { + const gold = treasureGoals(view); + const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); + const canUnlock = UNLOCKS.some((id) => inHand(view, id)); + const objectives = thief + ? new Set([cellKey(thief.position)]) + : style === "berserker" && !self.carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; + const path = + pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ?? + pathToward(view, self.position, objectives, { canUnlock }) ?? + pathToward(view, self.position, enemyCells, { canUnlock }); + if (path) { + // A locked door on the very next step: use the key first. + if (path.doorAhead) { + for (const id of UNLOCKS) { + const key = inHand(view, id); + if (key) { + return { + type: "cast", instanceId: key.instanceId, + target: { kind: "edge", cell: path.doorAhead.cell, side: path.doorAhead.side }, + }; + } + } + } else { + const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; + if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) { + const numbers = numbersInHand(view); + const helper = numbers.find( + (n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance, + ) ?? (path.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined); + if (helper) { + return { type: "playNumberForMovement", instanceId: helper.instanceId }; + } + } + return { type: "move", direction: path.dir }; + } + } + } + + return { type: "endTurn", draw: tier.draw }; +} + +/** The safe fallback when the automaton's choice was refused. */ +export function automatonFallback(view: GameView): Command { + const you = view.you as PlayerId; + if (view.pendingDiscard === you) { + return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; + } + if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" }; + return { type: "endTurn", draw: 2 }; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 182157d..81cd801 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -9,3 +9,4 @@ export * from "./cards"; export * from "./setups"; export * from "./game"; export * from "./view"; +export * from "./automaton"; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts new file mode 100644 index 0000000..fe6032c --- /dev/null +++ b/packages/engine/test/automaton.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + createGame, + type GameState, + type PlayerId, +} from "../src/game"; +import { viewFor } from "../src/view"; +import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; + +/** Whose input does the maze want right now? */ +function actingSeat(state: GameState): PlayerId { + return ( + state.stack?.waitingOn ?? + state.pendingDiscard ?? + state.chaosPending?.queue[0] ?? + state.outOfTurnWindow?.playerId ?? + state.players[state.turn.activeIndex]!.id + ); +} + +/** Drive a full bot-vs-bot game; returns the final state and command count. */ +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, + sets: expansion ? ["basic", "expansion1"] : ["basic"], + deckRev: 8, + }); + let commands = 0; + let stuck = 0; + const CAP = 4000; + while (state.phase === "playing" && commands < CAP) { + const seat = actingSeat(state); + const view = viewFor(state, seat); + 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); + r = applyCommand(state, seat, fb); + if (!r.ok) { + stuck++; + if (stuck > 3) { + throw new Error( + `automaton stuck at seat ${seat} after ${commands} commands: ` + + `${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`, + ); + } + // Last-ditch: burn the turn structure forward. + r = applyCommand(state, seat, { type: "endTurn", draw: 0 }); + if (!r.ok) r = applyCommand(state, seat, { type: "pass" }); + if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`); + } + } else { + stuck = 0; + } + state = r.state; + commands++; + } + return { state, commands }; +} + +describe("automaton vs automaton", () => { + it("two clockwork wizards fight a game to its end", () => { + const { state, commands } = playOut(11, 2); + expect(state.phase).toBe("finished"); + expect(state.winner).not.toBeNull(); + expect(commands).toBeLessThan(4000); + }); + + it("holds up across many seeds without wedging", () => { + let finished = 0; + for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) { + const { state } = playOut(seed, 2); + if (state.phase === "finished") finished++; + } + // Cautious clockwork can stall a maze; most games must still conclude. + expect(finished).toBeGreaterThanOrEqual(6); + }); + + it("a full table of four automatons concludes", () => { + const { state } = playOut(7, 4); + 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"], + ] 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 4b9787e..6bb5f57 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -35,7 +35,9 @@ import { getRoom, joinRoom, loadPersistedRooms, + addAutomaton, addChat, + driveOneAutomaton, makeTransferCode, redactFor, roomCount, @@ -170,6 +172,9 @@ function roomInfo(room: Room) { hostId: room.hostId, started: room.state !== null, colors: Object.fromEntries(room.colorChoices), + bots: Object.fromEntries( + [...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]), + ), }; } @@ -181,6 +186,75 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo } } +/** + * The clockwork plays at a watchable pace: one command every beat, each + * broadcast as it lands, until the maze wants a human again. + */ +const BOT_STEP_MS = 1500; + +/** The clockwork occasionally speaks. Sparingly — menace over chatter. */ +const BOT_LINES: Record> = { + hunter: { + treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."], + treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."], + damaged: ["AN INEFFICIENT USE OF YOUR TURN."], + died: ["INVENTORY TRANSFERRED. CONDOLENCES."], + }, + berserker: { + treasurePickedUp: ["GOLD IS MERELY BAIT."], + damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."], + died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."], + creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."], + }, + worrier: { + treasurePickedUp: ["taking this. sorry. sorry."], + damaged: ["ow. logged.", "unnecessary!"], + wallCreated: ["good wall. safe wall."], + died: ["oh no. oh no. it worked?"], + }, +}; + +function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void { + const bot = room.bots.get(seat); + if (!bot) return; + const lines = BOT_LINES[bot.style] ?? {}; + for (const e of events) { + // Speak only about its own deeds, and rarely. + const mine = + (e.type === "treasurePickedUp" && e.player === seat) || + (e.type === "treasureDropped" && e.player === seat) || + (e.type === "damaged" && e.player !== seat) || + (e.type === "died" && e.killedBy === seat) || + (e.type === "creatureCreated" && e.controller === seat) || + (e.type === "wallCreated" && e.caster === seat); + const pool = lines[e.type]; + if (!mine || !pool || Math.random() > 0.5) continue; + const text = pool[Math.floor(Math.random() * pool.length)]!; + const said = addChat(room, seat, text); + if (!("error" in said)) { + broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at })); + } + return; // one remark per step at most + } +} +const pumping = new Set(); +function runBots(room: Room): void { + if (pumping.has(room.id)) return; + pumping.add(room.id); + const tick = () => { + const step = driveOneAutomaton(room); + if (!step) { + pumping.delete(room.id); + return; + } + broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) })); + broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); + botRemark(room, step.seat, step.events as { type: string }[]); + setTimeout(tick, BOT_STEP_MS); + }; + setTimeout(tick, 800); // a beat after the human's own action settles +} + function broadcastRoomState(room: Room): void { broadcast(room, () => roomInfo(room)); if (room.state) { @@ -250,6 +324,20 @@ wss.on("connection", (socket) => { send(socket, { type: "events", events: redactFor(room.events, name) }); } broadcastRoomState(room); + runBots(room); + break; + } + case "addBot": { + 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, + typeof msg.tier === "string" ? msg.tier : undefined, + ); + if ("error" in result) return send(socket, { type: "error", message: result.error }); + broadcastRoomState(room); break; } case "start": { @@ -260,6 +348,7 @@ wss.on("connection", (socket) => { if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcastRoomState(room); + runBots(room); break; } case "command": { @@ -272,6 +361,7 @@ wss.on("connection", (socket) => { if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); + runBots(room); break; } case "rollDie": { diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 6aa416f..267f99d 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -17,6 +17,14 @@ import { } from "@wizwar/engine"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; +import { + automatonCommand, + automatonFallback, + AUTOMATON_STYLES, + AUTOMATON_TIERS, + type AutomatonStyle, + type AutomatonTier, +} from "@wizwar/engine"; export interface LoggedCommand { seq: number; @@ -41,6 +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, tier, and secrecy. */ + bots: Map; } const rooms = new Map(); @@ -98,6 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { log: [], events: [], chat: [], + bots: new Map(), }; rooms.set(room.id, room); recordRoom(room); @@ -230,6 +241,68 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text return { text, at }; } +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, + 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)); + if (!name) return { error: "the workshop is empty" }; + const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle); + const style: AutomatonStyle = known + ? (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, tier }); + appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) }); + return { name }; +} + +/** Whose input does the maze want right now? */ +function actingSeat(room: Room): PlayerId | null { + const s = room.state; + if (!s || s.phase !== "playing") return null; + return ( + s.stack?.waitingOn ?? + s.pendingDiscard ?? + s.chaosPending?.queue[0] ?? + s.outOfTurnWindow?.playerId ?? + s.players[s.turn.activeIndex]!.id + ); +} + +/** + * One automaton command, if the maze is waiting on clockwork. Null when a + * human holds the floor (or the game is over, or the clockwork is wedged). + */ +export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null { + const seat = actingSeat(room); + if (!seat || !room.bots.has(seat)) return null; + const view = viewFor(room.state!, seat); + 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)); + if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 }); + if ("error" in r) r = runCommand(room, seat, { type: "pass" }); + if ("error" in r) { + console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`); + return null; + } + } + return { seat, events: r.events }; +} + export interface GameSummary { roomId: string; name: PlayerId; @@ -427,13 +500,23 @@ export function loadPersistedRooms(): void { log: [], events: [], chat: [], + bots: new Map(), }; for (const line of lines.slice(1)) { if (line.kind === "join") { - const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); - if (!joinHash) throw new Error("join line has no token"); - room.players.push(line.name); - room.tokens.set(line.name, joinHash); + if (line.bot) { + room.players.push(line.name); + 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); + if (!joinHash) throw new Error("join line has no token"); + room.players.push(line.name); + room.tokens.set(line.name, joinHash); + } } else if (line.kind === "start") { const r = startInMemory(room, line.expansion, line.colors, line.deckRev); if ("error" in r) throw new Error(`replay start failed: ${r.error}`); diff --git a/packages/server/src/stats.ts b/packages/server/src/stats.ts index 381fd61..b3b61da 100644 --- a/packages/server/src/stats.ts +++ b/packages/server/src/stats.ts @@ -24,6 +24,8 @@ export interface EngagementStats { firstGameAt: string | null; /** How many of the games were hotseat tables reporting in anonymously. */ hotseatGames: number; + /** Games with at least one clockwork wizard at the table. */ + automatonGames: number; } interface StatsFile extends Omit { @@ -40,7 +42,7 @@ let data: StatsFile = { commandsPlayed: 0, minutesAtTable: 0, winsByTreasure: 0, winsByLastStanding: 0, longestGameCommands: 0, fullestTable: 0, firstGameAt: null, - hotseatGames: 0, wizards: [], roomStages: {}, + hotseatGames: 0, automatonGames: 0, wizards: [], roomStages: {}, }; let wizardSet = new Set(); let loaded = false; @@ -84,6 +86,7 @@ export function recordRoom(room: Room): void { const stage = data.roomStages[room.id]; for (const p of room.players) { + if (room.bots.has(p)) continue; // the clockwork are not wizards seated const key = p.toLowerCase(); if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; } } @@ -95,6 +98,7 @@ export function recordRoom(room: Room): void { } if (room.state && data.roomStages[room.id] === "created") { data.gamesStarted++; + if (room.bots.size > 0) data.automatonGames++; data.fullestTable = Math.max(data.fullestTable, room.players.length); data.roomStages[room.id] = "started"; dirty = true; diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 5d15c0c..76cebde 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -24,6 +24,14 @@ export interface JoinLine { tokenHash?: string; /** Legacy plaintext token (pre-hashing files only). */ token?: string; + /** An automaton seat: no token; the server plays it. */ + 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; } export interface StartLine { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a32f0f7..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. */ @@ -1028,7 +1029,7 @@ {:else} {/if} - {p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""} + {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} {/each} @@ -1049,6 +1050,20 @@ {/each} {#if net.you === net.hostId} + {#if net.players.length < 6} + + ⚙ seat a + + + + + + + {/if}