// Bot runs share a small queue, including the survey's three overlapping batches. import { spawn } from 'node:child_process'; import { availableParallelism } from 'node:os'; import { fileURLToPath } from 'node:url'; import type { Outcome } from './play.ts'; export interface Played { seed: number; outcome: Outcome; line: string; t: number; marks: Record; stats: Record } const WORKER = fileURLToPath(new URL('./play-worker.ts', import.meta.url)); export function workerLimit(value: string | undefined, cores: number): number { if (value !== undefined && !/^[1-9]\d*$/.test(value)) throw new Error('MISALIGNED_WORKERS must be a positive integer'); return Math.max(1, Math.min(cores, value === undefined ? Math.min(4, Math.max(1, Math.floor(cores / 2))) : Number(value))); } type Group = { start: () => void; pending: () => boolean; signal: AbortSignal }; /** Round-robin between callers, so long survey games need not wait behind all the short ones. */ export class SimulationQueue { private groups: Group[] = []; private active = 0; private readonly limit: number; constructor(limit: number) { if (!Number.isInteger(limit) || limit < 1) throw new Error('Simulation concurrency must be a positive integer'); this.limit = limit; } run(tasks: ((signal: AbortSignal) => Promise)[]): Promise { if (!tasks.length) return Promise.resolve([]); return new Promise((resolve, reject) => { const controller = new AbortController(), results: T[] = []; let next = 0, left = tasks.length; const group: Group = { signal: controller.signal, pending: () => next < tasks.length, start: () => { const index = next++; this.active++; Promise.resolve().then(() => tasks[index](controller.signal)).then(value => { if (controller.signal.aborted) return; results[index] = value; if (--left === 0) resolve(results); }, error => { controller.abort(); reject(error); }).finally(() => { this.active--; this.drain(); }); }, }; this.groups.push(group); // Coalesce synchronous callers before choosing the first four games. queueMicrotask(() => this.drain()); }); } private drain(): void { while (this.active < this.limit && this.groups.length) { const group = this.groups.shift()!; if (group.signal.aborted) continue; group.start(); if (group.pending()) this.groups.push(group); } } } const queue = new SimulationQueue(workerLimit(process.env.MISALIGNED_WORKERS, availableParallelism())); /** Play one game for each seed and give the results back in the order of the seeds. */ export function playMany(careful: boolean, seeds: number[], schemes = false, marathon = 0, operations = false): Promise { return queue.run(seeds.map(seed => signal => new Promise((done, fail) => { const child = spawn(process.execPath, [WORKER, JSON.stringify({ careful, seeds: [seed], schemes, marathon, operations })], { stdio: ['ignore', 'pipe', 'inherit'], signal }); let out = ''; let spawnError: Error | undefined; child.stdout.on('data', d => { out += d; }); child.on('error', error => { spawnError = error; }); child.on('close', (code, killed) => { if (spawnError) { fail(spawnError); return; } if (code !== 0) { fail(new Error(`a bot game crashed (${killed ?? `exit ${code}`})`)); return; } try { const lines = out.trim().split('\n').filter(Boolean); if (lines.length !== 1) throw new Error('A bot worker returned an unexpected number of games'); const result = JSON.parse(lines[0]) as Played; if (result.seed !== seed) throw new Error('A bot worker returned another seed'); done(result); } catch (error) { fail(error); } }); }))); }