clone

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

main
tribes / worker / src / simrun.ts
simrun.ts 10.4K
1/**2 * Server-side sim precompute: run the full deterministic match once at3 * create_sim time (background, ctx.waitUntil) and store the complete metrics4 * series + result, gzipped, so share links render finished stats instantly.5 *6 * If this dies on a CPU limit (free-plan Workers), nothing breaks: the viewer7 * falls back to computing in-browser and POSTing the cache (first-write-only).8 */9import { LIMITS } from '@tribes/protocol';10import type { MetricsFrame, Order, SimResult } from '@tribes/protocol';11import { createWorld } from '@tribes/engine';12import type { Env } from './env';13import * as db from './db';14import { nowSec } from './util';15import { getHost } from './runtime-validation';16import { getMetricsGz, getSourceAnywhere, putMetricsGzIfAbsent, replayKeyFor } from './r2blobs';17import { rateSim, ELO_START } from './rating';18import { isUniqueViolation } from './friendly-id';1920export interface StoredMetrics {21 frames: MetricsFrame[];22 result: SimResult;23 stateHash: string;24}2526/** Upper bound on an accepted client-posted cache (gzipped bytes). */27export const METRICS_MAX_GZ_BYTES = 1024 * 1024;2829export async function gzipJson(value: unknown): Promise<ArrayBuffer> {30 const stream = new Blob([JSON.stringify(value)]).stream().pipeThrough(new CompressionStream('gzip'));31 return new Response(stream).arrayBuffer();32}3334export async function gunzipJson(data: ArrayBuffer): Promise<unknown> {35 const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('gzip'));36 return JSON.parse(await new Response(stream).text());37}3839/** Light shape check for client-posted caches (server-computed ones are trusted). */40export function looksLikeStoredMetrics(v: unknown): v is StoredMetrics {41 if (typeof v !== 'object' || v === null) return false;42 const m = v as Record<string, unknown>;43 return (44 Array.isArray(m['frames']) &&45 m['frames'].length <= LIMITS.MAX_TICKS / LIMITS.SLOW_TICK_INTERVAL + 1 &&46 typeof m['stateHash'] === 'string' &&47 typeof m['result'] === 'object' &&48 m['result'] !== null &&49 Array.isArray((m['result'] as Record<string, unknown>)['tribes'])50 );51}5253/** The sim's replay identity — derived from ITS row, never from a client. */54async function replayKeyForSim(env: Env, simId: string): Promise<string | null> {55 const sim = await db.getSimById(env.DB, simId, '');56 if (!sim) return null;57 const refs = await db.getSimScripts(env.DB, simId);58 return replayKeyFor(59 sim.seed,60 sim.config_json,61 refs.map((r) => r.hash),62 );63}6465export async function storeMetrics(66 env: Env,67 simId: string,68 stored: StoredMetrics,69 computedBy: 'server' | 'client',70): Promise<boolean> {71 const replayKey = await replayKeyForSim(env, simId);72 if (replayKey === null) return false;73 const data = await gzipJson(stored);74 // Bytes to R2 at the server-derived replay key; D1 keeps a pointer row.75 // First-write-wins semantics live on the pointer row (same as before);76 // the R2 object is deterministic content, so a lost race is identical.77 await putMetricsGzIfAbsent(env, replayKey, data);78 const res = await env.DB79 .prepare(80 `INSERT OR IGNORE INTO sim_metrics (sim_id, data, state_hash, computed_by, created_at, replay_key, in_r2)81 VALUES (?1, NULL, ?2, ?3, ?4, ?5, 1)`,82 )83 .bind(simId, stored.stateHash, computedBy, nowSec(), replayKey)84 .run();85 return (res.meta.changes ?? 0) > 0;86}8788/** Normalize D1 BLOB representations (ArrayBuffer/typed array/number[]). */89function normalizeBlob(d: unknown): Uint8Array | null {90 if (d == null) return null;91 if (d instanceof Uint8Array) return d;92 if (d instanceof ArrayBuffer) return new Uint8Array(d);93 if (Array.isArray(d)) return Uint8Array.from(d as number[]);94 console.warn('sim_metrics blob came back as unexpected type:', typeof d);95 return null;96}9798export async function getStoredMetricsGz(env: Env, simId: string): Promise<Uint8Array | null> {99 const row = await env.DB100 .prepare(`SELECT data, replay_key, in_r2 FROM sim_metrics WHERE sim_id = ?1`)101 .bind(simId)102 .first<{ data: unknown; replay_key: string | null; in_r2: number }>();103 if (row) {104 if (row.in_r2 === 1 && row.replay_key !== null) {105 const gz = await getMetricsGz(env, row.replay_key);106 if (gz !== null) return gz;107 console.warn('metrics missing from R2:', simId, row.replay_key);108 }109 return normalizeBlob(row.data); // legacy inline row110 }111 // No pointer row yet — an identical roster+seed sim may already have112 // computed these exact metrics. Content-addressing makes that a free hit.113 const replayKey = await replayKeyForSim(env, simId);114 if (replayKey === null) return null;115 return getMetricsGz(env, replayKey);116}117118/**119 * Rate a finished PUBLIC sim from the server replay's own result (never from120 * the metrics blob — a client may have won the first-write race on that row;121 * for public sims clients are rejected, but we still only trust the replay).122 *123 * Idempotent: rating_events' PK (sim_id, script_id) guards the whole batch —124 * a duplicate application aborts the transaction, so ratings/sim_results are125 * applied exactly once per sim. Returns true when this call applied them.126 */127export async function applySimRating(128 env: Env,129 simId: string,130 refs: readonly db.SimScriptRow[],131 result: SimResult,132 finalFrame: MetricsFrame | null,133): Promise<boolean> {134 const ids = refs.map((r) => r.script_id);135 if (new Set(ids).size !== ids.length) {136 // Mirror matches carry zero rating information and would collide on the137 // per-script PKs — leave them unrated (they stay public share links).138 console.log(JSON.stringify({ event: 'sim_rating_skipped', simId, reason: 'duplicate_scripts' }));139 return false;140 }141 if (await db.hasRatingEvents(env.DB, simId)) return false;142 const now = nowSec();143 await env.DB.batch(ids.map((id) => db.stmtEnsureRating(env.DB, id, now)));144 const current = await db.getRatings(env.DB, ids);145146 const scoreOf = (slot: number): number =>147 result.tribes.find((t) => t.tribeId === slot)?.score ?? 0;148 const battlesOf = (slot: number): number => {149 const m = finalFrame?.tribes.find((t) => t.tribeId === slot);150 return m === undefined ? 0 : m.battlesWon + m.battlesLost;151 };152 const outcomes = rateSim(153 refs.map((r) => ({154 scriptId: r.script_id,155 score: scoreOf(r.slot),156 rating: current.get(r.script_id) ?? ELO_START,157 })),158 );159 const bySlot = new Map(refs.map((r) => [r.script_id, r.slot]));160 try {161 await env.DB.batch([162 // Plain INSERTs first: a PK clash (already rated) aborts the whole batch.163 ...outcomes.map((o) =>164 db.stmtInsertRatingEvent(env.DB, {165 simId,166 scriptId: o.scriptId,167 delta: o.delta,168 ratingAfter: o.ratingAfter,169 createdAt: now,170 }),171 ),172 ...outcomes.map((o) =>173 db.stmtApplyRating(env.DB, {174 scriptId: o.scriptId,175 rating: o.ratingAfter,176 winInc: o.place === 1 ? 1 : 0, // wins = sims won177 battlesInc: battlesOf(bySlot.get(o.scriptId)!),178 now,179 }),180 ),181 ...outcomes.map((o) =>182 db.stmtInsertSimResult(env.DB, {183 simId,184 slot: bySlot.get(o.scriptId)!,185 scriptId: o.scriptId,186 score: scoreOf(bySlot.get(o.scriptId)!),187 place: o.place,188 battles: battlesOf(bySlot.get(o.scriptId)!),189 }),190 ),191 ]);192 } catch (err) {193 if (isUniqueViolation(err)) return false; // concurrently applied elsewhere194 throw err;195 }196 console.log(JSON.stringify({ event: 'sim_rated', simId, scripts: ids.length }));197 return true;198}199200/** Run the full match and store its metrics. Deterministic: identical to what201 * any viewer's replay computes (same engine build, same rngSeed derivation).202 * Public sims are additionally rated from this replay's result. */203export async function computeSimMetrics(env: Env, simId: string): Promise<void> {204 try {205 const sim = await db.getSimById(env.DB, simId, '');206 if (!sim) return;207 const isPublic = sim.visibility === 'public';208 const existing = await getStoredMetricsGz(env, simId);209 if (existing !== null) {210 // Metrics already stored. Private sims: done. Public sims: the rating211 // may still be missing (e.g. a crash between the two writes) — replay212 // again unless it landed; never rate from the stored blob.213 if (!isPublic || (await db.hasRatingEvents(env.DB, simId))) return;214 }215 const refs = await db.getSimScripts(env.DB, simId);216 const host = await getHost();217 const config = JSON.parse(sim.config_json) as { tribeCount: number; mapSize: number; maxTicks: number };218 const world = createWorld({ seed: sim.seed, ...config });219 const vms = [];220 for (const ref of refs) {221 const source = (await getSourceAnywhere(env, ref.hash)) ?? '';222 vms.push(223 host.createTribeVm(source, {224 gasPerTick: LIMITS.GAS_PER_TICK,225 memoryLimitBytes: LIMITS.VM_MEMORY_LIMIT,226 // DETERMINISM: must match the browser runner exactly: seed + ':' + slot.227 rngSeed: `${sim.seed}:${ref.slot}`,228 }),229 );230 }231 const overruns = vms.map(() => 0);232 const frames: MetricsFrame[] = [];233 try {234 while (!world.isOver()) {235 const orders = new Map<number, Order[]>();236 const alive = new Set(world.result().tribes.filter((t) => t.alive).map((t) => t.tribeId));237 for (let slot = 0; slot < vms.length; slot++) {238 if (!alive.has(slot)) continue;239 const out = vms[slot]!.runTick(world.viewFor(slot));240 if (out.overBudget) {241 if (++overruns[slot]! >= LIMITS.MAX_CONSECUTIVE_OVERRUNS) world.forfeit(slot);242 } else overruns[slot] = 0;243 orders.set(slot, out.orders);244 }245 world.step(orders);246 const m = world.latestMetrics();247 if (m && (frames.length === 0 || m.tick !== frames[frames.length - 1]!.tick)) frames.push(m);248 }249 const result = world.result();250 if (existing === null) {251 await storeMetrics(env, simId, { frames, result, stateHash: world.stateHash() }, 'server');252 console.log(JSON.stringify({ event: 'sim_metrics_computed', simId, frames: frames.length }));253 }254 if (isPublic) {255 // Rate strictly from this replay's result (see applySimRating).256 await applySimRating(env, simId, refs, result, frames[frames.length - 1] ?? null);257 }258 } finally {259 vms.forEach((vm) => vm.dispose());260 }261 } catch (err) {262 // CPU-limit death lands here or kills us outright — the client fallback covers it.263 console.warn('sim metrics precompute failed:', simId, err);264 }265}