1/**2 * @tribes/script-host — sandboxed QuickJS host for tribe strategy scripts.3 *4 * One fresh QuickJS runtime+context per TribeVm. Deterministic gas metering5 * via interrupt-poll counting (never wall clock), hard VM memory limits, a6 * seeded in-VM PRNG, and host-side validation of every order a script emits.7 *8 * Guest global surface (documented, exhaustive):9 * - QuickJS default intrinsics MINUS `Date` (disabled at context creation):10 * Object/Array/Function/String/Number/Boolean/Symbol/Error types, Math,11 * JSON, RegExp, Proxy/Reflect, Map/Set, TypedArrays/ArrayBuffer, Promise.12 * - `Math.random` is replaced with a throwing stub (use `api.rng()`).13 * - global `eval` is deleted. (`Function` constructor still compiles guest14 * strings — deterministic and metered, so harmless.)15 * - `Promise` exists but the host never pumps the job queue: `tick` must be16 * synchronous; async work silently never runs.17 * - Host hooks `__begin` / `__runTick` / `__end` (frozen, non-writable).18 * - `tick` — whatever the submitted script defines.19 * - NO Date, console, setTimeout/setInterval, fetch, XMLHttpRequest,20 * TextEncoder, process, require, import().21 */22import {23 DefaultIntrinsics,24 getQuickJS,25 newQuickJSWASMModuleFromVariant,26} from "quickjs-emscripten";27import type {28 QuickJSContext,29 QuickJSRuntime,30 QuickJSSyncVariant,31 QuickJSWASMModule,32} from "quickjs-emscripten";33import type {34 Building,35 CompileResult,36 CraftTarget,37 DecreeKey,38 Order,39 Posture,40 Profession,41 Resource,42 RiteKind,43 ScriptHost,44 Tech,45 TickOutcome,46 TribeView,47 TribeVm,48 TribeVmOptions,49 WarbandKind,50 WarbandOrder,51 WarbandOrderType,52} from "@tribes/protocol";53import { BUILDINGS, LIMITS, PROFESSIONS, RESOURCES, TECHS } from "@tribes/protocol";54import { buildPrelude } from "./prelude.js";5556export {57 homesteaderSource,58 bulwarkSource,59 warlordSource,60 magnateSource,61 primitivesSource,62 referenceBots,63} from "./bots.js";64export type { QuickJSSyncVariant } from "quickjs-emscripten";6566/** TickOutcome plus the log lines api.log() captured during that tick. */67export interface TickOutcomeWithLogs extends TickOutcome {68 logs: string[];69}7071/** TribeVm plus access to the (host-side, capped) cumulative log ring. */72export interface TribeVmWithLogs extends TribeVm {73 runTick(view: TribeView): TickOutcomeWithLogs;74 /** Last ~256 api.log() lines across all ticks (oldest first). */75 getLogs(): string[];76}7778// ---------------------------------------------------------------------------79// Internals80// ---------------------------------------------------------------------------8182/** Poll budget for host-controlled (non-user-metered) eval sections, e.g. view83 * deserialization and order/log serialization. Deterministic: a fixed count,84 * never wall clock. Guards against `toJSON`/getter tricks in `memory`. */85const OVERHEAD_GAS = 1_000_000;86const MAX_STACK_BYTES = 512 * 1024;87const HOST_LOG_RING = 256;8889// Enum membership sets. RESOURCES/PROFESSIONS/BUILDINGS/TECHS come straight90// from the protocol's const arrays; the remaining unions are type-only in the91// protocol, so the literal lists here are typed against those unions — adding92// or removing a member in the protocol breaks this file at compile time.93const RESOURCE_SET: ReadonlySet<string> = new Set<Resource>(RESOURCES);94const PROFESSION_SET: ReadonlySet<string> = new Set<Profession>(PROFESSIONS);95const BUILDING_SET: ReadonlySet<string> = new Set<Building>(BUILDINGS);96const TECH_SET: ReadonlySet<string> = new Set<Tech>(TECHS);97const CRAFT_TARGETS: ReadonlySet<string> = new Set<CraftTarget>([98 "tools", "weapons", "armor", "mill", "brew", "fodder",99]);100const WARBAND_KINDS: ReadonlySet<string> = new Set<WarbandKind>([101 "infantry", "archers", "cavalry",102]);103const WARBAND_ORDER_TYPES: ReadonlySet<string> = new Set<WarbandOrderType>([104 "garrison", "patrol", "march", "raid", "besiege", "escort", "disband",105]);106const POSTURES: ReadonlySet<string> = new Set<Posture>(["ally", "neutral", "hostile"]);107const DECREE_KEYS: ReadonlySet<string> = new Set<DecreeKey>([108 "rations", "slaughter", "fodder", "ale_ration", "expansion",109]);110const RITE_KINDS: ReadonlySet<string> = new Set<RiteKind>([111 "blessing", "harvest", "warding", "war",112]);113114const isInt = (v: unknown): v is number =>115 typeof v === "number" && Number.isSafeInteger(v);116/** Entity ids and map coordinates: non-negative safe integers. */117const isId = (v: unknown): v is number => isInt(v) && v >= 0;118/** Per-mille rates: integer in [0, 1000]. Out-of-range DROPS the order119 * (v1 philosophy: never clamp — silently "fixing" a script hides its bug). */120const isPermille = (v: unknown): v is number => isInt(v) && v >= 0 && v <= 1000;121/** Resource quantities: non-negative safe integers. */122const isAmount = (v: unknown): v is number => isId(v);123const isResource = (v: unknown): v is Resource =>124 typeof v === "string" && RESOURCE_SET.has(v);125const isProfession = (v: unknown): v is Profession =>126 typeof v === "string" && PROFESSION_SET.has(v);127const isBuilding = (v: unknown): v is Building =>128 typeof v === "string" && BUILDING_SET.has(v);129const isTech = (v: unknown): v is Tech => typeof v === "string" && TECH_SET.has(v);130131/**132 * Sanitize an api.say() message: strip control and bidi-override characters133 * (they'd be rendered in every viewer's feed and fed to other scripts),134 * collapse whitespace runs, trim, cap length. Returns null when nothing135 * printable remains.136 */137export function sanitizeSay(raw: string): string | null {138 const cleaned = raw139 // eslint-disable-next-line no-control-regex140 .replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069\u200e\u200f]/g, "")141 .replace(/\s+/g, " ")142 .trim()143 .slice(0, LIMITS.SAY_MAX_CHARS);144 return cleaned.length > 0 ? cleaned : null;145}146147/**148 * Rebuild a `supplies` bag into a FRESH plain object (prototype-pollution149 * safe: only own enumerable keys that are Resource enum members are copied,150 * so a "__proto__"/"constructor" key can never survive — it fails the enum151 * check and drops the whole order). Values must be non-negative integers.152 * Returns null when any key or value is malformed (strict: no partial keep).153 */154function validateSupplies(raw: unknown): Partial<Record<Resource, number>> | null {155 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;156 const src = raw as Record<string, unknown>;157 const fresh: Partial<Record<Resource, number>> = {};158 for (const key of Object.keys(src)) {159 if (!isResource(key)) return null;160 const value = src[key];161 if (!isAmount(value)) return null;162 fresh[key] = value;163 }164 return fresh;165}166167/** Rebuild a WarbandOrder: known type + target that is null, an id, or a168 * fresh {x, y} point. Which target shape each type accepts is engine169 * semantics; the host only guarantees the transport shape. */170function validateWarbandOrder(raw: unknown): WarbandOrder | null {171 if (typeof raw !== "object" || raw === null) return null;172 const o = raw as Record<string, unknown>;173 const type = o["type"];174 if (typeof type !== "string" || !WARBAND_ORDER_TYPES.has(type)) return null;175 const t = o["target"];176 let target: WarbandOrder["target"];177 if (t === null || t === undefined) target = null;178 else if (isId(t)) target = t;179 else if (typeof t === "object") {180 const p = t as Record<string, unknown>;181 if (!isId(p["x"]) || !isId(p["y"])) return null;182 target = { x: p["x"], y: p["y"] };183 } else return null;184 return { type: type as WarbandOrderType, target };185}186187/**188 * Strict host-side shape validation of the v2 Order union. Anything189 * malformed -> null (dropped, never repaired): unknown kinds, non-enum190 * strings, non-integer numbers, and out-of-range per-mille values are all191 * dropped whole. Every accepted order is rebuilt into fresh literals — no192 * guest-shaped object (or its prototype chain) crosses into the engine.193 */194export function validateOrder(raw: unknown): Order | null {195 if (typeof raw !== "object" || raw === null) return null;196 const o = raw as Record<string, unknown>;197 switch (o["kind"]) {198 case "allocate": {199 if (!isProfession(o["profession"]) || !isPermille(o["permille"])) return null;200 const sid = o["settlementId"];201 if (sid === undefined) {202 return { kind: "allocate", profession: o["profession"], permille: o["permille"] };203 }204 return isId(sid)205 ? { kind: "allocate", profession: o["profession"], permille: o["permille"], settlementId: sid }206 : null;207 }208 case "craft": {209 const target = o["target"];210 if (typeof target !== "string" || !CRAFT_TARGETS.has(target)) return null;211 return isPermille(o["permille"])212 ? { kind: "craft", target: target as CraftTarget, permille: o["permille"] }213 : null;214 }215 case "build": {216 if (!isBuilding(o["building"]) || !isId(o["settlementId"])) return null;217 const count = o["count"];218 if (count === undefined) {219 return { kind: "build", building: o["building"], settlementId: o["settlementId"] };220 }221 // count must be a positive integer; affordability is engine business.222 return isInt(count) && count >= 1223 ? { kind: "build", building: o["building"], settlementId: o["settlementId"], count }224 : null;225 }226 case "demolish":227 return isBuilding(o["building"]) && isId(o["settlementId"])228 ? { kind: "demolish", building: o["building"], settlementId: o["settlementId"] }229 : null;230 case "found": {231 if (!isId(o["x"]) || !isId(o["y"])) return null;232 if (!isInt(o["settlers"]) || o["settlers"] < 1) return null;233 const supplies = validateSupplies(o["supplies"]);234 return supplies !== null235 ? { kind: "found", x: o["x"], y: o["y"], settlers: o["settlers"], supplies }236 : null;237 }238 case "route":239 return isId(o["fromId"]) &&240 isId(o["toId"]) &&241 isResource(o["resource"]) &&242 isPermille(o["permilleOfSurplus"])243 ? {244 kind: "route",245 fromId: o["fromId"],246 toId: o["toId"],247 resource: o["resource"],248 permilleOfSurplus: o["permilleOfSurplus"],249 }250 : null;251 case "muster": {252 const unitKind = o["unitKind"];253 if (typeof unitKind !== "string" || !WARBAND_KINDS.has(unitKind)) return null;254 return isId(o["settlementId"]) && isInt(o["size"]) && (o["size"] as number) >= 1255 ? {256 kind: "muster",257 settlementId: o["settlementId"],258 size: o["size"] as number,259 unitKind: unitKind as WarbandKind,260 }261 : null;262 }263 case "command": {264 if (!isId(o["warbandId"])) return null;265 const order = validateWarbandOrder(o["order"]);266 return order !== null ? { kind: "command", warbandId: o["warbandId"], order } : null;267 }268 case "trade_offer": {269 if (!isResource(o["give"]) || !isResource(o["want"])) return null;270 if (!isAmount(o["giveAmount"]) || !isAmount(o["wantAmount"])) return null;271 // A zero-for-zero offer is meaningless; want 0 = gift, give 0 = plea.272 if (o["giveAmount"] === 0 && o["wantAmount"] === 0) return null;273 const tribe = o["tribe"];274 if (tribe !== null && !isId(tribe)) return null;275 if (typeof o["repeat"] !== "boolean") return null;276 return {277 kind: "trade_offer",278 give: o["give"],279 giveAmount: o["giveAmount"],280 want: o["want"],281 wantAmount: o["wantAmount"],282 tribe: tribe as number | null,283 repeat: o["repeat"],284 };285 }286 case "trade_cancel":287 return isId(o["offerId"]) ? { kind: "trade_cancel", offerId: o["offerId"] } : null;288 case "stance": {289 const posture = o["posture"];290 if (typeof posture !== "string" || !POSTURES.has(posture)) return null;291 return isId(o["tribeId"])292 ? { kind: "stance", tribeId: o["tribeId"], posture: posture as Posture }293 : null;294 }295 case "research":296 return isTech(o["tech"]) ? { kind: "research", tech: o["tech"] } : null;297 case "decree": {298 const key = o["key"];299 if (typeof key !== "string" || !DECREE_KEYS.has(key)) return null;300 // Decrees are per-mille policy rates (design spine: all rates 0..1000).301 return isPermille(o["value"])302 ? { kind: "decree", key: key as DecreeKey, value: o["value"] }303 : null;304 }305 case "rite": {306 const rite = o["rite"];307 if (typeof rite !== "string" || !RITE_KINDS.has(rite)) return null;308 const target = o["target"];309 if (target !== null && target !== undefined && !isId(target)) return null;310 return { kind: "rite", rite: rite as RiteKind, target: target ?? null };311 }312 case "advance":313 return { kind: "advance" };314 case "say": {315 if (typeof o["text"] !== "string") return null;316 const text = sanitizeSay(o["text"]);317 return text !== null ? { kind: "say", text } : null;318 }319 default:320 return null;321 }322}323324/** FNV-1a 32-bit over the seed string -> uint32 for the guest splitmix32. */325function seed32(seed: string): number {326 let h = 0x811c9dc5;327 for (let i = 0; i < seed.length; i++) {328 h ^= seed.charCodeAt(i);329 h = Math.imul(h, 0x01000193);330 }331 return h >>> 0;332}333334/** JSON for injection into evalCode source (escape LS/PS just in case). */335function jsonLiteral(value: unknown): string {336 return JSON.stringify(value)337 .replace(/\u2028/g, "\\u2028")338 .replace(/\u2029/g, "\\u2029");339}340341function formatError(dumped: unknown): string {342 if (typeof dumped === "object" && dumped !== null) {343 const e = dumped as { name?: unknown; message?: unknown };344 if (typeof e.name === "string" && typeof e.message === "string") {345 return `${e.name}: ${e.message}`;346 }347 }348 return String(dumped);349}350351type EvalMode = "metered" | "overhead";352353interface EvalResultLite {354 str?: string;355 errorText?: string;356}357358class TribeVmImpl implements TribeVmWithLogs {359 private readonly runtime: QuickJSRuntime;360 private readonly context: QuickJSContext;361 private readonly gasPerTick: number;362363 private mode: EvalMode | "off" = "off";364 private meteredPolls = 0;365 private overheadPolls = 0;366 private interrupted = false;367 private overheadInterrupted = false;368369 /** Set when the prelude or the script's top-level failed; every subsequent370 * runTick deterministically reports it. */371 private installFailure: { overBudget: boolean; error: string | null } | null = null;372373 private readonly logRing: string[] = [];374 private disposed = false;375376 constructor(377 module: QuickJSWASMModule,378 source: string,379 opts: TribeVmOptions,380 private readonly onDispose: () => void,381 ) {382 this.gasPerTick = opts.gasPerTick;383 this.runtime = module.newRuntime();384 this.runtime.setMemoryLimit(opts.memoryLimitBytes);385 this.runtime.setMaxStackSize(MAX_STACK_BYTES);386 this.runtime.setInterruptHandler(() => {387 if (this.mode === "metered") {388 this.meteredPolls++;389 if (this.meteredPolls > this.gasPerTick) {390 this.interrupted = true;391 return true;392 }393 } else if (this.mode === "overhead") {394 this.overheadPolls++;395 if (this.overheadPolls > OVERHEAD_GAS) {396 this.overheadInterrupted = true;397 return true;398 }399 }400 return false;401 });402 // Fresh context; Date intrinsic disabled entirely (no wall-clock access).403 this.context = this.runtime.newContext({404 intrinsics: { ...DefaultIntrinsics, Date: false },405 });406407 const prelude = this.evalIn(408 "overhead",409 buildPrelude(seed32(opts.rngSeed), LIMITS.MEMORY_MAX_BYTES),410 "prelude.js",411 );412 if (prelude.errorText !== undefined) {413 this.installFailure = {414 overBudget: false,415 error: `internal: prelude failed: ${prelude.errorText}`,416 };417 return;418 }419 // Run the submitted script's top level under the same per-tick gas budget.420 this.meteredPolls = 0;421 this.interrupted = false;422 const install = this.evalIn("metered", source, "tribe.js");423 if (this.interrupted) {424 this.installFailure = { overBudget: true, error: null };425 } else if (install.errorText !== undefined) {426 this.installFailure = { overBudget: false, error: install.errorText };427 }428 }429430 private evalIn(431 mode: EvalMode,432 code: string,433 filename: string,434 wantString = false,435 ): EvalResultLite {436 this.mode = mode;437 let result;438 try {439 result = this.context.evalCode(code, filename, { type: "global" });440 } finally {441 this.mode = "off";442 }443 if (result.error) {444 const dumped = this.context.dump(result.error);445 result.error.dispose();446 return { errorText: formatError(dumped) };447 }448 let str: string | undefined;449 if (wantString) {450 try {451 str = this.context.getString(result.value);452 } catch {453 str = undefined;454 }455 }456 result.value.dispose();457 return { str };458 }459460 private pushLogs(lines: string[]): void {461 for (const line of lines) {462 if (this.logRing.length >= HOST_LOG_RING) this.logRing.shift();463 this.logRing.push(line);464 }465 }466467 /** Run __end() outside the user gas budget: collects orders/logs, enforces468 * the persistent-memory JSON cap, resets state for the next tick. */469 private collect(): {470 orders: unknown[];471 logs: string[];472 memoryExceeded: boolean;473 errorText?: string;474 } {475 const end = this.evalIn("overhead", "__end()", "host.js", true);476 if (end.errorText !== undefined || end.str === undefined) {477 const reason = this.overheadInterrupted478 ? "post-tick serialization exceeded the host overhead budget"479 : end.errorText ?? "internal: __end returned no payload";480 return { orders: [], logs: [], memoryExceeded: false, errorText: reason };481 }482 try {483 const payload = JSON.parse(end.str) as {484 orders?: unknown;485 logs?: unknown;486 memoryExceeded?: unknown;487 };488 const logs = Array.isArray(payload.logs)489 ? payload.logs.filter((l): l is string => typeof l === "string")490 : [];491 return {492 orders: Array.isArray(payload.orders) ? payload.orders : [],493 logs,494 memoryExceeded: payload.memoryExceeded === true,495 };496 } catch {497 return {498 orders: [],499 logs: [],500 memoryExceeded: false,501 errorText: "internal: __end payload was not valid JSON",502 };503 }504 }505506 runTick(view: TribeView): TickOutcomeWithLogs {507 if (this.disposed) throw new Error("TribeVm is disposed");508 this.meteredPolls = 0;509 this.interrupted = false;510 this.overheadPolls = 0;511 this.overheadInterrupted = false;512513 if (this.installFailure !== null) {514 return {515 orders: [],516 gasUsed: 0,517 overBudget: this.installFailure.overBudget,518 error: this.installFailure.error,519 logs: [],520 };521 }522523 // __begin takes the view's JSON *text* as a string argument and parses it524 // in-VM, so the outer jsonLiteral turns that text into a string literal.525 const begin = this.evalIn(526 "overhead",527 `__begin(${jsonLiteral(JSON.stringify(view))})`,528 "host.js",529 );530 if (begin.errorText !== undefined) {531 return {532 orders: [],533 gasUsed: 0,534 overBudget: false,535 error: `internal: failed to deliver state: ${begin.errorText}`,536 logs: [],537 };538 }539540 const run = this.evalIn("metered", "__runTick()", "host.js");541 const gasUsed = this.meteredPolls;542543 // Always drain __end(): it collects logs, clears buffers, and enforces the544 // memory cap even on over-budget/throwing ticks.545 const collected = this.collect();546 this.pushLogs(collected.logs);547548 if (this.interrupted) {549 // Deterministic overrun: discard any partially-emitted orders.550 return { orders: [], gasUsed, overBudget: true, error: null, logs: collected.logs };551 }552 if (run.errorText !== undefined) {553 return {554 orders: [],555 gasUsed,556 overBudget: false,557 error: run.errorText,558 logs: collected.logs,559 };560 }561 if (collected.errorText !== undefined) {562 return {563 orders: [],564 gasUsed,565 overBudget: false,566 error: collected.errorText,567 logs: collected.logs,568 };569 }570 if (collected.memoryExceeded) {571 return {572 orders: [],573 gasUsed,574 overBudget: false,575 error: `persistent memory exceeded ${LIMITS.MEMORY_MAX_BYTES} JSON bytes (or was not JSON-serializable); memory was reset`,576 logs: collected.logs,577 };578 }579580 const orders: Order[] = [];581 let says = 0;582 for (const raw of collected.orders) {583 // Keep the first LIMITS.ACTIONS_PER_TICK VALID orders; drop the rest.584 if (orders.length >= LIMITS.ACTIONS_PER_TICK) break;585 const order = validateOrder(raw);586 if (order === null) continue;587 if (order.kind === 'say' && ++says > LIMITS.SAYS_PER_TICK) continue;588 orders.push(order);589 }590 return { orders, gasUsed, overBudget: false, error: null, logs: collected.logs };591 }592593 getLogs(): string[] {594 return [...this.logRing];595 }596597 dispose(): void {598 if (this.disposed) return;599 this.disposed = true;600 this.context.dispose();601 this.runtime.dispose();602 this.onDispose();603 }604}605606class ScriptHostImpl implements ScriptHost {607 private readonly vms = new Set<TribeVmImpl>();608 private disposed = false;609610 constructor(private readonly module: QuickJSWASMModule) {}611612 /** Syntax-only check: compiles the source in a throwaway context without613 * executing it (compileOnly). Verbatim QuickJS error text on failure. */614 compile(source: string): CompileResult {615 if (this.disposed) throw new Error("ScriptHost is disposed");616 const runtime = this.module.newRuntime();617 runtime.setMemoryLimit(LIMITS.VM_MEMORY_LIMIT);618 runtime.setMaxStackSize(MAX_STACK_BYTES);619 const context = runtime.newContext({620 intrinsics: { ...DefaultIntrinsics, Date: false },621 });622 try {623 const result = context.evalCode(source, "script.js", {624 type: "global",625 compileOnly: true,626 });627 if (result.error) {628 const dumped = context.dump(result.error);629 result.error.dispose();630 return { ok: false, error: formatError(dumped) };631 }632 result.value.dispose();633 return { ok: true, error: null };634 } finally {635 context.dispose();636 runtime.dispose();637 }638 }639640 createTribeVm(source: string, opts: TribeVmOptions): TribeVmWithLogs {641 if (this.disposed) throw new Error("ScriptHost is disposed");642 const vm = new TribeVmImpl(this.module, source, opts, () => {643 this.vms.delete(vm);644 });645 this.vms.add(vm);646 return vm;647 }648649 dispose(): void {650 if (this.disposed) return;651 this.disposed = true;652 for (const vm of [...this.vms]) vm.dispose();653 this.vms.clear();654 }655}656657/**658 * Create a script host.659 *660 * @param variant Optional quickjs-emscripten sync variant. Defaults to the661 * bundled RELEASE_SYNC via getQuickJS() (works in Node, browsers via Vite).662 * For Cloudflare Workers, pass a Workers-compatible variant (e.g. from663 * `@cf-wasm/quickjs`) — no other code changes needed.664 */665/**666 * Default engine: quickjs-emscripten >= 0.32 RELEASE_SYNC (wasmfile). The667 * pinned engine version is part of replay semantics — gas polls are counted668 * per wasm build — and 0.32 is the minimum safe version: earlier builds never669 * fired interrupt polls inside regex matching, letting a catastrophic-670 * backtracking regex bypass the gas budget entirely and hang the VM (security671 * review finding; regression-tested in test/regex-gas.test.ts). Every672 * environment that RUNS sims must use this same build. The Cloudflare Worker673 * validator passes @cf-wasm/quickjs's workerd packaging of the same 0.32674 * wasm; do not let those versions drift apart.675 */676export async function createScriptHost(variant?: QuickJSSyncVariant): Promise<ScriptHost> {677 const module =678 variant !== undefined ? await newQuickJSWASMModuleFromVariant(variant) : await getQuickJS();679 return new ScriptHostImpl(module);680}