1/**2 * Sim Web Worker — runs the deterministic sim off the render thread.3 *4 * Real code path imports @tribes/engine and @tribes/script-host; the ?mock=15 * flag swaps in the local dev-mock (same interfaces) until those land.6 *7 * DETERMINISM: each tribe VM's rng seed is derived exactly as8 * rngSeed = `${sim.seed}:${slot}`9 * (sim seed string, ASCII colon, 0-based slot index). Replays must use the10 * same derivation to reproduce a sim.11 *12 * Backpressure: the worker only advances while it holds tick credits granted13 * by the render thread ({type:'credit'}); one tick consumes one credit and a14 * frame is posted every `sampleEvery` ticks, so the page controls both sim15 * rate and message rate. Metrics frames are posted whenever the engine's16 * latestMetrics() returns a new frame (every SLOW_TICK_INTERVAL ticks) —17 * they are the stats-first dashboard's data feed and flow regardless of the18 * watch flag; the heavy map payload is only built while watching.19 */20import { LIMITS } from '@tribes/protocol';21import type { EngineWorld, Order, ScriptHost, SimEvent, TribeView, TribeVm } from '@tribes/protocol';22import { createMockWorld, createMockScriptHost } from './dev-mock';23import type {24 FrameCaravan,25 FrameSettlement,26 FrameThreat,27 FrameTribe,28 FrameWarband,29 LogLine,30 MapFrame,31 WorkerInMsg,32 WorkerOutMsg,33} from './sim/frames';34import { log, setLogLevel } from './log';3536const post = (m: WorkerOutMsg) => self.postMessage(m);3738interface TribeState {39 vm: TribeVm;40 alive: boolean;41 consecutiveOverruns: number;42 overBudget: boolean;43 error: string | null;44}4546let world: EngineWorld | null = null;47let host: ScriptHost | null = null;48let tribeStates: TribeState[] = [];49/**50 * True only once init() has fully completed (world + all VMs constructed).51 * pump() must never advance the sim before this: init awaits the script host,52 * and the worker event loop processes queued 'credit' messages during that53 * await — ticking a world with no VMs would silently diverge the replay.54 */55let initialized = false;56let credits = 0;57let sampleEvery = 1;58let pov = -1;59/** Map payloads are only built while the page is on the Watch tab. */60let watching = false;61let running = false;62let done = false;63let lastViews: TribeView[] = [];64/** Tick of the last metrics frame posted (avoid re-posting the same frame). */65let lastMetricsTick = -1;66/** Cap per feed channel buffered between posted frames (oldest dropped). */67const EVENT_BUFFER_CAP = 200;68/** Per-tribe event channels accumulated since the last posted frame. */69let pendingByTribe: Record<number, SimEvent[]> = {};70/** Per-tribe api.log lines (internal monologue) since the last posted frame. */71let pendingLogs: Record<number, LogLine[]> = {};72/** World channel: high-level summaries since the last posted frame. */73let pendingWorld: SimEvent[] = [];7475async function init(msg: Extract<WorkerInMsg, { type: 'init' }>) {76 if (msg.logLevel) setLogLevel(msg.logLevel);77 const b = msg.bundle;78 pov = msg.pov;79 const seed = b.sim.seed;80 const cfg = {81 seed,82 tribeCount: b.sim.config.tribeCount,83 mapSize: b.sim.config.mapSize,84 maxTicks: b.sim.config.maxTicks,85 };86 // Build everything in locals first: the awaits below yield to the worker87 // event loop, and module globals must not become visible half-initialized88 // (see `initialized`).89 let newWorld: EngineWorld;90 let newHost: ScriptHost;91 if (msg.mock) {92 newWorld = createMockWorld(cfg);93 newHost = await createMockScriptHost();94 } else {95 // Lazy imports: mock mode must never evaluate the real engine/script-host96 // modules (they are heavyweight, and ?mock=1 should work even while those97 // packages are mid-integration).98 const [{ createWorld }, { createScriptHost }] = await Promise.all([99 import('@tribes/engine'),100 import('@tribes/script-host'),101 ]);102 newWorld = createWorld(cfg);103 newHost = await createScriptHost();104 }105 const states: TribeState[] = b.sim.scripts.map((ref) => {106 const source = b.sources[ref.hash] ?? '';107 return {108 vm: newHost.createTribeVm(source, {109 gasPerTick: LIMITS.GAS_PER_TICK,110 memoryLimitBytes: LIMITS.VM_MEMORY_LIMIT,111 // DETERMINISM: seed + ':' + slot, exactly.112 rngSeed: `${seed}:${ref.slot}`,113 }),114 alive: true,115 consecutiveOverruns: 0,116 overBudget: false,117 error: null,118 };119 });120 world = newWorld;121 host = newHost;122 tribeStates = states;123 initialized = true;124 log.debug('worker init done', { tribes: states.length, sources: Object.keys(b.sources).length });125 post({ type: 'ready' });126 refreshViews();127 emitFrame();128 maybeEmitMetrics();129 pump();130}131132function refreshViews() {133 if (!world) return;134 lastViews = [];135 for (let t = 0; t < tribeStates.length; t++) {136 const st = tribeStates[t]!;137 if (!st.alive) continue;138 try {139 lastViews.push(world.viewFor(t));140 } catch (err) {141 log.warn('viewFor failed for tribe', t, err);142 }143 }144}145146/** True for events the world feed carries: high-level story beats only. */147function isWorldEvent(ev: SimEvent): boolean {148 return (149 ev.t === 'say' ||150 ev.t === 'eliminated' ||151 ev.t === 'forfeited' ||152 ev.t === 'settlement_captured' ||153 ev.t === 'settlement_founded' ||154 ev.t === 'tech_unlocked' ||155 ev.t === 'age_advanced' ||156 ev.t === 'threat_spawned' ||157 (ev.t === 'battle' && ev.defender != null)158 );159}160161/**162 * Gather this tick's feed channels from lastViews. Every viewer is a163 * spectator (bundles are public), so all tribes' channels are always sent —164 * the POV setting only affects the map. Per-tribe channel = that tribe's own165 * fog-filtered events minus OTHER tribes' say broadcasts (those are world166 * material). World = deduped high-level events across all views.167 * Call exactly once per step.168 */169function collectEvents() {170 const seen = new Set<string>();171 for (const v of lastViews) {172 const mine = (pendingByTribe[v.tribeId] ??= []);173 for (const ev of v.events) {174 if (ev.t !== 'say' || ev.tribe === v.tribeId) mine.push(ev);175 if (isWorldEvent(ev)) {176 const key = JSON.stringify(ev);177 if (!seen.has(key)) {178 seen.add(key);179 pendingWorld.push(ev);180 }181 }182 }183 if (mine.length > EVENT_BUFFER_CAP) pendingByTribe[v.tribeId] = mine.slice(-EVENT_BUFFER_CAP);184 }185 if (pendingWorld.length > EVENT_BUFFER_CAP) pendingWorld = pendingWorld.slice(-EVENT_BUFFER_CAP);186}187188function tickOnce() {189 if (!world) return;190 const orders = new Map<number, Order[]>();191 // lastViews holds the current pre-step views (refreshed after the last step).192 const aliveSet = new Set<number>();193 for (const v of lastViews) for (const id of v.aliveTribes) aliveSet.add(id);194 for (const view of lastViews) {195 const st = tribeStates[view.tribeId]!;196 if (!aliveSet.has(view.tribeId)) {197 st.alive = false;198 continue;199 }200 const outcome = st.vm.runTick(view);201 st.overBudget = outcome.overBudget;202 if (outcome.error) st.error = outcome.error;203 // api.log lines are the tribe's internal monologue — thread them to the204 // per-tribe feed, tick-stamped.205 const logs = (outcome as { logs?: string[] }).logs;206 if (logs !== undefined && logs.length > 0) {207 const mine = (pendingLogs[view.tribeId] ??= []);208 for (const text of logs) mine.push({ t: world.tick, text });209 if (mine.length > EVENT_BUFFER_CAP) pendingLogs[view.tribeId] = mine.slice(-EVENT_BUFFER_CAP);210 }211 if (outcome.overBudget) {212 st.consecutiveOverruns++;213 if (st.consecutiveOverruns >= LIMITS.MAX_CONSECUTIVE_OVERRUNS) {214 world.forfeit(view.tribeId);215 st.alive = false;216 st.vm.dispose();217 continue;218 }219 } else {220 st.consecutiveOverruns = 0;221 }222 orders.set(view.tribeId, outcome.orders);223 }224 world.step(orders);225 refreshViews();226 collectEvents();227 maybeEmitMetrics();228 // Replay-debugging aid: hashes at 1/10/100/1000/... (visible with ?log=debug).229 const t = world.tick;230 if (t === 1 || t === 10 || t === 100 || t % 1000 === 0) {231 log.debug('state hash', t, world.stateHash());232 }233}234235/** Post a metrics frame when the engine has computed a NEW one. */236function maybeEmitMetrics() {237 if (!world) return;238 const m = world.latestMetrics();239 if (m && m.tick !== lastMetricsTick) {240 lastMetricsTick = m.tick;241 post({ type: 'metrics', frame: m });242 }243}244245/** Latest per-tribe headline scores, from the last metrics frame seen. */246function scoreOf(tribeId: number): number {247 const m = world?.latestMetrics();248 return m?.tribes.find((t) => t.tribeId === tribeId)?.score ?? 0;249}250251function buildFrame(): MapFrame {252 const w = world!;253 const mapSize = w.config.mapSize;254 const settlements: FrameSettlement[] = [];255 const warbands: FrameWarband[] = [];256 const caravans: FrameCaravan[] = [];257 const threats: FrameThreat[] = [];258 const tribes: FrameTribe[] = [];259 let terrain = '';260 let distant = '';261 let claims = '';262263 const views = lastViews;264265 if (watching) {266 const seenSettlement = new Set<number>();267 const seenWarband = new Set<number>();268 const seenCaravan = new Set<number>();269 const seenThreat = new Set<number>();270 const wallsOf = (s: { buildings: Partial<Record<string, number>> }) =>271 (s.buildings as Partial<Record<'walls', number>>).walls ?? 0;272273 const takeView = (v: TribeView, includeEnemies: boolean) => {274 for (const s of v.settlements) {275 if (seenSettlement.has(s.id)) continue;276 seenSettlement.add(s.id);277 settlements.push({ id: s.id, tribe: v.tribeId, x: s.x, y: s.y, tier: s.tier, wallLevel: wallsOf(s), pop: s.pop });278 }279 for (const b of v.warbands) {280 if (seenWarband.has(b.id)) continue;281 seenWarband.add(b.id);282 warbands.push({ id: b.id, tribe: v.tribeId, x: b.x, y: b.y, size: b.size, kind: b.kind });283 }284 for (const c of v.caravans) {285 if (seenCaravan.has(c.id)) continue;286 seenCaravan.add(c.id);287 caravans.push({ id: c.id, tribe: c.tribe, x: c.x, y: c.y, resource: c.resource, amount: c.amount });288 }289 for (const th of v.threats) {290 if (seenThreat.has(th.id)) continue;291 seenThreat.add(th.id);292 threats.push({ id: th.id, kind: th.kind, x: th.x, y: th.y, strength: th.strength });293 }294 if (includeEnemies) {295 for (const e of v.enemySettlements) {296 if (seenSettlement.has(e.id)) continue;297 seenSettlement.add(e.id);298 // Fogged: population is not visible — 0 by contract.299 settlements.push({ id: e.id, tribe: e.tribe, x: e.x, y: e.y, tier: e.tier, wallLevel: e.wallLevel, pop: 0 });300 }301 for (const e of v.enemyWarbands) {302 if (seenWarband.has(e.id)) continue;303 seenWarband.add(e.id);304 warbands.push({ id: e.id, tribe: e.tribe, x: e.x, y: e.y, size: e.size, kind: e.kind });305 }306 }307 };308309 if (pov >= 0) {310 const view = views.find((v) => v.tribeId === pov);311 terrain = view ? view.terrain : '?'.repeat(mapSize * mapSize);312 distant = view ? view.distant : '?'.repeat(mapSize * mapSize);313 claims = view ? view.claims : '.'.repeat(mapSize * mapSize);314 if (view) takeView(view, true);315 } else {316 // Omniscient = union of every tribe's own view (terrain merged char-wise).317 const chars = new Array<string>(mapSize * mapSize).fill('?');318 const dchars = new Array<string>(mapSize * mapSize).fill('?');319 const cchars = new Array<string>(mapSize * mapSize).fill('.');320 for (const v of views) {321 for (let i = 0; i < v.terrain.length; i++) {322 if (chars[i] === '?' && v.terrain[i] !== '?') chars[i] = v.terrain[i]!;323 if (dchars[i] === '?' && v.distant[i] !== '?') dchars[i] = v.distant[i]!;324 if (cchars[i] === '.' && v.claims[i] !== '.') cchars[i] = v.claims[i]!;325 }326 // Own assets from every view; enemy sightings too, in case a tribe's327 // own view has gone missing (viewFor failure) — dedupe handles overlap.328 takeView(v, true);329 }330 terrain = chars.join('');331 // Distant knowledge is only shown where no tribe has hard knowledge.332 for (let i = 0; i < dchars.length; i++) if (chars[i] !== '?') dchars[i] = '?';333 distant = dchars.join('');334 claims = cchars.join('');335 }336 settlements.sort((a, b) => a.id - b.id);337 warbands.sort((a, b) => a.id - b.id);338 caravans.sort((a, b) => a.id - b.id);339 threats.sort((a, b) => a.id - b.id);340 }341342 for (let t = 0; t < tribeStates.length; t++) {343 const st = tribeStates[t]!;344 const v = views.find((view) => view.tribeId === t);345 let pop = 0;346 let coin = 0;347 if (v) {348 for (const s of v.settlements) {349 pop += s.pop;350 coin += s.stores.coin;351 }352 for (const b of v.warbands) pop += b.size;353 }354 tribes.push({355 id: t,356 alive: st.alive,357 pop,358 settlements: v ? v.settlements.length : 0,359 warbands: v ? v.warbands.length : 0,360 coin,361 score: scoreOf(t),362 age: v ? v.age : 0,363 overBudget: st.overBudget,364 error: st.error,365 });366 }367368 // Drain all feed channels into this frame — anything accumulated across369 // sampled-out ticks rides along with the next posted frame.370 const eventsByTribe = pendingByTribe;371 const logsByTribe = pendingLogs;372 const worldFeed = pendingWorld;373 pendingByTribe = {};374 pendingLogs = {};375 pendingWorld = [];376 return {377 tick: w.tick,378 maxTicks: w.config.maxTicks,379 pov,380 mapSize,381 hasMap: watching,382 terrain,383 distant,384 claims,385 settlements,386 warbands,387 caravans,388 threats,389 tribes,390 eventsByTribe,391 logsByTribe,392 world: worldFeed,393 };394}395396function emitFrame() {397 if (!world) return;398 post({ type: 'frame', frame: buildFrame() });399}400401function finish() {402 if (!world || done) return;403 done = true;404 refreshViews();405 // The engine recomputes metrics on the final tick — flush it first so the406 // dashboard's last point matches the result banner.407 maybeEmitMetrics();408 emitFrame();409 post({ type: 'done', result: world.result(), stateHash: world.stateHash() });410 for (const st of tribeStates) {411 try {412 st.vm.dispose();413 } catch {414 /* already disposed */415 }416 }417 host?.dispose();418}419420function pump() {421 if (running || done || !world || !initialized) return;422 running = true;423 try {424 const start = Date.now();425 while (credits > 0 && !world.isOver()) {426 tickOnce();427 credits--;428 if (world.tick % sampleEvery === 0) emitFrame();429 // Yield periodically so control messages are processed.430 if (Date.now() - start > 40) {431 setTimeout(pump0, 0);432 running = false;433 return;434 }435 }436 if (world.isOver()) finish();437 } catch (err) {438 post({ type: 'fatal', error: err instanceof Error ? (err.stack ?? err.message) : String(err) });439 done = true;440 }441 running = false;442}443const pump0 = () => pump();444445self.onmessage = (e: MessageEvent) => {446 const msg = e.data as WorkerInMsg;447 if (msg.type === 'init') {448 void init(msg).catch((err) =>449 post({ type: 'fatal', error: err instanceof Error ? (err.stack ?? err.message) : String(err) }),450 );451 } else if (msg.type === 'credit') {452 credits = Math.min(credits + msg.n, 100_000);453 sampleEvery = Math.max(1, msg.sampleEvery);454 pump();455 } else if (msg.type === 'setPov') {456 // POV only affects the map — feed channels are always per-tribe.457 pov = msg.pov;458 // Re-emit the current frame so a paused viewer updates immediately.459 if (world) emitFrame();460 } else if (msg.type === 'watch') {461 watching = msg.on;462 // Immediately give the map something to draw when watching turns on.463 if (world && watching) emitFrame();464 }465};