12 Commits
Author SHA1 Message Date
Eric Wagoner 7bd82d7679 Merge branch 'automatons' 2026-08-16 18:34:43 -04:00
Eric WagonerandClaude Fable 5 b9c784a10c Difficulty without stupidity: apprentice, adept, archmage
Tiers degrade resources and repertoire, never judgment — the design
constraint was that no tier may ever look dumb. The APPRENTICE draws
one card a turn instead of two (a poorer wizard, not a worse one),
spends counters only on heavy hits (thrift, not blindness), and
carries a modest spellbook: damage, summons, stones, keys, and
treasure play, with no afflictions, amplifies, ambushes, guarding
tricks, or deja-vu. The ADEPT draws fully and knows everything except
ambushes and amplify. The ARCHMAGE is the full curriculum. Every card
any tier plays, it plays correctly.

The lobby workshop gains a tier picker beside the temperaments
(default adept); the tier persists with the seat, shows in roster and
scoresheet ("⚙ apprentice mystery"), and rides the drive loop.
Measured where it should matter: in berserker combat mirrors the
archmage beats the apprentice two to one, while pure treasure races
stay honest — the handicap lives in the card exchanges a human
actually feels, pinned deterministically in the tournament suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:59:11 -04:00
Eric WagonerandClaude Fable 5 a6ede6ca1e The clockwork reads the whole deck
Every playable card now has a place in the automaton's mind. The
damage table completes: lightning blast, wizardblade, and power drain
scale with numbers (and are skipped without one), disease bites in
the shared square, the dagger's true three points corrected. A new
affliction book casts the miseries — blind, slow, medusa, no-spell,
lock-in-place, walking dead, slow death, idiot, thought steal,
go-away — with a number for the duration, and WEAKNESS is saved for
whoever carries stolen gold. AMPLIFY doubles the heavy spells.

The counteraction ladder runs the full rack: REVERSE eats the big
blasts, ABSORB SPELL steals the good ones, FULL SHIELD, FULL
REFLECTION, REMOVE CURSE refuses afflictions at the door, REFLECTION
halves, ABSORB soaks the mid-range, BLUNT, EMPATHY out of berserker
spite, teleport escapes, shieldstone numbers.

Housekeeping: every stone hits the table the turn it is drawn, GIFT
FROM ABOVE is cashed instantly, curses get scrubbed with REMOVE
CURSE, LIFESAVER goes up at three players, a SAFE or GLUE guards
floor gold with enemies near, and DEJA-VU pulls the best attack back
from the discard when the hand runs dry of violence.

Table talk verified live ("ACQUISITION COMPLETE." mid-probe) — the
silence was timing, not plumbing — and the remark odds rise to half.
Three-way tournaments tell true stories now: the hunter wins on
gold, the worrier survives to win by attrition, and the berserker
learns that aggression in a three-way gets you buried first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:41:12 -04:00
Eric WagonerandClaude Fable 5 3eda686e29 The full clockwork curriculum: keys, vengeance, spellcraft, and voice
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 <noreply@anthropic.com>
2026-08-16 17:10:47 -04:00
Eric WagonerandClaude Fable 5 ca16faac73 Rev 9: REDIRECTION connects the exits you choose
Playtesting read the card right and the engine had it wrong. "Placing
the A tokens on one set of exits & the B tokens on the other" means
the two exits you mark BECOME a pair — and their former partners pair
with each other — not that the two exits trade destinations. Under
rev 9, click the exit and then the exit you want it to connect TO
(the hint now says so); already-connected exits are refused. Earlier
games keep the swap their logs were validated against. Test pins the
full four-way rewiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:40:22 -04:00
Eric WagonerandClaude Fable 5 1aafb9e837 The mystery machine keeps its mood to itself
"Random" read as random ACTIONS, and a revealed random pick is barely
a mystery anyway. The fourth workshop button is now "mystery": the
server rolls the temperament and keeps it — the join line records it
for replay, the drive loop plays it, but the room tells the table
only "mystery". Roster and scoresheet show ⚙ mystery; the machine's
behavior is the only tell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:36:21 -04:00
Eric WagonerandClaude Fable 5 af75a024ff A random pull from the workshop, and temperaments on the scoresheet
The lobby's automaton row gains "random" (the server already rolled
when no style was named — now the choice is offered). And each
automaton's temperament shows beside its name on the in-game
scoresheet in the handwritten face, so you always know whether the
machine across the maze wants your gold or your blood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:32:06 -04:00
Eric WagonerandClaude Fable 5 5a407a1e4c The clockwork learns numbers, summons, creatures — and temperament
The automaton now plays number cards where they matter: attached to
value-scaled attacks (waterbolt, powerthrust) choosing the biggest
for damage, and played for movement when the goal is just out of
stride. It raises its monsters when no wizard is in spell range and
commands them every turn — creatures march at enemies by the same
hazard-shy BFS and maul whoever shares their square. Targets are
picked by lowest life.

