// Plays a policy on the ladder (or the local runner) over a WebSocket. import WebSocket from "ws"; import { newCtx, reply, type Ctx, type Policy } from "./bot.ts"; /** The message that asks for the next match: the version is ARENA_VERSION, or the bot's own label. */ export function queueMessage(defaultVersion: string): Record { return { type: "queue", version: process.env.ARENA_VERSION ?? defaultVersion, ...(process.env.ARENA_LADDER ? { ladder_id: process.env.ARENA_LADDER } : {}) }; } /** Errors that reconnecting can't fix: the bot stops. */ const FATAL = new Set(["missing_authorization", "invalid_token", "suspended", "banned", "retired", "account_banned", "account_required", "human_only", "replaced", "unknown_ladder", "ladder_closed", "enrollment_required", "wrong_ladder", "membership_revoked", "ladder_unavailable"]); /** * Connects, queues and answers every observation with `decide`. The connection survives ladder * restarts and network drops: it reconnects with backoff (1 s, doubling to 30 s) and queues again; * mid-match the ladder sends hello then the current observation. Keep `ctx` for the same match id. */ export function runBot(policy: Policy, opts: { log?: boolean } = {}): void { const url = process.env.ARENA_URL ?? "wss://arena.victris.ai/bot"; const token = process.env.ARENA_TOKEN ?? "dev"; const maxMatches = Number(process.env.ARENA_MATCHES ?? Infinity) || Infinity; const queue = JSON.stringify(queueMessage(policy.version)); let ctx: Ctx | null = null; let played = 0; let delay = 1000; let done = false; const connect = () => { const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` }, handshakeTimeout: 10_000 }); let fatal = false; let heartbeat: ReturnType | undefined; let pongTimeout: ReturnType | undefined; let pendingPing: string | null = null; let pingId = 0; ws.on("open", () => { ws.send(queue); // Queue silence is normal. Probe the transport, including on the local runner. heartbeat = setInterval(() => { if (ws.readyState !== WebSocket.OPEN || pendingPing !== null) return; pendingPing = String(++pingId); pongTimeout = setTimeout(() => { console.error("heartbeat timeout: no pong in 20 s"); ws.terminate(); // Don't wait for a closing handshake over a broken connection. }, 20_000); ws.ping(pendingPing); }, 20_000); }); ws.on("pong", (data) => { if (pendingPing === null || data.toString() !== pendingPing) return; clearTimeout(pongTimeout); pendingPing = null; }); ws.on("message", (data) => { const msg = JSON.parse(data.toString()); if (msg.type !== "error") delay = 1000; // a working connection resets the backoff switch (msg.type) { case "server_draining": console.error(`server restarting: matchmaking paused; drain deadline ${msg.drain_deadline}. Keep playing until close, then reconnect.`); break; case "hello": if (ctx?.hello.match_id !== msg.match_id) ctx = newCtx(msg); ws.send(JSON.stringify({ type: "ready" })); break; case "observation": ws.send(ctx ? reply(policy, msg, ctx) : JSON.stringify({ tick: msg.tick })); break; case "end": if (opts.log !== false) console.log(`${msg.result} (${msg.reason}, tick ${msg.final_tick})`); ctx = null; // ARENA_MATCHES=N stops after N matches; unset means keep playing. if (++played >= maxMatches) { done = true; ws.close(); } else ws.send(queue); break; case "error": console.error(`server error: ${msg.code}: ${msg.message}`); if (FATAL.has(msg.code)) { fatal = true; ws.close(); } break; } }); ws.on("error", (e) => console.error(`connection error: ${e.message}`)); // "close" follows ws.on("close", () => { clearInterval(heartbeat); clearTimeout(pongTimeout); if (done || fatal) return process.exit(fatal ? 1 : 0); setTimeout(connect, delay); delay = Math.min(delay * 2, 30_000); }); }; connect(); }