From 3eda686e29ab28558c5b3fc0f7f997f9f6f7d53a Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 17:10:47 -0400 Subject: [PATCH] The full clockwork curriculum: keys, vengeance, spellcraft, and voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every play-strength item, in one education. DOORS: the pathfinder walks through doors it can open — already-opened ones freely, locked ones when holding Master Key or Pick Lock, casting the key at the door on the path before stepping through (and a loop where the clockwork re-keyed an already-open door forever is fixed: opened doors read as open). TREASURE DEFENSE: a wizard carrying the automaton's gold becomes the priority — chased over all objectives, shaken down with DROP OBJECT when held, and attacked first. WIDER SPELLCRAFT: blaster wands charged by number and fired; teleport as escape when wounded and hunted, and as a counteraction clear of big incoming spells; SPEED cast on sight; WARD armed to guard the gold; shieldstone displayed so spare numbers soak small hits; ANTI-ANTI pressed against counters (never against escapes); the berserker grows BIG with an enemy near, the worrier fades invisible or slams a CREATE WALL in its pursuer's face; ambushes armed from held interrupts — the worrier trapping its doorstep, the others trapping the treasure. Tournament suite green across all temperaments. And the clockwork speaks: sparing, temperament-voiced table talk on its own deeds — "ACQUISITION COMPLETE.", "SCHEDULED DEMISE: DELIVERED.", "good wall. safe wall." — through the same chat ledger as everyone else. The tally gains "games fought against the clockwork", and automatons no longer pollute the count of wizards seated. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 278 ++++++++++++++++++++++++++----- packages/server/src/index.ts | 47 ++++++ packages/server/src/stats.ts | 6 +- packages/web/src/Help.svelte | 1 + 4 files changed, 289 insertions(+), 43 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 5c9046c..1780de3 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -7,9 +7,9 @@ // 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 { cellKey, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board"; import { sightedCellsFor, type GameView } from "./view"; -import type { Command, PlayerId } from "./game"; +import type { AmbushTrigger, Command, PlayerId } from "./game"; export type AutomatonStyle = "hunter" | "berserker" | "worrier"; export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"]; @@ -22,9 +22,11 @@ const ATTACKS: Record = { waterbolt: { base: 0, perNumber: true }, dagger: { base: 1, perNumber: false }, "large-rock": { base: 2, perNumber: false }, + "blaster-wand": { base: 3, perNumber: false }, }; const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]); +const UNLOCKS = ["master-key", "pick-lock"]; function me(view: GameView) { return view.players.find((p) => p.id === view.you)!; @@ -34,6 +36,10 @@ 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") @@ -44,8 +50,10 @@ function numbersInHand(view: GameView): CardInstance[] { 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 (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId)) return 8; if (ATTACKS[c.cardId] != null) return 7; + if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" || + c.cardId === "ward" || c.cardId === "drop-object") return 6; if (def.cardType === "number") return 4 + (def.value ?? 0); if (def.cardType === "attack") return 3; return 2; // situational neutrals go first @@ -54,20 +62,26 @@ function discardValue(c: CardInstance): number { 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; hazard-shy, enemy-shy optional. */ +/** + * 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, - avoidNearEnemies: boolean, + 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 cameBy = new Map(); const seen = new Set([cellKey(from)]); let frontier: Cell[] = [from]; let found: string | null = null; @@ -77,18 +91,33 @@ function pathToward( for (const c of frontier) { for (const dir of SIDES) { const t = stepTarget(view.board, c, dir); - if (t.kind === "blocked") continue; - const k = cellKey(t.to); + 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 }); + 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 (avoidNearEnemies && nearEnemy(t.to)) continue; - next.push(t.to); + if (opts.avoidNearEnemies && nearEnemy(to)) continue; + next.push(to); } if (found) break; } @@ -98,7 +127,13 @@ function pathToward( let cursor = found; for (;;) { const hop = cameBy.get(cursor)!; - if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth }; + if (hop.prev === cellKey(from)) { + return { + dir: hop.dir, + distance: depth, + ...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}), + }; + } cursor = hop.prev; } } @@ -120,6 +155,15 @@ function treasureGoals(view: GameView): Set { 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); } @@ -131,17 +175,45 @@ function worstCards(view: GameView, n: number): string[] { .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): Command { const stack = view.stack!; - if (stack.defenderId !== view.you) return { type: "pass" }; + 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; 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 - flinch) return { type: "counteract", instanceId: shield.instanceId }; @@ -150,6 +222,18 @@ function respond(view: GameView, style: AutomatonStyle): Command { } const blunt = find("blunt"); if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.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" }; } @@ -162,8 +246,12 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | 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); + // 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 && !biggest) continue; + const withNumber = (atk.perNumber || uncharged) && biggest; + const damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); if (damage <= 0) continue; if (!best || damage > best.damage) { best = { @@ -194,6 +282,69 @@ function summonSpot(view: GameView, near: Cell): Cell | null { return best?.cell ?? null; } +/** Self-buffs and housekeeping worth a neutral cast this turn. */ +function selfCare(view: GameView, style: AutomatonStyle): 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 }; + } + // The ward guards the gold while its owner is away robbing yours. + if (inHand(view, "ward") && !view.yourWardArmed) { + return { type: "armWard", armed: true }; + } + // A shieldstone on the table turns spare numbers into armor. + const stone = inHand(view, "shieldstone"); + if (stone && !self.displayed.some((c) => c.cardId === "shieldstone")) { + return { type: "cast", instanceId: stone.instanceId }; + } + 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 } }; + } + } + } + // An ambush costs nothing to hold and everything to walk into. + const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire"); + 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. @@ -206,7 +357,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; } if (view.chaosPending && view.chaosPending.queue[0] === you) { - const shield = view.yourHand.find((c) => c.cardId === "full-shield"); + const shield = inHand(view, "full-shield"); return style === "worrier" && shield ? { type: "counteract", instanceId: shield.instanceId } : { type: "pass" }; @@ -232,6 +383,20 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter 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); + 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; @@ -242,20 +407,33 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter } 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 }; + const hunt = pathToward(view, c.position, enemyCells, {}); + if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir }; } } - // One attack per turn. + 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 = visible.sort((a, b) => a.life - b.life)[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); 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 }; } @@ -271,30 +449,46 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter } } - // March. The berserker hunts wizards over gold; the worrier keeps its - // distance; the hunter goes where the treasure is. + // 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 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 }; + 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: "move", direction: fallbackPath.dir }; } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0d47965..0ba36c9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -191,6 +191,52 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo * 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.35) 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; @@ -203,6 +249,7 @@ function runBots(room: Room): void { } 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 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/web/src/Help.svelte b/packages/web/src/Help.svelte index 17bd8b0..8bf0f14 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -100,6 +100,7 @@
{stats.longestGameCommands}
actions in the longest game yet played — every step, spell, and pass in its ledger
{stats.fullestTable}
wizards at the fullest table
{stats.hotseatGames}
of the games were hotseat tables, reporting in anonymously
+
{stats.automatonGames ?? 0}
games fought against the clockwork
{#if stats.firstGameAt}

The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.