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
+18 -10
View File
@@ -17,7 +17,12 @@ import {
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
import { automatonCommand, automatonFallback } from "@wizwar/engine";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
type AutomatonStyle,
} from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
@@ -42,8 +47,8 @@ export interface Room {
events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays. */
bots: Set<PlayerId>;
/** Seats the server itself plays, and each one's temperament. */
bots: Map<PlayerId, AutomatonStyle>;
}
const rooms = new Map<string, Room>();
@@ -101,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
log: [],
events: [],
chat: [],
bots: new Set(),
bots: new Map(),
};
rooms.set(room.id, room);
recordRoom(room);
@@ -237,14 +242,17 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(room: Room): { name: PlayerId } | { error: string } {
export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" };
const style: AutomatonStyle = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle)
? (styleWanted as AutomatonStyle)
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
room.players.push(name);
room.bots.add(name);
appendLine(room.id, { kind: "join", name, bot: true });
room.bots.set(name, style);
appendLine(room.id, { kind: "join", name, bot: true, style });
return { name };
}
@@ -269,7 +277,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const cmd = automatonCommand(view) ?? automatonFallback(view);
const cmd = automatonCommand(view, room.bots.get(seat)) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd);
if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view));
@@ -480,13 +488,13 @@ export function loadPersistedRooms(): void {
log: [],
events: [],
chat: [],
bots: new Set(),
bots: new Map(),
};
for (const line of lines.slice(1)) {
if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
room.bots.add(line.name);
room.bots.set(line.name, (line.style as AutomatonStyle) ?? "hunter");
} else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token");