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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5802e1df05
commit
ab52628d2f
@@ -17,6 +17,7 @@ import {
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
import { automatonCommand, automatonFallback } from "@wizwar/engine";
|
||||
|
||||
export interface LoggedCommand {
|
||||
seq: number;
|
||||
@@ -41,6 +42,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>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -98,6 +101,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Set(),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -230,6 +234,59 @@ 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): { 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" };
|
||||
room.players.push(name);
|
||||
room.bots.add(name);
|
||||
appendLine(room.id, { kind: "join", name, bot: 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the automatons play until the maze wants a human again. Returns the
|
||||
* event batches produced, one per command, for per-step broadcasting.
|
||||
*/
|
||||
export function driveAutomatons(room: Room): { seat: PlayerId; events: GameEvent[] }[] {
|
||||
const out: { seat: PlayerId; events: GameEvent[] }[] = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) break;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const cmd = automatonCommand(view) ?? 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}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push({ seat, events: r.events });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
roomId: string;
|
||||
name: PlayerId;
|
||||
@@ -427,13 +484,19 @@ export function loadPersistedRooms(): void {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Set(),
|
||||
};
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
room.players.push(line.name);
|
||||
room.tokens.set(line.name, joinHash);
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.add(line.name);
|
||||
} 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}`);
|
||||
|
||||
Reference in New Issue
Block a user