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
@@ -35,7 +35,9 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
driveAutomatons,
|
||||
makeTransferCode,
|
||||
redactFor,
|
||||
roomCount,
|
||||
@@ -170,6 +172,7 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: [...room.bots],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +184,17 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
}
|
||||
}
|
||||
|
||||
/** After a human acts, the clockwork answers; every step is broadcast. */
|
||||
function runBots(room: Room): void {
|
||||
const steps = driveAutomatons(room);
|
||||
for (const step of steps) {
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
|
||||
}
|
||||
if (steps.length > 0) {
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastRoomState(room: Room): void {
|
||||
broadcast(room, () => roomInfo(room));
|
||||
if (room.state) {
|
||||
@@ -250,6 +264,16 @@ 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);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "start": {
|
||||
@@ -260,6 +284,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 +297,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": {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -24,6 +24,8 @@ 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;
|
||||
}
|
||||
|
||||
export interface StartLine {
|
||||
|
||||
Reference in New Issue
Block a user