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>
This commit is contained in:
Eric Wagoner
2026-08-16 16:29:36 -04:00
co-authored by Claude Fable 5
parent 01101ca24a
commit 5a407a1e4c
7 changed files with 223 additions and 75 deletions
+166 -54
View File
@@ -2,30 +2,29 @@
// the same information a human seat receives — and returns one command per // the same information a human seat receives — and returns one command per
// consultation; the server keeps consulting until the maze stops asking. // consultation; the server keeps consulting until the maze stops asking.
// It never throws: when in doubt it passes, discards, or ends its turn. // 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 { cardDef, type CardInstance } from "./cards";
import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board"; import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board";
import { sightedCellsFor, type GameView } from "./view"; import { sightedCellsFor, type GameView } from "./view";
import type { Command, PlayerId } from "./game"; import type { Command, PlayerId } from "./game";
/** Damage attacks simple enough for clockwork: id -> flat damage dealt. */ export type AutomatonStyle = "hunter" | "berserker" | "worrier";
const SIMPLE_ATTACKS: Record<string, number> = { export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
fireball: 5,
"sudden-death": 10, /** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
powerthrust: 2, const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
dagger: 1, fireball: { base: 5, perNumber: false },
"large-rock": 2, "sudden-death": { base: 10, perNumber: false },
powerthrust: { base: 2, perNumber: true },
waterbolt: { base: 0, perNumber: true },
dagger: { base: 1, perNumber: false },
"large-rock": { base: 2, perNumber: false },
}; };
/** Cards the automaton happily discards to churn its hand. */ const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
function discardValue(c: CardInstance): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 8;
if (SIMPLE_ATTACKS[c.cardId] != null) return 7;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
}
function me(view: GameView) { function me(view: GameView) {
return view.players.find((p) => p.id === view.you)!; return view.players.find((p) => p.id === view.you)!;
@@ -35,14 +34,45 @@ function livingEnemies(view: GameView) {
return view.players.filter((p) => p.alive && p.id !== view.you); return view.players.filter((p) => p.alive && p.id !== view.you);
} }
/** BFS over walkable steps; returns the first direction of a shortest path. */ function numbersInHand(view: GameView): CardInstance[] {
function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side | null { return view.yourHand
.filter((c) => cardDef(c.cardId).cardType === "number")
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
}
/** Cards the automaton happily discards to churn its hand, worst first. */
function discardValue(c: CardInstance): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
if (SUMMONS.has(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null) return 7;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
}
interface PathResult {
dir: Side;
distance: number;
}
/** BFS over walkable steps toward the nearest goal; hazard-shy, enemy-shy optional. */
function pathToward(
view: GameView,
from: Cell,
goals: Set<string>,
avoidNearEnemies: boolean,
): PathResult | null {
if (goals.size === 0 || goals.has(cellKey(from))) return 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 }>(); const cameBy = new Map<string, { prev: string; dir: Side }>();
const seen = new Set<string>([cellKey(from)]); const seen = new Set<string>([cellKey(from)]);
let frontier: Cell[] = [from]; let frontier: Cell[] = [from];
let found: string | null = null; let found: string | null = null;
for (let depth = 0; depth < 60 && frontier.length > 0 && !found; depth++) { let depth = 0;
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
const next: Cell[] = []; const next: Cell[] = [];
for (const c of frontier) { for (const c of frontier) {
for (const dir of SIDES) { for (const dir of SIDES) {
@@ -54,11 +84,10 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
seen.add(k); seen.add(k);
cameBy.set(k, { prev: cellKey(c), dir }); cameBy.set(k, { prev: cellKey(c), dir });
if (goals.has(k)) { found = k; break; } if (goals.has(k)) { found = k; break; }
// The automaton refuses to path THROUGH hazards, but will end on one
// only if it is the goal itself.
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime") continue; hazard === "rosebush" || hazard === "slime") continue;
if (avoidNearEnemies && nearEnemy(t.to)) continue;
next.push(t.to); next.push(t.to);
} }
if (found) break; if (found) break;
@@ -69,12 +98,12 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
let cursor = found; let cursor = found;
for (;;) { for (;;) {
const hop = cameBy.get(cursor)!; const hop = cameBy.get(cursor)!;
if (hop.prev === cellKey(from)) return hop.dir; if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth };
cursor = hop.prev; cursor = hop.prev;
} }
} }
/** The treasure squares worth marching for, best first. */ /** The treasure squares worth marching for. */
function treasureGoals(view: GameView): Set<string> { function treasureGoals(view: GameView): Set<string> {
const self = me(view); const self = me(view);
const goals = new Set<string>(); const goals = new Set<string>();
@@ -85,8 +114,6 @@ function treasureGoals(view: GameView): Set<string> {
for (const t of view.treasures) { for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue; if (!t.position || t.carriedBy) continue;
if (t.owner === view.you) continue; if (t.owner === view.you) continue;
// A treasure already delivered to one of MY... any floor treasure of an
// enemy is worth taking; skip ones resting on my own home (already won).
if (cellKey(t.position) === cellKey(self.home)) continue; if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position)); goals.add(cellKey(t.position));
} }
@@ -94,8 +121,7 @@ function treasureGoals(view: GameView): Set<string> {
} }
function overLimit(view: GameView): number { function overLimit(view: GameView): number {
const limit = 7; return Math.max(0, view.yourHand.length - 7);
return Math.max(0, view.yourHand.length - limit);
} }
function worstCards(view: GameView, n: number): string[] { function worstCards(view: GameView, n: number): string[] {
@@ -105,38 +131,88 @@ function worstCards(view: GameView, n: number): string[] {
.map((c) => c.instanceId); .map((c) => c.instanceId);
} }
/** What the automaton does when the maze wants a response from it. */ /** Counteraction judgment; the worrier flinches at less. */
function respond(view: GameView): Command { function respond(view: GameView, style: AutomatonStyle): Command {
const stack = view.stack!; const stack = view.stack!;
if (stack.defenderId !== view.you) return { type: "pass" }; if (stack.defenderId !== view.you) return { type: "pass" };
const incoming = stack.attackCard ? (SIMPLE_ATTACKS[stack.attackCard.cardId] ?? 2) : 1; const flinch = style === "worrier" ? 1 : 0;
const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null;
const incoming = stack.attackCard
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
const hand = view.yourHand; const hand = view.yourHand;
const find = (id: string) => hand.find((c) => c.cardId === id); const find = (id: string) => hand.find((c) => c.cardId === id);
if (stack.kind === "spell") { if (stack.kind === "spell") {
const shield = find("full-shield"); const shield = find("full-shield");
if (shield && incoming >= 3) return { type: "counteract", instanceId: shield.instanceId }; if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId };
const reflect = find("full-reflection"); const reflect = find("full-reflection");
if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId }; if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
} }
const blunt = find("blunt"); const blunt = find("blunt");
if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId }; if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
return { type: "pass" }; return { type: "pass" };
} }
/** The best attack available against a visible target, numbers included. */
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
const numbers = numbersInHand(view);
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
let best: { cmd: Command; damage: number } | null = null;
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
const withNumber = atk.perNumber && biggest;
const damage = atk.base + (withNumber ? biggestValue : 0);
if (damage <= 0) continue;
if (!best || damage > best.damage) {
best = {
damage,
cmd: {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
},
};
}
}
return best?.cmd ?? null;
}
/** An empty, sighted square near the target for a summoned creature. */
function summonSpot(view: GameView, near: Cell): Cell | null {
const sighted = sightedCellsFor(view);
let best: { cell: Cell; d: number } | null = null;
for (const k of sighted) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
if (!best || d < best.d) best = { cell: { x, y }, d };
}
return best?.cell ?? null;
}
/** /**
* One decision from the automaton's seat, or null when the maze is not * One decision from the automaton's seat, or null when the maze is not
* asking it anything. * asking it anything.
*/ */
export function automatonCommand(view: GameView): Command | null { export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): Command | null {
const you = view.you as PlayerId; const you = view.you as PlayerId;
if (view.phase !== "playing") return null; if (view.phase !== "playing") return null;
if (view.pendingDiscard === you) { if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
} }
if (view.chaosPending && view.chaosPending.queue[0] === you) return { type: "pass" }; if (view.chaosPending && view.chaosPending.queue[0] === you) {
const shield = view.yourHand.find((c) => c.cardId === "full-shield");
return style === "worrier" && shield
? { type: "counteract", instanceId: shield.instanceId }
: { type: "pass" };
}
if (view.stack) { if (view.stack) {
return view.stack.waitingOn === you ? respond(view) : null; return view.stack.waitingOn === you ? respond(view, style) : null;
} }
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" }; if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
if (view.activePlayerId !== you) return null; if (view.activePlayerId !== you) return null;
@@ -156,34 +232,70 @@ export function automatonCommand(view: GameView): Command | null {
if (prize) return { type: "pickUpTreasure" }; if (prize) return { type: "pickUpTreasure" };
} }
// One attack per turn: the nearest visible enemy eats the best simple spell. // Command the menagerie: creatures march and maul before the wizard moves.
for (const c of view.creatures) {
if (c.controllerId !== you || c.justCreated) continue;
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
if (enemyHere && !c.attackUsed &&
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
}
if (c.movementUsed < c.movesPerTurn) {
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const hunt = pathToward(view, c.position, enemyCells, false);
if (hunt) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
}
}
// One attack per turn.
if (!view.turn.attackUsed && view.turn.round > 1) { if (!view.turn.attackUsed && view.turn.round > 1) {
const sighted = sightedCellsFor(view); const sighted = sightedCellsFor(view);
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position))); const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
if (visible.length > 0) { if (visible.length > 0) {
const target = visible[0]!; const target = visible.sort((a, b) => a.life - b.life)[0]!;
const spells = view.yourHand const spell = bestAttack(view, target.id);
.filter((c) => SIMPLE_ATTACKS[c.cardId] != null) if (spell) return spell;
.sort((a, b) => SIMPLE_ATTACKS[b.cardId]! - SIMPLE_ATTACKS[a.cardId]!); // The berserker summons help where the hunter saves the card.
if (spells.length > 0) { if (cellKey(target.position) === here && style !== "worrier") {
return { return { type: "punch", targetId: target.id };
type: "cast", instanceId: spells[0]!.instanceId, }
target: { kind: "player", playerId: target.id }, }
}; // No shot at a wizard: raise a creature to do the walking.
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
if (summon && livingEnemies(view).length > 0) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
const spot = summonSpot(view, near);
if (spot) {
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
} }
if (cellKey(target.position) === here) return { type: "punch", targetId: target.id };
} }
} }
// March toward the objective. // March. The berserker hunts wizards over gold; the worrier keeps its
// distance; the hunter goes where the treasure is.
if (view.turn.movementUsed < view.turn.movementAllowance) { if (view.turn.movementUsed < view.turn.movementAllowance) {
const goals = treasureGoals(view); const gold = treasureGoals(view);
const dir = firstStepToward(view, self.position, goals);
if (dir) return { type: "move", direction: dir };
// No treasure path: close on the nearest enemy for a brawl.
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const hunt = firstStepToward(view, self.position, enemyCells); const objectives = style === "berserker" && !me(view).carriedTreasureId
if (hunt) return { type: "move", direction: hunt }; ? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path = pathToward(view, self.position, objectives, style === "worrier");
const fallbackPath = path ?? pathToward(view, self.position, objectives, false) ??
pathToward(view, self.position, enemyCells, false);
if (fallbackPath) {
// A number card closes the gap when the goal is just out of stride.
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
if (!view.turn.numberPlayedForMovement && fallbackPath.distance > movesLeft) {
const numbers = numbersInHand(view);
const helper = numbers.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= fallbackPath.distance,
) ?? (fallbackPath.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
if (helper) {
return { type: "playNumberForMovement", instanceId: helper.instanceId };
}
}
return { type: "move", direction: fallbackPath.dir };
}
} }
return { type: "endTurn", draw: 2 }; return { type: "endTurn", draw: 2 };
+17 -3
View File
@@ -6,7 +6,7 @@ import {
type PlayerId, type PlayerId,
} from "../src/game"; } from "../src/game";
import { viewFor } from "../src/view"; import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback } from "../src/automaton"; import { automatonCommand, automatonFallback, type AutomatonStyle } from "../src/automaton";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId { function actingSeat(state: GameState): PlayerId {
@@ -20,8 +20,9 @@ function actingSeat(state: GameState): PlayerId {
} }
/** Drive a full bot-vs-bot game; returns the final state and command count. */ /** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(seed: number, players: number, expansion = true) { function playOut(seed: number, players: number, expansion = true, styles: AutomatonStyle[] = []) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`); const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
let { state } = createGame({ let { state } = createGame({
playerIds: ids, playerIds: ids,
seed, seed,
@@ -34,7 +35,7 @@ function playOut(seed: number, players: number, expansion = true) {
while (state.phase === "playing" && commands < CAP) { while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state); const seat = actingSeat(state);
const view = viewFor(state, seat); const view = viewFor(state, seat);
const cmd = automatonCommand(view) ?? automatonFallback(view); const cmd = automatonCommand(view, styleOf.get(seat)) ?? automatonFallback(view);
let r = applyCommand(state, seat, cmd); let r = applyCommand(state, seat, cmd);
if (!r.ok) { if (!r.ok) {
const fb = automatonFallback(view); const fb = automatonFallback(view);
@@ -83,4 +84,17 @@ describe("automaton vs automaton", () => {
const { state } = playOut(7, 4); const { state } = playOut(7, 4);
expect(state.phase).toBe("finished"); expect(state.phase).toBe("finished");
}); });
it("every temperament finishes its wars", () => {
for (const styles of [
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
] as AutomatonStyle[][]) {
let finished = 0;
for (const seed of [3, 17, 29]) {
const { state } = playOut(seed, 2, true, styles);
if (state.phase === "finished") finished++;
}
expect(finished).toBeGreaterThanOrEqual(2);
}
});
}); });
+2 -2
View File
@@ -172,7 +172,7 @@ function roomInfo(room: Room) {
hostId: room.hostId, hostId: room.hostId,
started: room.state !== null, started: room.state !== null,
colors: Object.fromEntries(room.colorChoices), colors: Object.fromEntries(room.colorChoices),
bots: [...room.bots], bots: Object.fromEntries(room.bots),
}; };
} }
@@ -282,7 +282,7 @@ wss.on("connection", (socket) => {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); 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" }); if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
const result = addAutomaton(room); const result = addAutomaton(room, typeof msg.style === "string" ? msg.style : undefined);
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcastRoomState(room); broadcastRoomState(room);
break; break;
+18 -10
View File
@@ -17,7 +17,12 @@ import {
} from "@wizwar/engine"; } from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats"; import { recordRoom } from "./stats";
import { automatonCommand, automatonFallback } from "@wizwar/engine"; import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
type AutomatonStyle,
} from "@wizwar/engine";
export interface LoggedCommand { export interface LoggedCommand {
seq: number; seq: number;
@@ -42,8 +47,8 @@ export interface Room {
events: GameEvent[]; // full history (unredacted — redact per recipient) events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */ /** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[]; chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays. */ /** Seats the server itself plays, and each one's temperament. */
bots: Set<PlayerId>; bots: Map<PlayerId, AutomatonStyle>;
} }
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
@@ -101,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
log: [], log: [],
events: [], events: [],
chat: [], chat: [],
bots: new Set(), bots: new Map(),
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room); recordRoom(room);
@@ -237,14 +242,17 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"]; const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */ /** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(room: Room): { name: PlayerId } | { error: string } { export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" }; if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" }; if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n)); const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" }; if (!name) return { error: "the workshop is empty" };
const style: AutomatonStyle = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle)
? (styleWanted as AutomatonStyle)
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
room.players.push(name); room.players.push(name);
room.bots.add(name); room.bots.set(name, style);
appendLine(room.id, { kind: "join", name, bot: true }); appendLine(room.id, { kind: "join", name, bot: true, style });
return { name }; return { name };
} }
@@ -269,7 +277,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
const seat = actingSeat(room); const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null; if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat); const view = viewFor(room.state!, seat);
const cmd = automatonCommand(view) ?? automatonFallback(view); const cmd = automatonCommand(view, room.bots.get(seat)) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd); let r = runCommand(room, seat, cmd);
if ("error" in r) { if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view)); r = runCommand(room, seat, automatonFallback(view));
@@ -480,13 +488,13 @@ export function loadPersistedRooms(): void {
log: [], log: [],
events: [], events: [],
chat: [], chat: [],
bots: new Set(), bots: new Map(),
}; };
for (const line of lines.slice(1)) { for (const line of lines.slice(1)) {
if (line.kind === "join") { if (line.kind === "join") {
if (line.bot) { if (line.bot) {
room.players.push(line.name); room.players.push(line.name);
room.bots.add(line.name); room.bots.set(line.name, (line.style as AutomatonStyle) ?? "hunter");
} else { } else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token"); if (!joinHash) throw new Error("join line has no token");
+2
View File
@@ -26,6 +26,8 @@ export interface JoinLine {
token?: string; token?: string;
/** An automaton seat: no token; the server plays it. */ /** An automaton seat: no token; the server plays it. */
bot?: true; bot?: true;
/** The automaton's temperament. */
style?: string;
} }
export interface StartLine { export interface StartLine {
+14 -2
View File
@@ -1028,7 +1028,7 @@
{:else} {:else}
<span class="dot" style:background="#b3a687"></span> <span class="dot" style:background="#b3a687"></span>
{/if} {/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots.includes(p) ? "" : chosen === undefined ? " — choosing…" : ""} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -1050,7 +1050,12 @@
</div> </div>
{#if net.you === net.hostId} {#if net.you === net.hostId}
{#if net.players.length < 6} {#if net.players.length < 6}
<button class="stamp tiny" onclick={() => net.addBot()}>⚙ seat an automaton</button> <span class="bot-row">
⚙ seat an automaton:
<button class="stamp tiny" onclick={() => net.addBot("hunter")}>hunter</button>
<button class="stamp tiny" onclick={() => net.addBot("berserker")}>berserker</button>
<button class="stamp tiny" onclick={() => net.addBot("worrier")}>worrier</button>
</span>
{/if} {/if}
<label class="check"> <label class="check">
<input type="checkbox" bind:checked={withExpansion} /> <input type="checkbox" bind:checked={withExpansion} />
@@ -1995,6 +2000,13 @@
.big-peek :global(.card:hover) { transform: scale(2.1); } .big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; } .big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; } .big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
.bot-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: #6b5a41;
}
.discard-link { .discard-link {
background: none; background: none;
border: none; border: none;
+4 -4
View File
@@ -225,7 +225,7 @@ class Net {
started = $state(false); started = $state(false);
/** Lobby standee choices, by player name. */ /** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({}); roomColors = $state<Record<string, number>>({});
roomBots = $state<string[]>([]); roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null); you = $state<string | null>(null);
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
@@ -296,7 +296,7 @@ class Net {
case "room": case "room":
this.roomId = msg.roomId; this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {}; this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? []; this.roomBots = msg.bots ?? {};
if (this.you && this.token) { if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -428,8 +428,8 @@ class Net {
} }
/** Ask the server how all our games are doing. */ /** Ask the server how all our games are doing. */
addBot(): void { addBot(style?: string): void {
this.send({ type: "addBot" }); this.send({ type: "addBot", ...(style ? { style } : {}) });
} }
rollTableDie(): void { rollTableDie(): void {