clone

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

main
tribes / packages / script-host / test / host.test.ts
host.test.ts 20.6K
1import { describe, expect, it } from "vitest";2import type { Order, TribeVmOptions } from "@tribes/protocol";3import { LIMITS } from "@tribes/protocol";4import {5 createScriptHost,6 referenceBots,7 validateOrder,8 type TribeVmWithLogs,9} from "../src/index.js";10import { makeView } from "./fixtures.js";1112const BOT_SOURCES = Object.values(referenceBots);1314const OPTS: TribeVmOptions = {15 gasPerTick: LIMITS.GAS_PER_TICK,16 memoryLimitBytes: LIMITS.VM_MEMORY_LIMIT,17 rngSeed: "12345",18};1920async function withVm<T>(21 source: string,22 opts: Partial<TribeVmOptions>,23 fn: (vm: TribeVmWithLogs) => T,24): Promise<T> {25 const host = await createScriptHost();26 const vm = host.createTribeVm(source, { ...OPTS, ...opts }) as TribeVmWithLogs;27 try {28 return fn(vm);29 } finally {30 host.dispose();31 }32}3334// ---------------------------------------------------------------------------35// compile36// ---------------------------------------------------------------------------3738describe("compile", () => {39 it("accepts all reference bots", async () => {40 const host = await createScriptHost();41 expect(BOT_SOURCES.length).toBe(4);42 for (const source of BOT_SOURCES) {43 const result = host.compile(source);44 expect(result).toEqual({ ok: true, error: null });45 }46 host.dispose();47 });4849 it("returns QuickJS error text for syntax garbage", async () => {50 const host = await createScriptHost();51 const result = host.compile("function tick( { nope ][");52 expect(result.ok).toBe(false);53 expect(typeof result.error).toBe("string");54 expect(result.error!.length).toBeGreaterThan(0);55 expect(result.error).toMatch(/SyntaxError/i);56 host.dispose();57 });5859 it("does not execute top-level code", async () => {60 const host = await createScriptHost();61 // Would loop forever if compile executed the source.62 const result = host.compile("while (true) {} function tick() {}");63 expect(result.ok).toBe(true);64 host.dispose();65 });66});6768// ---------------------------------------------------------------------------69// api orders (v2 policy verbs)70// ---------------------------------------------------------------------------7172describe("api", () => {73 it("delivers one valid Order per api call, in call order, across all verbs", async () => {74 const src = `75 function tick(state, memory, api) {76 api.allocate("farmer", 300);77 api.allocate("miner", 100, 1);78 api.craft("weapons", 250);79 api.build("granary", 1);80 api.build("house", 1, 3);81 api.demolish("tower", 1);82 api.found(4, 5, 120, { grain: 50, tools: 10 });83 api.route(1, 2, "grain", 500);84 api.muster(1, 30, "infantry");85 api.command(7, { type: "raid", target: 3 });86 api.command(7, { type: "march", target: { x: 9, y: 9 } });87 api.command(7, { type: "disband", target: null });88 api.trade({ give: "ale", giveAmount: 10, want: "coin", wantAmount: 5, tribe: null, repeat: true });89 api.trade({ give: "coin", giveAmount: 25, want: "coin", wantAmount: 0, tribe: 2, repeat: false });90 api.cancelTrade(12);91 api.stance(1, "hostile");92 api.research("milling");93 api.decree("rations", 800);94 api.rite("warding", 1);95 api.rite("blessing");96 api.say("hello");97 }98 `;99 await withVm(src, {}, (vm) => {100 const outcome = vm.runTick(makeView());101 expect(outcome.error).toBeNull();102 expect(outcome.overBudget).toBe(false);103 const expected: Order[] = [104 { kind: "allocate", profession: "farmer", permille: 300 },105 { kind: "allocate", profession: "miner", permille: 100, settlementId: 1 },106 { kind: "craft", target: "weapons", permille: 250 },107 { kind: "build", building: "granary", settlementId: 1 },108 { kind: "build", building: "house", settlementId: 1, count: 3 },109 { kind: "demolish", building: "tower", settlementId: 1 },110 { kind: "found", x: 4, y: 5, settlers: 120, supplies: { grain: 50, tools: 10 } },111 { kind: "route", fromId: 1, toId: 2, resource: "grain", permilleOfSurplus: 500 },112 { kind: "muster", settlementId: 1, size: 30, unitKind: "infantry" },113 { kind: "command", warbandId: 7, order: { type: "raid", target: 3 } },114 { kind: "command", warbandId: 7, order: { type: "march", target: { x: 9, y: 9 } } },115 { kind: "command", warbandId: 7, order: { type: "disband", target: null } },116 { kind: "trade_offer", give: "ale", giveAmount: 10, want: "coin", wantAmount: 5, tribe: null, repeat: true },117 { kind: "trade_offer", give: "coin", giveAmount: 25, want: "coin", wantAmount: 0, tribe: 2, repeat: false },118 { kind: "trade_cancel", offerId: 12 },119 { kind: "stance", tribeId: 1, posture: "hostile" },120 { kind: "research", tech: "milling" },121 { kind: "decree", key: "rations", value: 800 },122 { kind: "rite", rite: "warding", target: 1 },123 { kind: "rite", rite: "blessing", target: null },124 { kind: "say", text: "hello" },125 ];126 expect(outcome.orders).toEqual(expected);127 });128 });129130 it("drops malformed orders host-side, keeps the valid ones", async () => {131 const src = `132 function tick(state, memory, api) {133 api.allocate("wizard", 100); // bad profession -> dropped134 api.allocate("farmer", 1001); // out-of-range permille -> DROPPED, not clamped135 api.allocate("farmer", -1); // negative permille -> dropped136 api.allocate("farmer", 500.5); // non-integer permille -> dropped137 api.allocate("farmer", 100, -3); // negative settlement id -> dropped138 api.craft("bread", 100); // not a craft target -> dropped139 api.build("castle", 1); // bad building -> dropped140 api.build("house", -1); // negative settlement id -> dropped141 api.build("house", 1, 0); // count < 1 -> dropped142 api.demolish("hall", 1.5); // non-integer id -> dropped143 api.found(1, 2, 0, {}); // settlers < 1 -> dropped144 api.found(1, 2, 10, { gold: 5 }); // non-Resource supply key -> dropped145 api.found(1, 2, 10, { grain: -5 }); // negative supply amount -> dropped146 api.found(1, 2, 10, { grain: 1.5 }); // fractional supply amount -> dropped147 api.route(1, 2, "mana", 100); // bad resource -> dropped148 api.route(1, 2, "grain", 2000); // out-of-range permille -> dropped149 api.muster(1, 0, "infantry"); // size < 1 -> dropped150 api.muster(1, 10, "wizards"); // bad warband kind -> dropped151 api.command(1, { type: "flee", target: null }); // bad order type -> dropped152 api.command(1, { type: "march", target: { x: 1.5, y: 2 } }); // fractional point -> dropped153 api.command(1, "garrison"); // order not an object -> dropped154 api.trade({ give: "mana", giveAmount: 1, want: "coin", wantAmount: 1, tribe: null, repeat: true }); // bad resource155 api.trade({ give: "ale", giveAmount: 0, want: "coin", wantAmount: 0, tribe: null, repeat: true }); // 0-for-0156 api.cancelTrade("soon"); // NaN id -> dropped157 api.stance(1, "friendly"); // bad posture -> dropped158 api.research("alchemy"); // bad tech -> dropped159 api.decree("curfew", 100); // bad decree key -> dropped160 api.decree("rations", 1200); // out-of-range value -> dropped161 api.rite("sacrifice", null); // bad rite -> dropped162 api.allocate("farmer", 250); // valid -> kept163 }164 `;165 await withVm(src, {}, (vm) => {166 const outcome = vm.runTick(makeView());167 expect(outcome.error).toBeNull();168 expect(outcome.orders).toEqual([{ kind: "allocate", profession: "farmer", permille: 250 }]);169 });170 });171172 it("keeps only the first ACTIONS_PER_TICK valid orders", async () => {173 const src = `174 function tick(state, memory, api) {175 for (var i = 0; i < 100; i++) api.craft("tools", i);176 }177 `;178 await withVm(src, {}, (vm) => {179 const outcome = vm.runTick(makeView());180 expect(outcome.error).toBeNull();181 expect(outcome.orders.length).toBe(LIMITS.ACTIONS_PER_TICK);182 // First 64 in emit order, rest dropped.183 expect(outcome.orders[0]).toEqual({ kind: "craft", target: "tools", permille: 0 });184 expect(outcome.orders[63]).toEqual({ kind: "craft", target: "tools", permille: 63 });185 });186 });187188 it("invalid orders do not consume the per-tick action budget", async () => {189 const src = `190 function tick(state, memory, api) {191 for (var i = 0; i < 70; i++) api.allocate("wizard", 10); // all invalid192 for (var i = 0; i < 10; i++) api.craft("tools", 100 + i); // all valid193 }194 `;195 await withVm(src, {}, (vm) => {196 const outcome = vm.runTick(makeView());197 expect(outcome.error).toBeNull();198 expect(outcome.orders.length).toBe(10);199 expect(outcome.orders.every((o) => o.kind === "craft")).toBe(true);200 });201 });202203 it("caps say at SAYS_PER_TICK and sanitizes the text", async () => {204 const src = `205 function tick(state, memory, api) {206 api.say(" first\\u0000 message\\u202e here ");207 api.say("second message");208 }209 `;210 await withVm(src, {}, (vm) => {211 const outcome = vm.runTick(makeView());212 expect(outcome.error).toBeNull();213 expect(outcome.orders).toEqual([{ kind: "say", text: "first message here" }]);214 });215 });216217 it("captures api.log lines", async () => {218 const src = `219 function tick(state, memory, api) {220 api.log("hello " + state.tick);221 api.log({ a: 1 });222 }223 `;224 await withVm(src, {}, (vm) => {225 const outcome = vm.runTick(makeView({ tick: 7 }));226 expect(outcome.logs).toEqual(['hello 7', '{"a":1}']);227 expect(vm.getLogs()).toEqual(['hello 7', '{"a":1}']);228 });229 });230231 it("reports an error when the script defines no tick function", async () => {232 await withVm("var x = 1;", {}, (vm) => {233 const outcome = vm.runTick(makeView());234 expect(outcome.error).toMatch(/tick/);235 expect(outcome.orders).toEqual([]);236 });237 });238239 it("persists memory between ticks inside the VM", async () => {240 const src = `241 function tick(state, memory, api) {242 memory.count = (memory.count || 0) + 1;243 api.log("count=" + memory.count);244 }245 `;246 await withVm(src, {}, (vm) => {247 expect(vm.runTick(makeView({ tick: 0 })).logs).toEqual(["count=1"]);248 expect(vm.runTick(makeView({ tick: 1 })).logs).toEqual(["count=2"]);249 expect(vm.runTick(makeView({ tick: 2 })).logs).toEqual(["count=3"]);250 });251 });252});253254// ---------------------------------------------------------------------------255// validateOrder unit tests (host boundary, no VM)256// ---------------------------------------------------------------------------257258describe("validateOrder", () => {259 const found = (supplies: unknown) => ({ kind: "found", x: 1, y: 2, settlers: 10, supplies });260261 it("rejects prototype-pollution supply keys (__proto__/constructor)", () => {262 // JSON.parse creates "__proto__" as an OWN property — exactly what a263 // hostile payload would look like after the VM JSON transfer.264 expect(validateOrder(found(JSON.parse('{"__proto__": {"polluted": 1}, "grain": 5}')))).toBeNull();265 expect(validateOrder(found({ constructor: 1 }))).toBeNull();266 expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();267 });268269 it("rebuilds supplies as a fresh object with only Resource keys", () => {270 const supplies = { grain: 5, tools: 2 };271 const order = validateOrder(found(supplies));272 expect(order).toEqual({ kind: "found", x: 1, y: 2, settlers: 10, supplies: { grain: 5, tools: 2 } });273 // Fresh rebuild — never the guest-shaped object itself.274 expect((order as { supplies: unknown }).supplies).not.toBe(supplies);275 });276277 it("rejects arrays, null, and non-integer supply values", () => {278 expect(validateOrder(found([1, 2]))).toBeNull();279 expect(validateOrder(found(null))).toBeNull();280 expect(validateOrder(found({ grain: Number.MAX_SAFE_INTEGER + 2 }))).toBeNull();281 expect(validateOrder(found({ grain: NaN }))).toBeNull();282 });283284 it("rejects unknown kinds and non-objects", () => {285 expect(validateOrder({ kind: "train", unit: "soldier" })).toBeNull(); // v1 verb286 expect(validateOrder("allocate")).toBeNull();287 expect(validateOrder(null)).toBeNull();288 expect(validateOrder({ kind: "allocate", profession: "farmer", permille: 1000 })).toEqual({289 kind: "allocate",290 profession: "farmer",291 permille: 1000,292 });293 });294});295296// ---------------------------------------------------------------------------297// determinism298// ---------------------------------------------------------------------------299300describe("determinism", () => {301 const rngScript = `302 function tick(state, memory, api) {303 // Mix rng into decisions so nondeterminism would show up in orders.304 var n = Math.floor(api.rng() * 1000);305 var m = Math.floor(api.rng() * 1000);306 api.allocate("farmer", n);307 api.route(1, 2, "grain", m);308 api.log("rng:" + n + "," + m);309 memory.last = n;310 }311 `;312313 async function runSequence(source: string, seed: string) {314 const host = await createScriptHost();315 const vm = host.createTribeVm(source, { ...OPTS, rngSeed: seed }) as TribeVmWithLogs;316 const outcomes = [];317 for (let t = 0; t < 5; t++) {318 const view = makeView({319 tick: t,320 events: t === 2 ? [{ t: "sighted", tick: 1, tribe: 0, enemy: 1, x: 3, y: 4 }] : [],321 });322 const o = vm.runTick(view);323 outcomes.push({ orders: o.orders, gasUsed: o.gasUsed, overBudget: o.overBudget, error: o.error, logs: o.logs });324 }325 host.dispose();326 return outcomes;327 }328329 it("same script + views + seed => identical orders and gasUsed across fresh hosts", async () => {330 const a = await runSequence(rngScript, "9999");331 const b = await runSequence(rngScript, "9999");332 expect(b).toEqual(a);333 });334335 it("reference bots are deterministic too", async () => {336 for (const source of BOT_SOURCES) {337 const a = await runSequence(source, "424242");338 const b = await runSequence(source, "424242");339 expect(b).toEqual(a);340 }341 });342343 it("reference bots actually play: first tick emits only valid v2 orders", async () => {344 const v2Kinds = new Set([345 "allocate", "craft", "build", "demolish", "found", "route", "muster",346 "command", "trade_offer", "trade_cancel", "stance", "research",347 "decree", "rite", "advance", "say",348 ]);349 for (const source of BOT_SOURCES) {350 await withVm(source, {}, (vm) => {351 const outcome = vm.runTick(makeView());352 expect(outcome.error).toBeNull();353 expect(outcome.overBudget).toBe(false);354 // Every bot opens with a policy init: allocations at minimum.355 expect(outcome.orders.length).toBeGreaterThan(0);356 expect(outcome.orders.length).toBeLessThanOrEqual(LIMITS.ACTIONS_PER_TICK);357 expect(outcome.orders.some((o) => o.kind === "allocate")).toBe(true);358 for (const order of outcome.orders) {359 expect(v2Kinds.has(order.kind)).toBe(true);360 }361 });362 }363 });364365 it("different seeds give different rng streams", async () => {366 const a = await runSequence(rngScript, "1");367 const b = await runSequence(rngScript, "2");368 expect(b.map((o) => o.logs)).not.toEqual(a.map((o) => o.logs));369 });370});371372// ---------------------------------------------------------------------------373// gas374// ---------------------------------------------------------------------------375376describe("gas", () => {377 it("interrupts while(true), discards partial orders, VM stays callable", async () => {378 const src = `379 function tick(state, memory, api) {380 api.craft("tools", 500); // partial order must be discarded on overrun381 while (true) {}382 }383 `;384 await withVm(src, {}, (vm) => {385 const first = vm.runTick(makeView());386 expect(first.overBudget).toBe(true);387 expect(first.orders).toEqual([]);388 expect(first.error).toBeNull();389 expect(first.gasUsed).toBeGreaterThan(LIMITS.GAS_PER_TICK);390391 // Next tick is still callable and behaves the same, deterministically.392 const second = vm.runTick(makeView({ tick: 1 }));393 expect(second.overBudget).toBe(true);394 expect(second.orders).toEqual([]);395 expect(second.gasUsed).toBe(first.gasUsed);396 });397 });398399 it("interrupts a while(true) at top level during install", async () => {400 await withVm("while (true) {} function tick() {}", {}, (vm) => {401 const outcome = vm.runTick(makeView());402 expect(outcome.overBudget).toBe(true);403 expect(outcome.orders).toEqual([]);404 });405 });406407 it("host survives an over-budget VM alongside a healthy one", async () => {408 const host = await createScriptHost();409 const bad = host.createTribeVm("function tick() { while (true) {} }", OPTS);410 const good = host.createTribeVm(411 'function tick(s, m, api) { api.research("milling"); }',412 OPTS,413 );414 expect(bad.runTick(makeView()).overBudget).toBe(true);415 const outcome = good.runTick(makeView());416 expect(outcome.overBudget).toBe(false);417 expect(outcome.orders).toEqual([{ kind: "research", tech: "milling" }]);418 host.dispose();419 });420});421422// ---------------------------------------------------------------------------423// memory424// ---------------------------------------------------------------------------425426describe("memory", () => {427 it("VM memory limit turns huge allocations into an error; VM survives", async () => {428 const src = `429 function tick(state, memory, api) {430 var s = "x";431 while (true) s += s; // double until OOM432 }433 `;434 await withVm(src, { memoryLimitBytes: 8 * 1024 * 1024, gasPerTick: 1_000_000 }, (vm) => {435 const first = vm.runTick(makeView());436 expect(first.overBudget).toBe(false);437 expect(first.error).not.toBeNull();438 expect(first.orders).toEqual([]);439440 const second = vm.runTick(makeView({ tick: 1 }));441 expect(second.error).not.toBeNull(); // same script OOMs again442 });443 });444445 it("persistent memory JSON cap: over-cap tick errors, memory resets", async () => {446 const src = `447 function tick(state, memory, api) {448 if (state.tick === 0) {449 memory.blob = new Array(70000 + 1).join("x"); // ~70KB string > 64KB cap450 api.craft("tools", 500); // must be dropped on the failing tick451 } else {452 api.log("blob is " + typeof memory.blob);453 }454 }455 `;456 await withVm(src, {}, (vm) => {457 const first = vm.runTick(makeView({ tick: 0 }));458 expect(first.error).toMatch(/memory/i);459 expect(first.orders).toEqual([]);460461 const second = vm.runTick(makeView({ tick: 1 }));462 expect(second.error).toBeNull();463 expect(second.logs).toEqual(["blob is undefined"]); // reset happened464 });465 });466});467468// ---------------------------------------------------------------------------469// sandbox470// ---------------------------------------------------------------------------471472describe("sandbox", () => {473 const probeScript = `474 function tick(state, memory, api) {475 api.log("Date:" + typeof Date);476 api.log("fetch:" + typeof fetch);477 api.log("setTimeout:" + typeof setTimeout);478 api.log("process:" + typeof globalThis.process);479 api.log("require:" + typeof globalThis.require);480 api.log("console:" + typeof console);481 api.log("XMLHttpRequest:" + typeof XMLHttpRequest);482 api.log("eval:" + typeof globalThis.eval);483 try {484 Math.random();485 api.log("random:leaked");486 } catch (e) {487 api.log("random:threw");488 }489 // Global surface snapshot: must be identical across VMs and runs.490 api.log("globals:" + Object.getOwnPropertyNames(globalThis).sort().join(","));491 }492 `;493494 async function probe(): Promise<string[]> {495 return withVm(probeScript, {}, (vm) => {496 const outcome = vm.runTick(makeView());497 expect(outcome.error).toBeNull();498 return outcome.logs;499 });500 }501502 it("exposes no Date, timers, network, or process globals", async () => {503 const logs = await probe();504 expect(logs).toContain("Date:undefined");505 expect(logs).toContain("fetch:undefined");506 expect(logs).toContain("setTimeout:undefined");507 expect(logs).toContain("process:undefined");508 expect(logs).toContain("require:undefined");509 expect(logs).toContain("console:undefined");510 expect(logs).toContain("XMLHttpRequest:undefined");511 expect(logs).toContain("eval:undefined");512 expect(logs).toContain("random:threw");513 });514515 it("has an identical, deterministic global surface across two fresh VMs", async () => {516 const a = await probe();517 const b = await probe();518 expect(b).toEqual(a);519 });520});