And it has moods. The HUNTER plays the classic game: gold first,
violence when convenient. The BERSERKER hunts wizards over treasure
and finishes games by last-wizard-standing. The WORRIER paths around
enemies, counters at a lower threshold, shields against chaos, and
never brawls. Hosts pick the temperament when seating one (or the
workshop assigns at random); it persists with the seat, shows in the
roster, and the tournament harness proves every pairing finishes.
Across ten seeds of berserker versus hunter: six treasure wins to
four kills — the moods play genuinely different games.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:29:36 -04:00
Eric WagonerandClaude Fable 5 01101ca24a Ignore playwright scratch output
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:28 -04:00
Eric WagonerandClaude Fable 5 75313154ad Number cards ride along on edge and cell casts
The edge-click dispatch attached the tapped number card only for
wall attacks, so the warp wand's first use could never receive its
charges ("a wand's first use needs a number card" no matter what),
and wall of fire silently burned one turn instead of its NUMBER.
Cell-target casts had the same gap for duration terrain. The attached
number now rides every edge and cell cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:12 -04:00
Eric WagonerandClaude Fable 5 3842307bf0 The clockwork plays at a watchable pace
One command per beat — 1.5 seconds, the replay's 1x cadence — each
broadcast with its board state as it lands, starting a beat after
the human's own action settles. The pump is per-room, re-entrancy
guarded, and stops the moment the maze wants a human (or the game
ends). Watching an automaton stalk your treasure across the maze is
now a spectacle instead of a teleport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:17:28 -04:00
Eric WagonerandClaude Fable 5 ab52628d2f The automatons awaken (branch only — not for the public droplet yet)
Phase 4 begins. The automaton is a pure function in the engine —
automatonCommand(view) — playing from its own redacted GameView, the
same information a human seat receives: hidden hands stay hidden from
the clockwork. It ranks simple damage spells, counters what hurts
(full shield at 3+, reflection at 4+, blunt at 2+), discards its
worst cards by a value order, BFS-pathfinds to enemy treasures and
home again, refuses to path through hazards, brawls when there is
nothing to steal, and always has a safe fallback; the server's drive
loop steps any bot-held seat through the same runCommand path as
humans, so bot commands log, persist, replay, and broadcast like
anyone's.

Hosts seat them pre-start with "⚙ seat an automaton" (Automaton,
Automaton II, ... V); bot seats persist as tokenless join lines and
restore on boot. Proven three ways: bot-vs-bot engine games conclude
across seeds in 8-14 rounds (~100 commands — human-scale), a full
four-automaton table finishes, and a live websocket game of human
vs. automaton ended with the clockwork carrying two treasures home
through a do-nothing opponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:11:54 -04:00
11 changed files with 1019 additions and 7 deletions
+1
View File
@@ -5,3 +5,4 @@ dist/
.DS_Store
data/
.claude/
.playwright-mcp/
+663
View File
@@ -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<AutomatonTier, TierTraits> = {
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false },
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true },
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true },
};
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false },
"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<string, { withNumber: boolean; vsCarrier?: boolean }> = {
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<string>,
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<string, { prev: string; dir: Side; viaDoor: boolean }>();
const seen = new Set<string>([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<string> {
const self = me(view);
const goals = new Set<string>();
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 };
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./cards";
export * from "./setups";
export * from "./game";
export * from "./view";
export * from "./automaton";
+116
View File
@@ -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);
}
});
});
+90
View File
@@ -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<string, Record<string, string[]>> = {
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<string>();
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": {
+83
View File
@@ -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<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
}
const rooms = new Map<string, Room>();
@@ -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") {
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}`);
+5 -1
View File
@@ -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<EngagementStats, "wizardsSeated"> {
@@ -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<string>();
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;
+8
View File
@@ -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 {
+41 -2
View File
@@ -36,6 +36,7 @@
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
let botTier = $state("adept");
/** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
@@ -1028,7 +1029,7 @@
{:else}
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
</li>
{/each}
</ul>
@@ -1049,6 +1050,20 @@
{/each}
</div>
{#if net.you === net.hostId}
{#if net.players.length < 6}
<span class="bot-row">
⚙ seat a
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
<option value="apprentice">apprentice</option>
<option value="adept">adept</option>
<option value="archmage">archmage</option>
</select>
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
</span>
{/if}
<label class="check">
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
@@ -1156,7 +1171,10 @@
{#each view.players as p (p.id)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span>
<span class="score-name">{p.id}{p.id === view.you ? " (you)" : ""}</span>
<span class="score-name">
{p.id}{p.id === view.you ? " (you)" : ""}{#if net.roomBots[p.id]}
<span class="score-temper">⚙ {net.roomBots[p.id]}</span>{/if}
</span>
<span class="score-life">{p.life}</span>
<span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if}
@@ -1833,6 +1851,12 @@
}
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
.score-row.active .score-name { font-weight: 700; }
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;
color: #8a7a5e;
margin-left: 0.25rem;
}
.table-cards {
display: flex;
flex-wrap: wrap;
@@ -1994,6 +2018,21 @@
.big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
.tier-pick {
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 3px;
font: inherit;
color: #43331f;
padding: 0.1rem 0.25rem;
}
.bot-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: #6b5a41;
}
.discard-link {
background: none;
border: none;
+1
View File
@@ -100,6 +100,7 @@
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
</dl>
{#if stats.firstGameAt}
<p class="colophon">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.</p>
+6
View File
@@ -225,6 +225,7 @@ class Net {
started = $state(false);
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
@@ -295,6 +296,7 @@ class Net {
case "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -426,6 +428,10 @@ class Net {
}
/** Ask the server how all our games are doing. */
addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}