clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / sim / runner.ts
runner.ts 4.2K
1/**2 * SimRunner — owns the sim Web Worker, translates play/pause/speed into tick3 * credits (backpressure), and hands frames to the viewer. Restart recreates4 * the worker from the same bundle (deterministic, so identical replay).5 */6import type { MetricsFrame, SimBundle, SimResult } from '@tribes/protocol';7import type { MapFrame, WorkerInMsg, WorkerOutMsg } from './frames';8import { logLevelFromSearch } from '../log';910export type Speed = 1 | 4 | 16 | 'max';1112const TICKS_PER_SEC: Record<Exclude<Speed, 'max'>, number> = { 1: 10, 4: 40, 16: 160 };13const MAX_BURST = 3000; // credits granted per rAF at 'max'14const MAX_SAMPLE_EVERY = 25;1516export interface RunnerEvents {17 onFrame(frame: MapFrame): void;18 /** A new stats frame from the engine (every SLOW_TICK_INTERVAL ticks). */19 onMetrics(frame: MetricsFrame): void;20 onDone(result: SimResult, stateHash: string): void;21 onFatal(error: string): void;22}2324export class SimRunner {25 private worker: Worker | null = null;26 private raf = 0;27 private lastTime = 0;28 private acc = 0;29 playing = false;30 speed: Speed = 4;31 pov = -1;32 /** Map payloads only stream while true (Watch tab active). */33 watch = false;34 finished = false;35 /** Worker has completed init ('ready' received); no credits before this. */36 private workerReady = false;3738 constructor(39 private bundle: SimBundle,40 private mock: boolean,41 private events: RunnerEvents,42 ) {}4344 start() {45 this.stopWorker();46 cancelAnimationFrame(this.raf); // never stack a second rAF loop on restart47 this.finished = false;48 this.workerReady = false;49 this.worker = new Worker(new URL('../sim-worker.ts', import.meta.url), { type: 'module' });50 this.worker.onmessage = (e: MessageEvent) => {51 const msg = e.data as WorkerOutMsg;52 if (msg.type === 'ready') this.workerReady = true;53 else if (msg.type === 'frame') this.events.onFrame(msg.frame);54 else if (msg.type === 'metrics') this.events.onMetrics(msg.frame);55 else if (msg.type === 'done') {56 this.finished = true;57 this.playing = false;58 this.events.onDone(msg.result, msg.stateHash);59 } else if (msg.type === 'fatal') {60 this.finished = true;61 this.playing = false;62 this.events.onFatal(msg.error);63 }64 };65 this.worker.onerror = (e) => this.events.onFatal(e.message || 'worker error');66 this.post({67 type: 'init',68 bundle: this.bundle,69 mock: this.mock,70 pov: this.pov,71 logLevel: logLevelFromSearch(location.search),72 });73 // Restore the watch state on (re)start — the worker starts with it off.74 if (this.watch) this.post({ type: 'watch', on: true });75 this.lastTime = 0;76 this.acc = 0;77 this.loop();78 }7980 restart() {81 this.start();82 this.playing = true;83 }8485 setPlaying(playing: boolean) {86 if (this.finished) return;87 this.playing = playing;88 }8990 setSpeed(speed: Speed) {91 this.speed = speed;92 }9394 setPov(pov: number) {95 this.pov = pov;96 this.post({ type: 'setPov', pov });97 }9899 /** Toggle map-frame streaming (on while the Watch tab is active). */100 setWatch(on: boolean) {101 this.watch = on;102 this.post({ type: 'watch', on });103 }104105 dispose() {106 cancelAnimationFrame(this.raf);107 this.stopWorker();108 }109110 private stopWorker() {111 if (this.worker) {112 this.worker.terminate();113 this.worker = null;114 }115 }116117 private post(msg: WorkerInMsg) {118 this.worker?.postMessage(msg);119 }120121 private loop = () => {122 this.raf = requestAnimationFrame(this.loop);123 const now = performance.now();124 const dt = this.lastTime ? Math.min(0.25, (now - this.lastTime) / 1000) : 0;125 this.lastTime = now;126 if (!this.playing || this.finished || !this.worker || !this.workerReady) return;127 if (this.speed === 'max') {128 this.post({ type: 'credit', n: MAX_BURST, sampleEvery: MAX_SAMPLE_EVERY });129 return;130 }131 this.acc += TICKS_PER_SEC[this.speed] * dt;132 const n = Math.floor(this.acc);133 if (n > 0) {134 this.acc -= n;135 // Sample so we post at most ~60 frames/sec of sim data.136 const sampleEvery = Math.max(1, Math.round(TICKS_PER_SEC[this.speed] / 60));137 this.post({ type: 'credit', n, sampleEvery });138 }139 };140}