openvibers/openvibe.games Commits
Run games-quickjs@1 pack mods in budgeted per-mod QuickJS sandboxes
Changes
@@@ -17,16 +17,16 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints andprops, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under`packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)--- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit) and the-- `games.*` events++- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the++ `games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events- the game WebSocket protocol and the portal, `/play` and `/editor` pages## Does not own- identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the localcopy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events)--- mod grants' authority (Network keeps them, `mods.grant.manage`) and executable mods (they wait for-- OpenVibe.Host Stage C)++- mod grants' authority (Network keeps them, `mods.grant.manage`) and installable executable mods++ (they wait for OpenVibe.Host Stage C; script mods ship in the place's own packs)## Depends on@@@ -191,8 +191,12 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsrequested capabilities; every runtime binding checks it at call time, arevoked install or capability stops affecting the world on the next tick,and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are-- metadata only. Executable mods (scripts) are refused until sandboxed-- execution exists in OpenVibe.Host (Stage C). API: `GET /api/v1/mods`,++ metadata only. Executable mods are refused by the mods API until sandboxed++ execution exists in OpenVibe.Host (Stage C); `games-quickjs@1` script mods++ ship in the place's own packs instead, pinned by the def-set digest, and run++ in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets++ `cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget++ disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`,`GET /api/v1/mods/:id` (public); `POST /api/v1/mods`,`POST /api/v1/mods/:id/enable|disable|revoke|grants`,`DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit`@@@ -22,6 +22,7 @@"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)","map editor with assets mirrored to OpenVibe.Media","declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",++ "server script mods (games-quickjs@1 in packs, pinned by the def-set digest): one QuickJS WASM sandbox per mod, cpuMs/memoryMb/stack budgets, intents only; off unless GAMES_SCRIPT_MODS=1","players keyed by Network subjects","games.* events through a transactional outbox","games.progress.summary user module",@@@ -7,7 +7,7 @@"dev": "tsx watch --env-file-if-exists=.env src/main.ts","start": "tsx src/main.ts","build": "tsc --build",-- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""++ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:quickjs-emscripten --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""},"dependencies": {"@dimforge/rapier3d-deterministic-compat": "0.21.0",@@@ -24,6 +24,7 @@"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0",++ "quickjs-emscripten": "0.32.0","tsx": "^4.19.3","ws": "^8.18.1"},@@@ -59,3 +59,12 @@ describe('loadConfig migrationsDir', () => {expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)})})++++describe('loadConfig scriptMods', () => {++ it('is off unless the place enables it', () => {++ expect(loadConfig({}).scriptMods).toBe(false)++ expect(loadConfig({ GAMES_SCRIPT_MODS: '0' }).scriptMods).toBe(false)++ expect(loadConfig({ GAMES_SCRIPT_MODS: '1' }).scriptMods).toBe(true)++ expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)++ })++})@@@ -63,6 +63,11 @@ export interface ServerConfig {metricsLogSeconds: number/** Multiplier on world-event cadences (tests shrink it). */eventIntervalScale: number++ /**++ * GAMES_SCRIPT_MODS=1: run the def set's games-quickjs@1 script mods on this++ * place. Off by default; the mods stay in the def set (and its digest) either way.++ */++ scriptMods: boolean}export interface PlatformConfig {@@@ -155,6 +160,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,++ scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',}}@@@ -11,12 +11,14 @@* - lifecycle events commit with the state they describe and reach* OpenVibe.Events (mock) with the games service token;* - a restart restores the authoritative state: characters, world props,-- * the world clock and the mod registry — without duplicating mod props.++ * the world clock and the mod registry — without duplicating mod props;++ * - a games-quickjs@1 script mod changes the world only through the intents++ * the gameplay modules apply.*/import { mkdtempSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'--import { createContent } from '@openvibe/content'++import { createContent, type ContentRegistry } from '@openvibe/content'import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'import { openTestDb } from '@openvibe/persistence/testing'import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'@@@ -36,6 +38,8 @@ import { hashGuestKey } from '../net/guestIdentity.js'import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'import { ModRegistry, type ModActor } from '../mods/registry.js'import { ModRuntime } from '../mods/runtime.js'++import { ScriptModHost } from '../mods/script/host.js'++import { createQuickJsSandboxFactory } from '../mods/script/quickjs.js'import { sampleManifest } from '../mods/testFixtures.js'import { ServerMetrics } from '../observability/metrics.js'import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'@@@ -82,8 +86,11 @@ interface Boot {dispose(): void}--async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {-- const content = createContent()++async function boot(++ platform: ReturnType<typeof createMockPlatform>,++ opts: { content?: ContentRegistry; scripts?: ScriptModHost } = {},++): Promise<Boot> {++ const content = opts.content ?? createContent()const physics = createRapierWorld(rapier)// The store rides on the shared database; close() is never called (the instance is shared).const store = openPgStore(db, { placeId: config.placeId })@@@ -112,6 +119,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boconst game = new GameServer(config, world, store, new ServerMetrics(), log, {events: recorder,mods: runtime,++ ...(opts.scripts ? { scripts: opts.scripts } : {}),})await game.load()return {@@@ -126,7 +134,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo}}--function connect(game: GameServer) {++function connect(game: GameServer, contentDigest = createContent().digest) {const inbox: ServerMessage[] = []let closed: { code: number; reason: string } | null = null// The session system now requires an identity stamped on the connection@@@ -145,7 +153,7 @@ function connect(game: GameServer) {const msg = {t: 'hello',v: PROTOCOL_VERSION,-- contentDigest: createContent().digest,++ contentDigest,slot: 0,name: 'Ana',appearance: defaultAppearance(),@@@ -206,8 +214,24 @@ describe('Games on platform identity, events and mods (real server, restart)', (expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })// Reject before loading a character or writing any world state. This also-- // covers an older hello that has no digest at all.-- for (const offered of [undefined, 'v2-00000000']) {++ // covers an older hello that has no digest at all, and a client whose def++ // set carries a games-quickjs@1 mod the server does not (the digest pins++ // mod manifests and sources like every other definition).++ const withMod = createContent([++ {++ mods: [++ {++ format: 'games-quickjs@1',++ id: 'extra',++ version: '1.0.0',++ entry: 'extra.js',++ source: 'function onTick() {}',++ hooks: ['onTick'],++ },++ ],++ },++ ]).digest++ for (const offered of [undefined, 'v2-00000000', withMod]) {const stale = connect(a.game)stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }a.game.onMessage(stale.conn, {@@@ -382,3 +406,53 @@ describe('Games on platform identity, events and mods (real server, restart)', (c.dispose()}, 60_000)})++++describe('games-quickjs@1 script mods on the real server', () => {++ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })++++ it('a mod changes game state only through intents the gameplay modules apply', async () => {++ const content = createContent([++ {++ mods: [++ {++ format: 'games-quickjs@1',++ id: 'welcome-gift',++ version: '1.0.0',++ entry: 'welcome-gift.js',++ hooks: ['onPlayerJoin'],++ source: `function onPlayerJoin(e) {++ e.player.name = 'not a rename';++ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'scrap_metal', count: 3 });++ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'no_such_item', count: 1 });++ game.emit({ kind: 'announce', text: 'welcome ' + e.player.name });++ }`,++ },++ ],++ },++ ])++ const scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), log)++ const b = await boot(platform, { content, scripts })++ await scripts.start()++ const p = connect(b.game, content.digest)++ const { welcome } = await p.hello('guest-script-mod')++ expect(welcome).toBeDefined()++++ const scrap = (m: ServerMessage) =>++ m.t === 'inventory'++ ? m.inv.slots.reduce((n, s) => n + (s.stack.def === 'scrap_metal' ? s.stack.count : 0), 0)++ : 0++ // A fresh character carries no scrap: all of it is the gift, through the game's inventory.++ const inventories = p.inbox.filter((m) => m.t === 'inventory')++ expect(inventories.length).toBeGreaterThan(0)++ expect(scrap(inventories.at(-1)!)).toBe(3)++ expect(p.inbox).toContainEqual({ t: 'announce', text: 'welcome not a rename' })++ // The unknown item was refused by the game, not by trusting the mod.++ expect(scripts.status()[0]).toMatchObject({++ state: 'running',++ intentsApplied: 2,++ intentsRejected: 1,++ })++ scripts.dispose()++ b.dispose()++ })++})@@@ -6,6 +6,7 @@ import type { EntityId, Logger, PlayerId } from '@openvibe/shared'import type { ServerConfig } from '../../config.js'import type { TicketIdentity } from '../../net/wsTicket.js'import type { ModRuntime } from '../../mods/runtime.js'++import type { ScriptModHost } from '../../mods/script/host.js'import type { ServerMetrics } from '../../observability/metrics.js'import type { GameEventRecorder } from '../../platform/gameEvents.js'import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'@@@ -64,6 +65,8 @@ export interface GameIntegrations {events?: GameEventRecorder/** Installed mods (content packs), reconciled every tick. */mods?: ModRuntime++ /** games-quickjs@1 script mods; present only where the place enables them (GAMES_SCRIPT_MODS). */++ scripts?: ScriptModHost/** The person's games.progress.summary user module on Network, written when a character leaves. */progressSummary?: ProgressSummaryWriter}@@@ -1,11 +1,23 @@import { terrainHeight } from '@openvibe/content'import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'import type { ModHost } from '../../mods/runtime.js'++import type { ScriptPlayer, ScriptWorld } from '../../mods/script/host.js'++import type { PlayerSession } from '../playerSession.js'import type { ServerContext } from './context.js'import type { System } from './system.js'++function scriptPlayer(s: PlayerSession): ScriptPlayer {++ return {++ id: s.playerId,++ name: s.name,++ pos: [s.move.pos.x, s.move.pos.y, s.move.pos.z],++ yaw: s.yaw,++ }++}++/**-- * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent.++ * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent,++ * and, where the place enables them, the script mods' tick, join/leave hooks and world.*/export class ModSystem implements System {readonly name = 'mods'@@@ -41,11 +53,64 @@ export class ModSystem implements System {entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,}-- constructor(private readonly ctx: ServerContext) {}++ /**++ * What script mods read and change. Changes arrive as validated intents and++ * go through the same inventory and broadcast paths the game itself uses.++ */++ readonly scriptWorld: ScriptWorld = {++ players: () => [...this.ctx.sessions.values()].map(scriptPlayer),++ player: (id) => {++ const session = this.ctx.sessions.get(id)++ return session ? scriptPlayer(session) : null++ },++ apply: (_modId, intent) => {++ switch (intent.kind) {++ case 'announce':++ this.ctx.net.broadcastAll({ t: 'announce', text: intent.text })++ return null++ case 'giveItem': {++ const session = this.ctx.sessions.get(intent.playerId)++ if (!session) return 'no such player'++ if (!this.ctx.world.content.item(intent.item)) return 'unknown item'++ if (!session.inventory.canFit(intent.item, intent.count)) return 'inventory_full'++ session.inventory.add(intent.item, intent.count)++ session.dirty = true++ this.ctx.net.sendInventory(session)++ return null++ }++ }++ },++ }++++ constructor(private readonly ctx: ServerContext) {++ ctx.integrations.scripts?.bind(this.scriptWorld)++ }-- tick(ctx: ServerContext, _dt: number): void {++ tick(ctx: ServerContext, dt: number): void {// Reconcile installs (a revoked or disabled mod's effects are// retracted on the first tick after the change).ctx.integrations.mods?.tick(ctx.clock.tick, this.host)++ const scripts = ctx.integrations.scripts++ if (scripts) {++ scripts.tick(ctx.clock.tick, dt)++ let running = 0++ let failures = 0++ const status = scripts.status()++ for (const s of status) {++ if (s.state === 'running') running++++ failures += s.failures.cpu + s.failures.memory + s.failures.stack + s.failures.error++ }++ ctx.metrics.scriptModsRunning = running++ ctx.metrics.scriptModsDisabled = status.length - running++ ctx.metrics.scriptModFailures = failures++ }++ }++++ onJoin(session: PlayerSession): void {++ this.ctx.integrations.scripts?.playerJoined(scriptPlayer(session))++ }++++ onLeave(session: PlayerSession): void {++ this.ctx.integrations.scripts?.playerLeft(session.playerId)}}@@@ -34,6 +34,7 @@ import { createNetworkModGrants } from './mods/networkGrants.js'import { createGamesActorLimits } from './net/actorLimits.js'import { handleModsRequest } from './mods/routes.js'import { ModRuntime } from './mods/runtime.js'++import { ScriptModHost } from './mods/script/host.js'import { isGuestToken } from './platform/accounts.js'import { ProgressSummaryWriter } from './platform/progressSummary.js'import {@@@ -201,13 +202,25 @@ async function main(): Promise<void> {const progressSummary = platform? new ProgressSummaryWriter(platform.client, platformLog.child({ system: 'progress-summary' })): null++ // games-quickjs@1 script mods run only where the place enables them; the engine (WASM) is not++ // even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.++ const scriptLog = log.child({ system: 'script-mods' })++ let scripts: ScriptModHost | undefined++ if (config.scriptMods && content.allMods().length > 0) {++ const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')++ scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), scriptLog)++ } else if (content.allMods().length > 0) {++ scriptLog.info('script mods off for this place', { mods: content.allMods().length })++ }const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {...(recorder ? { events: recorder } : {}),mods: modRuntime,++ ...(scripts ? { scripts } : {}),...(progressSummary ? { progressSummary } : {}),})// Async boot: env, markets and the NPC population load before the tick loop starts.await game.load()++ await scripts?.start()const accountData = platform? createAccountData({@@@ -373,6 +386,7 @@ async function main(): Promise<void> {mods: {installed: mods.list().length,active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,++ scripts: { enabled: scripts !== undefined, mods: scripts?.status() ?? [] },},}),)@@@ -514,6 +528,7 @@ async function main(): Promise<void> {// (that checkpoint plus every disconnect save queued before it) drain.game.shutdown()const drained = await game.drain()++ scripts?.dispose()const cut = await httpDoneawait within(2000, mirrorDone)await within(1000, progressSummary?.settle())nnew file mode 100644@@@ -0,0 +1,236 @@++import { describe, expect, it } from 'vitest'++import type { ScriptMod } from '@openvibe/content'++import type { Logger } from '@openvibe/shared'++import {++ ERROR_STRIKES,++ parseIntent,++ ScriptModHost,++ type ScriptIntent,++ type ScriptWorld,++} from './host.js'++import type { Json, ModSandboxFactory, SandboxHostFunctions, SandboxOutcome } from './sandbox.js'++++/** A fake engine: each mod's "script" is a host-side function driving the lent `game` functions. */++type FakeScript = (++ game: SandboxHostFunctions,++ hook: string,++ payload: Json,++ cpuMs: number,++ clock: { t: number },++) => SandboxOutcome++++function fakeFactory(scripts: Record<string, FakeScript>, clock: { t: number }) {++ const disposed: string[] = []++ const factory: ModSandboxFactory = {++ engine: 'fake',++ async create(spec) {++ const script = scripts[spec.id]++ if (!script) throw new Error(`no script ${spec.id}`)++ return {++ ok: true,++ sandbox: {++ call: (hook, payload, cpuMs) => script(spec.host, hook, payload, cpuMs, clock),++ dispose: () => void disposed.push(spec.id),++ },++ }++ },++ }++ return { factory, disposed }++}++++function mod(id: string, budgets?: ScriptMod['budgets']): ScriptMod {++ return {++ format: 'games-quickjs@1',++ id,++ version: '1.0.0',++ entry: `${id}.js`,++ source: '/* fake */',++ hooks: ['onTick', 'onPlayerJoin'],++ ...(budgets ? { budgets } : {}),++ }++}++++function recordingWorld(): ScriptWorld & { applied: [string, ScriptIntent][] } {++ const applied: [string, ScriptIntent][] = []++ return {++ applied,++ players: () => [{ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 }],++ player: (id) => (id === 'p1' ? { id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 } : null),++ apply: (modId, intent) => {++ applied.push([modId, intent])++ return null++ },++ }++}++++const logs: [string, string, unknown][] = []++const log: Logger = {++ debug: (m, f) => void logs.push(['debug', m, f]),++ info: (m, f) => void logs.push(['info', m, f]),++ warn: (m, f) => void logs.push(['warn', m, f]),++ error: (m, f) => void logs.push(['error', m, f]),++ child: () => log,++}++++async function boot(scripts: Record<string, FakeScript>, mods: ScriptMod[]) {++ const clock = { t: 0 }++ const { factory, disposed } = fakeFactory(scripts, clock)++ const host = new ScriptModHost(mods, factory, log, () => clock.t)++ const world = recordingWorld()++ host.bind(world)++ await host.start()++ return { host, world, clock, disposed }++}++++describe('ScriptModHost', () => {++ it('applies intents only after the call completes; a failed call changes nothing', async () => {++ const { host, world } = await boot(++ {++ good: (game, hook) => {++ if (hook === 'onTick') game.emit!({ kind: 'announce', text: 'tick' })++ return { ok: true }++ },++ half: (game) => {++ game.emit!({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1 })++ return { ok: false, reason: 'error', message: 'boom after emitting' }++ },++ },++ [mod('good'), mod('half')],++ )++ host.tick(1, 1 / 30)++ expect(world.applied).toEqual([['good', { kind: 'announce', text: 'tick' }]])++ expect(host.status().find((s) => s.id === 'half')).toMatchObject({++ state: 'running',++ failures: { error: 1 },++ intentsApplied: 0,++ })++ })++++ it('disables a mod over its cpuMs for this instance and keeps ticking the others', async () => {++ const seen: number[] = []++ const { host, world, disposed } = await boot(++ {++ hog: (_game, _hook, _payload, cpuMs, clock) => {++ clock.t += cpuMs++ return { ok: false, reason: 'cpu', message: 'interrupted' }++ },++ fine: (game, _hook, _payload, cpuMs) => {++ seen.push(cpuMs)++ game.emit!({ kind: 'announce', text: 'still here' })++ return { ok: true }++ },++ },++ [mod('hog', { cpuMs: 3 }), mod('fine')],++ )++ host.tick(1, 1 / 30)++ host.tick(2, 1 / 30)++ const hog = host.status().find((s) => s.id === 'hog')!++ expect(hog).toMatchObject({ state: 'disabled', reason: 'cpu', calls: 1, failures: { cpu: 1 } })++ expect(disposed).toEqual(['hog'])++ expect(seen).toEqual([2, 2]) // the default budget, offered whole each tick++ expect(world.applied).toHaveLength(2)++ expect(logs.some(([l, m]) => l === 'warn' && m === 'script mod disabled')).toBe(true)++ })++++ it('shares cpuMs across every call in a tick and disables a mod that spent it', async () => {++ const offered: number[] = []++ const { host } = await boot(++ {++ busy: (_g, _h, _p, cpuMs, clock) => {++ offered.push(cpuMs)++ clock.t += 1.5++ return { ok: true }++ },++ },++ [mod('busy', { cpuMs: 3 })],++ )++ host.tick(1, 1 / 30)++ host.playerJoined({ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 })++ expect(offered).toEqual([3, 1.5])++ host.playerJoined({ id: 'p2', name: 'Bo', pos: [0, 1, 0], yaw: 0 })++ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'cpu' })++ })++++ it('a throwing mod never throws into the tick, and is disabled after ERROR_STRIKES in a row', async () => {++ const { host } = await boot(++ {++ thrower: () => {++ throw new Error('engine bug')++ },++ },++ [mod('thrower')],++ )++ for (let i = 1; i < ERROR_STRIKES; i++) expect(() => host.tick(i, 1 / 30)).not.toThrow()++ expect(host.status()[0]).toMatchObject({++ state: 'running',++ failures: { error: ERROR_STRIKES - 1 },++ })++ host.tick(ERROR_STRIKES, 1 / 30)++ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'error' })++ })++++ it('a mod whose sandbox cannot be created is disabled and the rest load', async () => {++ const { host } = await boot({ ok: () => ({ ok: true }) }, [mod('missing'), mod('ok')])++ expect(host.status().map((s) => [s.id, s.state])).toEqual([++ ['missing', 'disabled'],++ ['ok', 'running'],++ ])++ })++++ it('refuses bad intents inside the call and caps intents per call', async () => {++ const errors: string[] = []++ const { host, world } = await boot(++ {++ spam: (game) => {++ for (const bad of [{ kind: 'teleport' }, { kind: 'announce', text: '' }, 'x']) {++ try {++ game.emit!(bad)++ } catch (err) {++ errors.push((err as Error).message)++ }++ }++ try {++ for (let i = 0; i < 100; i++) game.emit!({ kind: 'announce', text: `n${i}` })++ } catch (err) {++ errors.push((err as Error).message)++ }++ return { ok: true }++ },++ },++ [mod('spam')],++ )++ host.tick(1, 1 / 30)++ expect(errors).toHaveLength(4)++ expect(world.applied).toHaveLength(16)++ })++++ it('game.emit outside a hook throws', async () => {++ let captured: SandboxHostFunctions | null = null++ const { host } = await boot(++ {++ keep: (game) => {++ captured = game++ return { ok: true }++ },++ },++ [mod('keep')],++ )++ host.tick(1, 1 / 30)++ expect(() => captured!.emit!({ kind: 'announce', text: 'late' })).toThrow(/inside a hook/)++ })++})++++describe('parseIntent', () => {++ it('rebuilds the intent from known fields only', () => {++ const raw = JSON.parse(++ '{"kind":"giveItem","playerId":"p1","item":"scrap","count":2,"admin":true,"__proto__":{"polluted":1}}',++ ) as Json++ const intent = parseIntent(raw)++ expect(intent).toEqual({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 })++ expect(Object.getPrototypeOf(intent)).toBe(Object.prototype)++ expect(({} as Record<string, unknown>).polluted).toBeUndefined()++ expect(parseIntent({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1000 })).toMatch(++ /count/,++ )++ })++})nnew file mode 100644@@@ -0,0 +1,351 @@++/**++ * The script mod host: runs the `games-quickjs@1` mods of the def set, one++ * sandbox each, from the tick and the join/leave hooks.++ *++ * A mod reads the world and asks for changes; it never makes them. What it++ * can reach is the frozen global `game` (see docs/mods.md):++ *++ * game.players() every player in the world: { id, name, pos, yaw }++ * game.player(id) one player, or null++ * game.emit(intent) queue an intent (validated on the spot, throws if bad)++ * game.log(...values) a line in the server log (a few per tick)++ *++ * Intents queued during a call are applied after the call returns, through++ * `ScriptWorld.apply` (the gameplay modules), and only if the call completed:++ * a call stopped for its budget or by a throw changes nothing.++ *++ * Budgets: `cpuMs` is per tick (every call the mod gets in one tick shares it)++ * and caps each call; `memoryMb` and `stackKb` bound the mod's engine. A++ * budget breach stops the call, disables the mod for this instance (its++ * sandbox is freed), logs it and counts it; so does an engine failure, and++ * ERROR_STRIKES throws in a row. The tick goes on either way; nothing a mod does reaches the++ * caller as an exception.++ */++import { performance } from 'node:perf_hooks'++import { SCRIPT_MOD_BUDGETS, type ScriptMod, type ScriptModHook } from '@openvibe/content'++import type { Logger } from '@openvibe/shared'++import type {++ Json,++ ModSandbox,++ ModSandboxFactory,++ SandboxFailureReason,++ SandboxHostFunctions,++} from './sandbox.js'++++/** The load (top-level code) may use this many ticks' worth of cpuMs. */++export const LOAD_CPU_FACTOR = 25++/** Throws in a row that disable a mod. */++export const ERROR_STRIKES = 5++export const MAX_INTENTS_PER_CALL = 16++export const MAX_LOGS_PER_TICK = 4++++export interface ScriptPlayer {++ id: string++ name: string++ pos: [number, number, number]++ yaw: number++}++++/** Everything a mod may ask for. Each is applied by the gameplay modules, never by the mod. */++export type ScriptIntent =++ | { kind: 'announce'; text: string }++ | { kind: 'giveItem'; playerId: string; item: string; count: number }++++/** What the game lends the host: reads, and one door for changes. */++export interface ScriptWorld {++ players(): ScriptPlayer[]++ player(id: string): ScriptPlayer | null++ /** Applies a validated intent; returns why it was refused, or null. */++ apply(modId: string, intent: ScriptIntent): string | null++}++++export interface ScriptModBudget {++ cpuMs: number++ memoryMb: number++ stackKb: number++}++++export type ScriptModState = 'running' | 'disabled'++++export interface ScriptModStatus {++ id: string++ version: string++ hooks: ScriptModHook[]++ budget: ScriptModBudget++ state: ScriptModState++ /** Why it was disabled (a budget, or `error` after ERROR_STRIKES throws). */++ reason: SandboxFailureReason | null++ calls: number++ failures: Record<SandboxFailureReason, number>++ intentsApplied: number++ intentsRejected: number++}++++/** The manifest's requested budgets, with defaults for what it left out. */++export function resolveBudget(mod: ScriptMod): ScriptModBudget {++ return {++ cpuMs: mod.budgets?.cpuMs ?? SCRIPT_MOD_BUDGETS.cpuMs.default,++ memoryMb: mod.budgets?.memoryMb ?? SCRIPT_MOD_BUDGETS.memoryMb.default,++ stackKb: mod.budgets?.stackKb ?? SCRIPT_MOD_BUDGETS.stackKb.default,++ }++}++++const isObject = (v: Json): v is { [key: string]: Json } =>++ typeof v === 'object' && v !== null && !Array.isArray(v)++const shortString = (v: Json | undefined, max: number): v is string =>++ typeof v === 'string' && v.length > 0 && v.length <= max++++/**++ * An intent from script JSON, rebuilt field by field: nothing the script put++ * on the object besides the known fields (a `__proto__` key included) survives.++ */++export function parseIntent(raw: Json): ScriptIntent | string {++ if (!isObject(raw)) return 'an intent must be an object'++ switch (raw.kind) {++ case 'announce': {++ const text = typeof raw.text === 'string' ? raw.text.trim() : ''++ if (!shortString(text, 200)) return 'announce: text must be 1-200 characters'++ return { kind: 'announce', text }++ }++ case 'giveItem': {++ const { playerId, item, count } = raw++ if (!shortString(playerId, 64)) return 'giveItem: playerId must be a string'++ if (!shortString(item, 64)) return 'giveItem: item must be a string'++ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > 100)++ return 'giveItem: count must be an integer 1-100'++ return { kind: 'giveItem', playerId, item, count }++ }++ default:++ return `unknown intent kind ${JSON.stringify(raw.kind ?? null)}`++ }++}++++interface ModSlot {++ mod: ScriptMod++ budget: ScriptModBudget++ hooks: Set<ScriptModHook>++ sandbox: ModSandbox | null++ state: ScriptModState++ reason: SandboxFailureReason | null++ /** CPU spent since the current tick began. */++ spentMs: number++ logsThisTick: number++ strikes: number++ calls: number++ failures: Record<SandboxFailureReason, number>++ intentsApplied: number++ intentsRejected: number++}++++export class ScriptModHost {++ private readonly slots: ModSlot[]++ private world: ScriptWorld | null = null++ /** The call in progress: whose intents `game.emit` queues, and where. */++ private current: { slot: ModSlot; intents: ScriptIntent[] } | null = null++++ constructor(++ mods: readonly ScriptMod[],++ private readonly factory: ModSandboxFactory,++ private readonly log: Logger,++ private readonly now: () => number = () => performance.now(),++ ) {++ this.slots = mods.map((mod) => ({++ mod,++ budget: resolveBudget(mod),++ hooks: new Set(mod.hooks),++ sandbox: null,++ state: 'running',++ reason: null,++ spentMs: 0,++ logsThisTick: 0,++ strikes: 0,++ calls: 0,++ failures: { cpu: 0, memory: 0, stack: 0, error: 0 },++ intentsApplied: 0,++ intentsRejected: 0,++ }))++ }++++ /** The world the mods read and change; the game server binds it once. */++ bind(world: ScriptWorld): void {++ this.world = world++ }++++ /** Creates every sandbox and evaluates each mod once. A mod that fails to load is disabled. */++ async start(): Promise<void> {++ for (const slot of this.slots) {++ const { mod, budget } = slot++ let created: Awaited<ReturnType<ModSandboxFactory['create']>>++ try {++ created = await this.factory.create({++ id: mod.id,++ filename: mod.entry,++ source: mod.source,++ limits: { memoryBytes: budget.memoryMb * 1024 * 1024, stackBytes: budget.stackKb * 1024 },++ loadCpuMs: budget.cpuMs * LOAD_CPU_FACTOR,++ host: this.hostFunctions(slot),++ })++ } catch (err) {++ created = {++ ok: false,++ reason: 'error',++ message: err instanceof Error ? err.message : String(err),++ }++ }++ if (!created.ok) {++ slot.failures[created.reason]++++ this.disable(slot, created.reason, `load: ${created.message}`)++ continue++ }++ slot.sandbox = created.sandbox++ this.log.info('script mod loaded', {++ mod: mod.id,++ version: mod.version,++ engine: this.factory.engine,++ hooks: mod.hooks.join(','),++ cpuMs: budget.cpuMs,++ memoryMb: budget.memoryMb,++ stackKb: budget.stackKb,++ })++ }++ }++++ tick(tick: number, dtSeconds: number): void {++ for (const slot of this.slots) {++ slot.spentMs = 0++ slot.logsThisTick = 0++ }++ for (const slot of this.slots) this.run(slot, 'onTick', { tick, dt: dtSeconds })++ }++++ playerJoined(player: ScriptPlayer): void {++ for (const slot of this.slots) this.run(slot, 'onPlayerJoin', { player: { ...player } })++ }++++ playerLeft(playerId: string): void {++ for (const slot of this.slots) this.run(slot, 'onPlayerLeave', { playerId })++ }++++ status(): ScriptModStatus[] {++ return this.slots.map((s) => ({++ id: s.mod.id,++ version: s.mod.version,++ hooks: [...s.mod.hooks],++ budget: { ...s.budget },++ state: s.state,++ reason: s.reason,++ calls: s.calls,++ failures: { ...s.failures },++ intentsApplied: s.intentsApplied,++ intentsRejected: s.intentsRejected,++ }))++ }++++ /** Frees every sandbox (shutdown). */++ dispose(): void {++ for (const slot of this.slots) {++ slot.sandbox?.dispose()++ slot.sandbox = null++ }++ }++++ private run(slot: ModSlot, hook: ScriptModHook, payload: Json): void {++ const sandbox = slot.sandbox++ if (slot.state !== 'running' || !sandbox || !slot.hooks.has(hook)) return++ const remaining = slot.budget.cpuMs - slot.spentMs++ if (remaining <= 0) {++ slot.failures.cpu++++ this.disable(slot, 'cpu', `${hook}: this tick's ${slot.budget.cpuMs} cpuMs are spent`)++ return++ }++ const intents: ScriptIntent[] = []++ this.current = { slot, intents }++ const started = this.now()++ let outcome: ReturnType<ModSandbox['call']>++ try {++ outcome = sandbox.call(hook, payload, remaining)++ } catch (err) {++ outcome = {++ ok: false,++ reason: 'error',++ message: err instanceof Error ? err.message : String(err),++ }++ } finally {++ this.current = null++ }++ slot.spentMs += this.now() - started++ slot.calls++++ if (!outcome.ok) {++ slot.failures[outcome.reason]++++ if (outcome.reason !== 'error' || outcome.fatal) {++ this.disable(slot, outcome.reason, `${hook}: ${outcome.message}`)++ return++ }++ slot.strikes++++ if (slot.strikes >= ERROR_STRIKES) {++ this.disable(++ slot,++ 'error',++ `${hook}: ${ERROR_STRIKES} throws in a row, last: ${outcome.message}`,++ )++ } else if (slot.strikes === 1) {++ this.log.warn('script mod threw', { mod: slot.mod.id, hook, error: outcome.message })++ }++ return++ }++ slot.strikes = 0++ for (const intent of intents) {++ const refused = this.world ? this.world.apply(slot.mod.id, intent) : 'no world bound'++ if (refused === null) slot.intentsApplied++++ else {++ slot.intentsRejected++++ this.log.debug('script mod intent refused', {++ mod: slot.mod.id,++ kind: intent.kind,++ refused,++ })++ }++ }++ }++++ private disable(slot: ModSlot, reason: SandboxFailureReason, message: string): void {++ slot.state = 'disabled'++ slot.reason = reason++ slot.sandbox?.dispose()++ slot.sandbox = null++ this.log.warn('script mod disabled', {++ mod: slot.mod.id,++ reason,++ message: message.slice(0, 500),++ })++ }++++ /** The functions behind `game`; each knows its mod, so no mod can act as another. */++ private hostFunctions(slot: ModSlot): SandboxHostFunctions {++ return {++ players: () => (this.world?.players() ?? []) as unknown as Json,++ player: (id) => {++ if (typeof id !== 'string') throw new Error('game.player(id): id must be a string')++ return (this.world?.player(id) ?? null) as unknown as Json++ },++ emit: (raw) => {++ const call = this.current++ if (!call || call.slot !== slot)++ throw new Error('game.emit is only available inside a hook')++ if (call.intents.length >= MAX_INTENTS_PER_CALL)++ throw new Error(`at most ${MAX_INTENTS_PER_CALL} intents per call`)++ const intent = parseIntent(raw ?? null)++ if (typeof intent === 'string') throw new Error(intent)++ call.intents.push(intent)++ return undefined++ },++ log: (...values) => {++ if (slot.logsThisTick >= MAX_LOGS_PER_TICK) return undefined++ slot.logsThisTick++++ const text = values.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')++ this.log.info('script mod log', { mod: slot.mod.id, text: text.slice(0, 500) })++ return undefined++ },++ }++ }++}nnew file mode 100644@@@ -0,0 +1,297 @@++/**++ * The QuickJS sandbox against real scripts: escapes, budgets, and the host++ * driving it end to end. Timings use generous slack (software CI) but stay far++ * below what an unbounded script would take.++ */++import { performance } from 'node:perf_hooks'++import { describe, expect, it } from 'vitest'++import type { ScriptMod } from '@openvibe/content'++import type { Logger } from '@openvibe/shared'++import { ScriptModHost, type ScriptIntent, type ScriptPlayer, type ScriptWorld } from './host.js'++import { createQuickJsSandboxFactory } from './quickjs.js'++import type { Json, ModSandbox, SandboxHostFunctions, SandboxSpec } from './sandbox.js'++++const factory = createQuickJsSandboxFactory()++const MB = 1024 * 1024++++async function sandbox(++ source: string,++ host: SandboxHostFunctions = {},++ limits: Partial<SandboxSpec['limits']> = {},++): Promise<ModSandbox> {++ const made = await factory.create({++ id: 'test',++ filename: 'test.js',++ source,++ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024, ...limits },++ loadCpuMs: 200,++ host,++ })++ if (!made.ok) throw new Error(`load failed: ${made.reason} ${made.message}`)++ return made.sandbox++}++++/** A sandbox whose `report(v)` collects values, for scripts that answer questions. */++async function asking(source: string): Promise<{ box: ModSandbox; answers: Json[] }> {++ const answers: Json[] = []++ const box = await sandbox(source, { report: (v) => void answers.push(v ?? null) })++ return { box, answers }++}++++const quiet: Logger = {++ debug: () => {},++ info: () => {},++ warn: () => {},++ error: () => {},++ child: () => quiet,++}++++describe('QuickJS sandbox: escapes', () => {++ it('exposes no require, process, timers, module system or host objects', async () => {++ const { box, answers } = await asking(`++ function onTick() {++ const names = ['require', 'process', 'module', 'exports', '__dirname', 'global', 'Buffer',++ 'setTimeout', 'setInterval', 'setImmediate', 'fetch', 'XMLHttpRequest', 'WebSocket',++ 'std', 'os', 'scriptArgs', 'print', 'Deno', 'Bun', 'WebAssembly'];++ game.report(names.filter((n) => typeof globalThis[n] !== 'undefined'));++ // The Function constructor reaches this context's global, never the host's.++ game.report(Function('return typeof process')());++ game.report(game.report.constructor('return typeof require')());++ // Everything on the global that is not a language intrinsic.++ game.report(Object.getOwnPropertyNames(globalThis).filter((n) => /^[a-z_$]/.test(n) && ![++ 'globalThis', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'decodeURI', 'decodeURIComponent',++ 'encodeURI', 'encodeURIComponent', 'escape', 'unescape', 'eval', 'undefined', 'onTick',++ 'game'].includes(n)));++ // The lent object is frozen and cannot be replaced.++ try { game.report = null } catch (e) {}++ try { globalThis.game = {} } catch (e) {}++ game.report([Object.isFrozen(game), typeof game.report]);++ }`)++ expect(box.call('onTick', null, 100)).toEqual({ ok: true })++ expect(answers).toEqual([[], 'undefined', 'undefined', [], [true, 'function']])++ box.dispose()++ })++++ it('cannot pollute host prototypes or reach host objects through the bridge', async () => {++ const seen: Json[] = []++ const box = await sandbox(++ `function onTick() {++ Object.prototype.polluted = 'yes';++ Array.prototype.push = function () { return 'hijacked' };++ JSON.parse = function () { return { admin: true } };++ const got = game.echo({ __proto__: { admin: true }, constructor: { prototype: { admin: true } } });++ game.echo(JSON.stringify(Object.keys(got)));++ }`,++ { echo: (v) => (seen.push(v ?? null), v ?? null) },++ )++ expect(box.call('onTick', null, 100)).toEqual({ ok: true })++ const plain: Record<string, unknown> = {}++ expect(plain.polluted).toBeUndefined()++ expect(plain.admin).toBeUndefined()++ const host: number[] = []++ expect(host.push(1)).toBe(1)++ expect(JSON.parse('{}')).toEqual({})++ // Only data arrived, as fresh host objects with the host's own prototype.++ expect(Object.getPrototypeOf(seen[0])).toBe(Object.prototype)++ expect((seen[0] as Record<string, unknown>).admin).toBeUndefined()++ box.dispose()++ })++++ it("cannot read or change another mod's state", async () => {++ const a = await asking(`++ globalThis.secret = 'a-only';++ Object.prototype.leak = 'from a';++ function onTick() { game.report([typeof secret, ({}).leak ?? null]) }`)++ const b = await asking(`++ function onTick() {++ game.report([typeof secret, ({}).leak ?? null, typeof globalThis.onTick]);++ }`)++ expect(b.box.call('onTick', null, 100)).toEqual({ ok: true })++ expect(a.box.call('onTick', null, 100)).toEqual({ ok: true })++ expect(b.answers).toEqual([['undefined', null, 'function']])++ expect(a.answers).toEqual([['string', 'from a']])++ a.box.dispose()++ b.box.dispose()++ })++})++++describe('QuickJS sandbox: budgets', () => {++ it('stops an infinite loop within its cpuMs', async () => {++ const box = await sandbox('function onTick() { for (;;) {} }')++ const started = performance.now()++ const outcome = box.call('onTick', null, 5)++ const took = performance.now() - started++ expect(outcome).toMatchObject({ ok: false, reason: 'cpu' })++ expect(took).toBeLessThan(5 + 100)++ box.dispose()++ })++++ it('stops a promise-job loop, even one that swallows the interrupt, within the same cpuMs', async () => {++ for (const source of [++ 'function onTick() { const spin = () => Promise.resolve().then(spin); spin() }',++ 'function onTick() { const spin = () => Promise.resolve().then(spin, spin); spin() }',++ 'async function onTick() { for (;;) {} }',++ ]) {++ const box = await sandbox(source)++ const started = performance.now()++ expect(box.call('onTick', null, 5)).toMatchObject({ ok: false, reason: 'cpu' })++ expect(performance.now() - started).toBeLessThan(5 + 100)++ box.dispose()++ }++ })++++ it('stops an allocation bomb at memoryMb, even one that catches the error', async () => {++ for (const source of [++ 'function onTick() { const keep = []; for (;;) keep.push(new Array(1 << 16).fill(1.5)) }',++ 'function onTick() { const keep = []; for (;;) keep.push({ n: keep.length }) }',++ `function onTick() {++ const keep = [];++ try { for (;;) keep.push(new Float64Array(1 << 16)) } catch (e) { keep.length = 0 }++ }`,++ ]) {++ const box = await sandbox(source, {}, { memoryBytes: 4 * MB })++ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'memory' })++ box.dispose()++ }++ })++++ it('refuses a single allocation larger than memoryMb', async () => {++ const box = await sandbox(++ 'function onTick() { new Float64Array(1 << 20) }',++ {},++ {++ memoryBytes: 4 * MB,++ },++ )++ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, reason: 'memory' })++ box.dispose()++ })++++ it("survives an engine path that exhausts the host's own stack, as a fatal stack breach", async () => {++ const other = await asking('function onTick() { game.report("still here") }')++ const box = await sandbox(++ 'function onTick() { let a = []; for (let i = 0; i < 1e5; i++) a = [a]; JSON.stringify(a) }',++ {},++ { memoryBytes: 32 * MB },++ )++ expect(box.call('onTick', null, 2000)).toMatchObject({++ ok: false,++ reason: 'stack',++ fatal: true,++ })++ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, fatal: true })++ expect(() => box.dispose()).not.toThrow()++ expect(other.box.call('onTick', null, 100)).toEqual({ ok: true })++ expect(other.answers).toEqual(['still here'])++ other.box.dispose()++ })++++ it('stops runaway recursion at the stack limit', async () => {++ const box = await sandbox('function onTick() { const f = (n) => f(n + 1) + 1; f(0) }')++ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'stack' })++ box.dispose()++ })++++ it('reports a top-level infinite loop as a load failure', async () => {++ const made = await factory.create({++ id: 'boot-loop',++ filename: 'boot-loop.js',++ source: 'while (true) {}',++ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024 },++ loadCpuMs: 10,++ host: {},++ })++ expect(made).toMatchObject({ ok: false, reason: 'cpu' })++ })++})++++describe('ScriptModHost on QuickJS', () => {++ const base = { format: 'games-quickjs@1', version: '1.0.0', hooks: ['onTick'] } as const++ const scriptMod = (id: string, source: string, extra: Partial<ScriptMod> = {}): ScriptMod => ({++ ...base,++ hooks: [...base.hooks],++ id,++ entry: `${id}.js`,++ source,++ ...extra,++ })++++ function world(): ScriptWorld & { applied: ScriptIntent[]; ana: ScriptPlayer } {++ const ana: ScriptPlayer = { id: 'p1', name: 'Ana', pos: [1, 2, 3], yaw: 0 }++ const applied: ScriptIntent[] = []++ return {++ ana,++ applied,++ players: () => [ana],++ player: (id) => (id === ana.id ? ana : null),++ apply: (_mod, intent) => {++ applied.push(intent)++ if (intent.kind === 'giveItem') ana.name = `${ana.name}+${intent.count}${intent.item}`++ return null++ },++ }++ }++++ it('a valid mod changes game state through intents only', async () => {++ const w = world()++ const host = new ScriptModHost(++ [++ scriptMod(++ 'gifts',++ `function onTick(e) {++ const [p] = game.players();++ p.name = 'renamed in the sandbox'; // a copy: changes nothing outside++ p.pos[0] = 999;++ game.emit({ kind: 'giveItem', playerId: p.id, item: 'scrap', count: 2 });++ game.log('gave scrap on tick', e.tick);++ }`,++ ),++ ],++ factory,++ quiet,++ )++ host.bind(w)++ await host.start()++ host.tick(7, 1 / 30)++ expect(w.applied).toEqual([{ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 }])++ expect(w.ana).toEqual({ id: 'p1', name: 'Ana+2scrap', pos: [1, 2, 3], yaw: 0 })++ expect(host.status()[0]).toMatchObject({ state: 'running', intentsApplied: 1 })++ host.dispose()++ })++++ it('a throwing or runaway mod does not stop the tick or the other mods', async () => {++ const w = world()++ const host = new ScriptModHost(++ [++ scriptMod('thrower', 'function onTick() { throw new TypeError("nope") }'),++ scriptMod('looper', 'function onTick() { for (;;) {} }', { budgets: { cpuMs: 3 } }),++ scriptMod(++ 'bomb',++ 'function onTick() { const k = []; for (;;) k.push(new Array(65536).fill(0)) }',++ {++ budgets: { memoryMb: 4, cpuMs: 10 },++ },++ ),++ scriptMod('steady', 'function onTick() { game.emit({ kind: "announce", text: "ok" }) }'),++ ],++ factory,++ quiet,++ )++ host.bind(w)++ await host.start()++ expect(() => {++ host.tick(1, 1 / 30)++ host.tick(2, 1 / 30)++ }).not.toThrow()++ const byId = Object.fromEntries(host.status().map((s) => [s.id, s]))++ expect(byId.thrower).toMatchObject({ state: 'running', failures: { error: 2 } })++ expect(byId.looper).toMatchObject({ state: 'disabled', reason: 'cpu' })++ expect(byId.bomb).toMatchObject({ state: 'disabled', reason: 'memory' })++ expect(byId.steady).toMatchObject({ state: 'running', intentsApplied: 2 })++ expect(w.applied).toEqual([++ { kind: 'announce', text: 'ok' },++ { kind: 'announce', text: 'ok' },++ ])++ host.dispose()++ })++})nnew file mode 100644@@@ -0,0 +1,327 @@++/**++ * The QuickJS implementation of the sandbox adapter (sandbox.ts), on++ * quickjs-emscripten: QuickJS compiled to WASM, no native build.++ *++ * Every mod gets its own WASM module instance and linear memory, runtime and++ * context, so a mod cannot see another's heap even through an engine bug, and++ * an engine abort poisons only that mod's instance. A fresh context holds the++ * language intrinsics only — QuickJS's std/os libraries are not part of the++ * build — and the host adds exactly one thing, the frozen global `game`.++ *++ * Budgets:++ * - cpuMs: the interrupt handler stops a call at its deadline (QuickJS polls++ * it every few thousand operations). An interrupt a promise job or async++ * hook swallows into a rejection is still a breach: the flag is ours.++ * - memory: QuickJS's own malloc limit cannot count bytes under emscripten++ * (no malloc_usable_size: it sees single allocations and a per-block++ * overhead, not the total), so the bound is the WASM memory itself, capped++ * at the engine's base heap plus `memoryBytes` (HeapCeiling). The malloc++ * limit stays as a second line against any single allocation that large.++ * - stack: the runtime's max stack size turns deep recursion into a catchable++ * stack overflow. A few engine paths recurse in C without that check (a++ * deeply nested JSON.stringify) and exhaust the host's own stack instead;++ * that RangeError is caught here, reported as a stack breach, and the++ * instance is treated as dead.++ */++import { performance } from 'node:perf_hooks'++import {++ newQuickJSWASMModule,++ newVariant,++ RELEASE_SYNC,++ type QuickJSContext,++ type QuickJSHandle,++ type QuickJSRuntime,++} from 'quickjs-emscripten'++import type {++ Json,++ ModSandbox,++ ModSandboxFactory,++ SandboxFailure,++ SandboxFailureReason,++ SandboxHostFunctions,++ SandboxOutcome,++ SandboxSpec,++} from './sandbox.js'++++/** Must match the exact version pinned in apps/server/package.json. */++export const QUICKJS_ENGINE = '[email protected]'++++/** The linear memory this build declares it starts with; QuickJS's own state lives in it. */++export const ENGINE_BASE_BYTES = 16 * 1024 * 1024++const WASM_PAGE = 64 * 1024++++/** Longest JSON a host call may pass either way; past it the call throws inside the script. */++const MAX_HOST_JSON = 64 * 1024++/** Longest failure message kept from a script (it chooses what it throws). */++const MAX_MESSAGE = 500++++/**++ * Runs before the mod, with the native bridge as its only argument; the bridge++ * never becomes a global. It builds the frozen `game` object (one wrapper per++ * lent function, JSON in and out) and returns the hook dispatcher the host++ * keeps. Nothing it does at call time looks up a global or a prototype method++ * the mod could have replaced (`JSON` is captured, arguments are copied by index).++ */++const PRELUDE = `(function (bridge, namesJson) {++ 'use strict';++ const parse = JSON.parse, stringify = JSON.stringify, freeze = Object.freeze;++ const game = {};++ const names = parse(namesJson);++ for (let i = 0; i < names.length; i++) {++ const name = names[i];++ game[name] = function () {++ const args = [];++ for (let k = 0; k < arguments.length; k++) args[k] = arguments[k];++ const out = bridge(name, stringify(args));++ return out === undefined ? undefined : parse(out);++ };++ freeze(game[name]);++ }++ freeze(game);++ Object.defineProperty(globalThis, 'game', { value: game, writable: false, enumerable: false, configurable: false });++ return function dispatch(hook, payload) {++ const fn = globalThis[hook];++ if (typeof fn === 'function') fn(parse(payload));++ };++})`++++/** The part of WebAssembly.Memory used here; the server's TS lib (ES2022, no DOM) does not declare it. */++interface WasmMemory {++ readonly buffer: ArrayBuffer++ grow(deltaPages: number): number++}++const { Memory } = (++ globalThis as unknown as {++ WebAssembly: { Memory: new (d: { initial: number; maximum: number }) => WasmMemory }++ }++).WebAssembly++++/**++ * A mod's linear memory, capped at ENGINE_BASE_BYTES + `memoryBytes`. Past the++ * cap the engine's allocator is refused and the allocation fails inside the++ * script ("out of memory"). `exceeded` stays true from the moment an++ * allocation needed more than the cap — even if the script caught the error —++ * because the memory never shrinks and can no longer grow.++ */++class HeapCeiling {++ readonly memory: WasmMemory++ private refusedAt = -1++++ constructor(memoryBytes: number) {++ const base = ENGINE_BASE_BYTES / WASM_PAGE++ const memory = new Memory({++ initial: base,++ maximum: base + Math.ceil(memoryBytes / WASM_PAGE),++ })++ // The allocator asks for a generous step first and smaller ones after a++ // refusal; only a refusal not followed by a successful grow is a breach.++ const grow = memory.grow.bind(memory)++ memory.grow = (delta) => {++ try {++ return grow(delta)++ } catch (err) {++ this.refusedAt = memory.buffer.byteLength++ throw err++ }++ }++ this.memory = memory++ }++++ get exceeded(): boolean {++ return this.refusedAt === this.memory.buffer.byteLength++ }++}++++function failure(reason: SandboxFailureReason, message: string, fatal = false): SandboxFailure {++ return { ok: false, reason, message: message.slice(0, MAX_MESSAGE), ...(fatal && { fatal }) }++}++++/** A script exception that is not a breach of ours: the engine's own limits, or a throw. */++function classify(message: string): SandboxFailure {++ if (/out of memory/i.test(message)) return failure('memory', message)++ if (/stack overflow/i.test(message)) return failure('stack', message)++ return failure('error', message)++}++++/** A thrown script value as one line of text (an Error's name, message and first stack frame). */++function describe(ctx: QuickJSContext, handle: QuickJSHandle): string {++ try {++ const v: unknown = ctx.dump(handle)++ if (v && typeof v === 'object' && 'message' in v) {++ const e = v as { name?: unknown; message?: unknown; stack?: unknown }++ const at = typeof e.stack === 'string' ? e.stack.trim().split('\n')[0] : ''++ return `${String(e.name ?? 'Error')}: ${String(e.message)}${at ? ` (${at.trim()})` : ''}`++ }++ return String(v)++ } catch {++ return 'an exception the engine could not describe'++ } finally {++ handle.dispose()++ }++}++++class QuickJsSandbox implements ModSandbox {++ private deadline = Infinity++ private cpuMs = 0++ /** Set by us, never by the script: the interrupt handler stops the call while it is set. */++ private breach: 'cpu' | 'memory' | null = null++ /** The engine threw into the host (an abort, the host's stack): nothing in it can be trusted. */++ private poisoned = false++ private disposed = false++ private dispatch: QuickJSHandle | null = null++++ constructor(++ private readonly runtime: QuickJSRuntime,++ private readonly ctx: QuickJSContext,++ private readonly heap: HeapCeiling,++ ) {++ runtime.setInterruptHandler(() => {++ if (this.breach === null) {++ if (heap.exceeded) this.breach = 'memory'++ else if (performance.now() > this.deadline) this.breach = 'cpu'++ }++ return this.breach !== null++ })++ }++++ /** Installs `game`, then evaluates the mod once. */++ load(spec: SandboxSpec): SandboxOutcome {++ const ctx = this.ctx++ return this.run(spec.loadCpuMs, () => {++ const bridge = ctx.newFunction('bridge', (nameH, argsH) =>++ bridgeCall(ctx, spec.host, nameH, argsH),++ )++ const names = ctx.newString(JSON.stringify(Object.keys(spec.host)))++ const prelude = ctx.evalCode(PRELUDE, 'host-prelude.js')++ if (prelude.error) {++ bridge.dispose()++ names.dispose()++ return prelude.error++ }++ const made = ctx.callFunction(prelude.value, ctx.undefined, bridge, names)++ prelude.value.dispose()++ bridge.dispose()++ names.dispose()++ if (made.error) return made.error++ this.dispatch = made.value++ const evaluated = ctx.evalCode(spec.source, spec.filename)++ if (evaluated.error) return evaluated.error++ evaluated.value.dispose()++ return null++ })++ }++++ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome {++ if (this.disposed || this.poisoned || !this.dispatch)++ return failure('error', 'the sandbox is no longer usable', true)++ const ctx = this.ctx++ const dispatch = this.dispatch++ return this.run(cpuMs, () => {++ const hookH = ctx.newString(hook)++ const payloadH = ctx.newString(JSON.stringify(payload))++ const r = ctx.callFunction(dispatch, ctx.undefined, hookH, payloadH)++ hookH.dispose()++ payloadH.dispose()++ if (r.error) return r.error++ r.value.dispose()++ return null++ })++ }++++ /**++ * Runs `body` (which returns the script's exception handle, or null) and the++ * promise jobs it queued, one at a time, all before one deadline.++ */++ private run(cpuMs: number, body: () => QuickJSHandle | null): SandboxOutcome {++ this.breach = null++ this.cpuMs = cpuMs++ this.deadline = performance.now() + cpuMs++ try {++ let thrown = body()++ while (!thrown && this.breach === null && this.runtime.hasPendingJob()) {++ if (performance.now() > this.deadline) this.breach = 'cpu'++ else {++ const jobs = this.runtime.executePendingJobs(1)++ if (jobs.error) thrown = jobs.error++ }++ }++ if (this.breach === null && this.heap.exceeded) this.breach = 'memory'++ const message = thrown ? describe(this.ctx, thrown) : ''++ if (this.breach === 'cpu') return failure('cpu', `interrupted: over its ${this.cpuMs} cpuMs`)++ if (this.breach === 'memory')++ return failure('memory', `heap over its memory budget${message ? `: ${message}` : ''}`)++ return thrown ? classify(message) : { ok: true }++ } catch (err) {++ this.poisoned = true++ const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)++ if (err instanceof RangeError && /call stack/i.test(message))++ return failure('stack', `the host's stack ran out: ${message}`, true)++ return failure(this.breach ?? 'error', `the engine failed: ${message}`, true)++ } finally {++ this.deadline = Infinity++ }++ }++++ dispose(): void {++ if (this.disposed) return++ this.disposed = true++ // A poisoned engine is only dropped: its teardown would trip the engine's++ // own consistency checks. The WASM instance is this mod's alone.++ if (this.poisoned) return++ try {++ this.dispatch?.dispose()++ this.ctx.dispose()++ this.runtime.dispose()++ } catch {++ // Same: a runtime stopped mid-allocation can fail its teardown checks.++ }++ }++}++++/** The native side of `game.<name>(...)`: JSON in, JSON out, errors as script exceptions. */++function bridgeCall(++ ctx: QuickJSContext,++ host: SandboxHostFunctions,++ nameH: QuickJSHandle | undefined,++ argsH: QuickJSHandle | undefined,++): QuickJSHandle | { error: QuickJSHandle } {++ try {++ if (!nameH || !argsH) throw new Error('bad bridge call')++ const name = ctx.getString(nameH)++ const raw = ctx.getString(argsH)++ if (raw.length > MAX_HOST_JSON) throw new Error(`arguments to game.${name} are too large`)++ const fn = Object.prototype.hasOwnProperty.call(host, name) ? host[name] : undefined++ if (!fn) throw new Error(`game.${name} is not a function`)++ const args = JSON.parse(raw) as Json[]++ const out = fn(...args)++ if (out === undefined) return ctx.undefined++ const json = JSON.stringify(out)++ if (json.length > MAX_HOST_JSON) throw new Error(`game.${name} returned too much`)++ return ctx.newString(json)++ } catch (err) {++ // Only the message crosses: never the host Error object or its stack.++ return { error: ctx.newError(err instanceof Error ? err.message : String(err)) }++ }++}++++export function createQuickJsSandboxFactory(): ModSandboxFactory {++ return {++ engine: QUICKJS_ENGINE,++ async create(spec) {++ const heap = new HeapCeiling(spec.limits.memoryBytes)++ const module = await newQuickJSWASMModule(++ newVariant(RELEASE_SYNC, { wasmMemory: heap.memory }),++ )++ const runtime = module.newRuntime()++ runtime.setMemoryLimit(spec.limits.memoryBytes)++ runtime.setMaxStackSize(spec.limits.stackBytes)++ const sandbox = new QuickJsSandbox(runtime, runtime.newContext(), heap)++ const loaded = sandbox.load(spec)++ if (!loaded.ok) {++ sandbox.dispose()++ return loaded++ }++ return { ok: true, sandbox }++ },++ }++}nnew file mode 100644@@@ -0,0 +1,73 @@++/**++ * The script sandbox adapter: the whole contract between the mod host and a++ * script engine. QuickJS (quickjs.ts) is the first implementation; OpenVibe.Run++ * can implement the same interface later (ADR-036) and the host does not change.++ *++ * The contract, which every implementation must keep:++ * - one isolated context per mod: no state, prototype or object identity is++ * shared between mods or with the host;++ * - only JSON crosses the boundary, in both directions: a host function gets++ * JSON arguments and returns JSON, a hook gets a JSON payload. No host++ * object, function or prototype is ever reachable from the script;++ * - nothing but the lent functions: no require/import, fs, net, process,++ * timers or clock beyond what the engine's language intrinsics provide;++ * - the host functions appear in the script as the frozen global `game`;++ * - limits are enforced by the engine, not by trusting the script: `cpuMs`++ * per call (wall clock on the single-threaded tick), a heap of++ * `memoryBytes` (beyond the engine's own fixed base), a stack of++ * `stackBytes`; a breach the script catches or swallows is still a breach;++ * - `call` and `dispose` never throw: every failure is a `SandboxFailure`.++ */++++export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }++++/** Functions lent to a script, named as they appear on `game`. A throw becomes an Error inside the script. */++export type SandboxHostFunctions = Readonly<Record<string, (...args: Json[]) => Json | undefined>>++++export interface SandboxLimits {++ memoryBytes: number++ stackBytes: number++}++++/** Why a call (or the load) did not complete. Every reason but `error` is a budget breach. */++export type SandboxFailureReason = 'cpu' | 'memory' | 'stack' | 'error'++++export interface SandboxFailure {++ ok: false++ reason: SandboxFailureReason++ message: string++ /** The sandbox cannot be used again (the engine itself failed); the host must dispose it. */++ fatal?: boolean++}++++export type SandboxOutcome = { ok: true } | SandboxFailure++++export interface ModSandbox {++ /**++ * Calls the script's global function `hook` with `payload`, letting it run++ * for at most `cpuMs`; pending promise jobs it queued run inside the same++ * budget. A missing hook is a success.++ */++ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome++ /** Frees the context and everything in it. Idempotent. */++ dispose(): void++}++++export interface SandboxSpec {++ /** The mod id, for diagnostics only. */++ id: string++ /** File name shown in the script's stack traces. */++ filename: string++ source: string++ limits: SandboxLimits++ /** CPU budget for evaluating the source once (top-level code). */++ loadCpuMs: number++ host: SandboxHostFunctions++}++++export interface ModSandboxFactory {++ /** Engine name and exact version, for logs and /api/status. */++ readonly engine: string++ /** A fresh, isolated context with `host` installed and `source` evaluated once. */++ create(spec: SandboxSpec): Promise<{ ok: true; sandbox: ModSandbox } | SandboxFailure>++}@@@ -43,6 +43,10 @@ export class ServerMetrics {mapZones = 0/** Cumulative map-layer body rebuilds; identical saves must not move it. */mapRebuilds = 0++ /** games-quickjs@1 script mods: running, disabled for this instance, and failed calls (all reasons). */++ scriptModsRunning = 0++ scriptModsDisabled = 0++ scriptModFailures = 0private emaAlpha = 0.05@@@ -78,6 +82,9 @@ export class ServerMetrics {mapTerrains: this.mapTerrains,mapZones: this.mapZones,mapRebuilds: this.mapRebuilds,++ scriptModsRunning: this.scriptModsRunning,++ scriptModsDisabled: this.scriptModsDisabled,++ scriptModFailures: this.scriptModFailures,memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),}}@@@ -121,6 +121,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.- **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.++ - **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** `games-quickjs@1` manifests ride in packs and in the def-set digest; `apps/server/src/mods/script` runs each mod in its own `quickjs-emscripten` WASM instance behind the small adapter in `sandbox.ts` (the one OpenVibe.Run implements later); the host API is `game.players/player/emit/log`, changes arrive only as validated intents applied after a completed call; `cpuMs`/`memoryMb`/`stackKb` are enforced by the interrupt handler, a per-mod capped `WebAssembly.Memory` (QuickJS's malloc limit cannot count bytes under emscripten) and the max stack, and a breach disables the mod for the instance. Off unless the place sets `GAMES_SCRIPT_MODS=1`. Open: capability API v2, seeded randomness, hot reload, installing script mods through the mods API.- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.nnew file mode 100644@@@ -0,0 +1,153 @@++# Mods++++Games has two mod kinds. Both travel in a content pack (`ContentPackV2`,++`packages/content/src/pack.ts`) and both are part of the def set, so the++`hello`/`welcome` digest covers them.++++- **`games-content@2`**: declarative data (definitions, a map, announcements,++ props). Installable through the mods API with per-capability grants; see the++ README's Mods section.++- **`games-quickjs@1`**: server script mods, described here (ADR-0007 decision 6,++ tier T1).++++## `games-quickjs@1`++++A pack carries script mods in `mods: [...]`. Each entry is a manifest and its++source together (`packages/content/src/schema/scriptMod.ts`, strict, so an++unknown field is refused):++++| field | rule |++| --------- | ------------------------------------------------------------------------ |++| `format` | `"games-quickjs@1"` |++| `id` | `^[a-z0-9][a-z0-9_-]{0,39}$`, unique across all packs (never overridden) |++| `version` | `major.minor.patch` |++| `entry` | the script's file name (`*.js`), shown in stack traces |++| `source` | the script, at most 64 Ki characters |++| `hooks` | one or more of `onTick`, `onPlayerJoin`, `onPlayerLeave` |++| `budgets` | optional `{ cpuMs, memoryMb, stackKb }`, each up to its ceiling |++++A pack may carry up to 8 mods. v1 mods are a single script: there is no module++loader, so `import` and `require` do not exist.++++**Pinned by the digest.** Manifests and sources are merged into `ContentDefs.mods`++and hashed by `contentDigest` with every other definition. A client whose copy of++a mod differs by one byte (or that lacks it) is refused with `content_mismatch`++before any player state is created. A def set without mods keeps the digest it++had before mods existed.++++**Where mods come from.** Packs passed to `createContent` (the built-in packs in++`packages/content/src/packs`). The mods API still refuses executable installs: a++script mod ships with the code of the place, reviewed like code.++++**Off unless the place enables it.** `GAMES_SCRIPT_MODS=1` (or `true`) runs the++def set's script mods on this instance. Without it the engine is never loaded++and the server logs `script mods off for this place`; the mods stay in the def++set either way, so the digest does not depend on the flag.++++### Writing a mod++++The script runs once at load (top-level code), then the host calls the global++functions named by `hooks`:++++```js++// greeter.js — hooks: ["onPlayerJoin"]++function onPlayerJoin(e) {++ game.emit({ kind: 'announce', text: `Welcome, ${e.player.name}` })++}++```++++| hook | payload |++| --------------- | ------------------------------------ |++| `onTick` | `{ tick, dt }` (dt in seconds) |++| `onPlayerJoin` | `{ player: { id, name, pos, yaw } }` |++| `onPlayerLeave` | `{ playerId }` |++++A declared hook that the script does not define is skipped.++++### Host API++++The only thing the host adds to a fresh QuickJS context is the frozen global++`game`. Everything else is the language's own intrinsics: no `require`,++`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.++++| call | does |++| --------------------- | ---------------------------------------------------------------- |++| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |++| `game.player(id)` | one player, or `null` |++| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |++| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |++++Intents (at most 16 per call):++++| intent | applied by |++| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |++| `{ kind: 'announce', text }` (1–200 characters) | the same broadcast as server announcements |++| `{ kind: 'giveItem', playerId, item, count }` (1–100) | the player's inventory (`canFit`, `add`, saved, sent to the client); refused if the player is gone, the item unknown or the inventory full |++++Intents are applied after the call returns, and only if it completed: a call++stopped by a budget or ended by a throw changes nothing. An intent is rebuilt++from its known fields, so nothing else the script put on it (a `__proto__`++key included) reaches the host. Only JSON crosses the boundary, in both++directions.++++### Budgets++++| budget | default | ceiling | enforced by |++| ---------- | ------- | ------- | -------------------------------------------------------------- |++| `cpuMs` | 2 | 10 | the QuickJS interrupt handler (wall clock on the tick thread) |++| `memoryMb` | 8 | 32 | the mod's WASM memory, capped at the 16 MiB engine base + this |++| `stackKb` | 128 | 256 | the runtime's max stack size |++++`cpuMs` is per tick: every call the mod gets in one tick (its `onTick` and any++join/leave hooks) shares it, and it caps each call. Promise jobs a call queued++run inside the same deadline, one job at a time. The top-level code gets++25 × `cpuMs` once.++++`memoryMb` is the heap a mod may hold beyond the engine's own fixed base.++QuickJS's malloc limit cannot count bytes under emscripten (it has no++`malloc_usable_size`), so the bound is the linear memory itself: each mod's++`WebAssembly.Memory` has a maximum, a refused grow fails the allocation inside++the script, and that refusal is a breach even if the script catches the++`out of memory`. The malloc limit stays as a second line against one huge++allocation. `stackKb` stops at 256 because past it the host's own (native)++stack runs out first; a path that recurses in C without QuickJS's check (a++deeply nested `JSON.stringify`) that exhausts the host's stack is caught,++reported as a `stack` breach and the instance dropped.++++An interrupt or out-of-memory that the script catches, or that an async hook++turns into a rejection, still counts: the breach flag is the host's, not the++script's.++++A mod that goes over a budget is stopped mid-call and **disabled for this++instance**: its sandbox is freed, `script mod disabled` is logged with the++reason (`cpu`, `memory`, `stack`), and it is counted. Five throws in a row++disable it the same way (`error`); a single throw is logged and counted. The++tick goes on either way. A disabled mod comes back only with a restart.++++Counters: `scriptModsRunning`, `scriptModsDisabled`, `scriptModFailures` in the++metrics log line; per-mod state, budgets, calls, failures by reason and intents++applied/refused under `mods.scripts` in `GET /api/v1/platform`.++++### Isolation++++Each mod gets its own WASM module instance, runtime and context++(`apps/server/src/mods/script/quickjs.ts`), so no heap, global or prototype is++shared between mods or with the host. Escape tests++(`apps/server/src/mods/script/quickjs.test.ts`) check that no host object or++Node global is reachable, that prototype pollution inside a sandbox never++reaches host objects, and that one mod cannot see another's state.++++### The adapter++++`apps/server/src/mods/script/sandbox.ts` is the whole contract between the host++and an engine — `ModSandboxFactory.create(spec)` returning a `ModSandbox` with++`call(hook, payload, cpuMs)` and `dispose()` — so OpenVibe.Run can implement the++same interface later (ADR-036) without the host changing. QuickJS++(`quickjs-emscripten`, exact version pinned in `apps/server/package.json`) is the++first implementation.++++### Not yet++++Capability API v2 (per-mod grants for script bindings, key/value storage),++seeded randomness, hot reload, multi-file mods, and installing script mods++through the mods API.@@@ -7,6 +7,7 @@ export * from './schema/recipe.js'export * from './schema/skill.js'export * from './schema/resourceNode.js'export * from './schema/world.js'++export * from './schema/scriptMod.js'export * from './surface.js'export * from './mapFileV2.js'export * from './mapDiff.js'@@@ -210,3 +210,55 @@ describe('Scraplandia default pack', () => {expect(createContent().world.resourceNodes.map((n) => [n.node, n.pos])).toEqual([])})})++++describe('games-quickjs@1 mods in a pack', () => {++ const mod = {++ format: 'games-quickjs@1',++ id: 'greeter',++ version: '1.0.0',++ entry: 'greeter.js',++ source:++ 'function onPlayerJoin(e) { game.emit({ kind: "announce", text: "hi " + e.player.name }) }',++ hooks: ['onPlayerJoin'],++ budgets: { cpuMs: 1 },++ }++++ it('validates the manifest and carries the mod into the registry', () => {++ const content = createContent([{ mods: [mod] }])++ expect(content.allMods()).toEqual([mod])++ expect(createContent().allMods()).toEqual([])++ const bad = mergePackDefs(base, [++ {++ mods: [++ { ...mod, hooks: ['onEverything'] },++ { ...mod, id: 'b', budgets: { cpuMs: 50 } },++ ],++ },++ {++ mods: [++ { ...mod, extra: true },++ { ...mod, format: 'games-quickjs@2' },++ ],++ },++ ])++ expect(bad.errors).toHaveLength(4)++ expect(bad.defs.mods).toBeUndefined()++ })++++ it('refuses a mod id defined twice, even with allowOverride', () => {++ const r = mergePackDefs(base, [{ mods: [mod] }, { mods: [mod] }], { allowOverride: true })++ expect(r.errors).toEqual(["pack 1: mod 'greeter' already exists"])++ })++++ it('pins the source and manifest in the def-set digest', () => {++ const plain = createContent().digest++ const withMod = createContent([{ mods: [mod] }]).digest++ const edited = createContent([{ mods: [{ ...mod, source: mod.source + ' ' }] }]).digest++ const rebudgeted = createContent([{ mods: [{ ...mod, budgets: { cpuMs: 2 } }] }]).digest++ expect(new Set([plain, withMod, edited, rebudgeted]).size).toBe(4)++ // A def set without mods keeps the digest it had before mods existed.++ expect(contentDigest(base)).toBe(++ contentDigest({ ...base, mods: undefined } as unknown as ContentDefs),++ )++ })++})@@@ -22,6 +22,7 @@ import { ItemDefSchema, type ItemDef } from './schema/item.js'import { RecipeSchema, type Recipe } from './schema/recipe.js'import type { ContentDefs } from './registry.js'import { WorldDefSchema, type WorldDef } from './schema/world.js'++import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'/** The pack format version both sides must agree on. */export const CONTENT_PACK_VERSION = 2@@@ -29,6 +30,9 @@ export const CONTENT_PACK_VERSION = 2/** Most defs of each kind one pack may carry (the manifest schema uses the same bounds). */export const MAX_PACK_DEFS = { items: 200, recipes: 200, crops: 100, npcs: 100 } as const++/** Most `games-quickjs@1` script mods one pack may carry. */++export const MAX_PACK_MODS = 8++/** The def sections a pack may carry. */export type PackDefKind = keyof typeof MAX_PACK_DEFS@@@ -38,10 +42,13 @@ export interface ContentPackV2 {map?: WorldDefannouncements?: { text: string; everySeconds: number }[]props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]++ /** `games-quickjs@1` server script mods (schema/scriptMod.ts); they are part of the def set. */++ mods?: unknown[]}/** What a merge reads from the base: the four def lists a pack extends and the factions npcs may join. */--export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'>++export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'> &++ Pick<Partial<ContentDefs>, 'mods'>export interface MergeOptions {/** Let a pack def replace a base or earlier-pack def with the same id (default: refused). */@@@ -111,6 +118,7 @@ export function mergePackDefs<B extends PackBase>(const recipes = [...base.recipes]const crops = [...base.crops]const npcs = [...base.npcs]++ const mods: ScriptMod[] = [...(base.mods ?? [])]const errors: string[] = []let map = 'world' in base ? (base as B & { world: WorldDef }).world : undefinedlet mapSeen = false@@@ -136,6 +144,18 @@ export function mergePackDefs<B extends PackBase>(const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)const v = parseDefs<NpcArchetype>('npcs', NpcArchetypeSchema, p.defs?.npcs)mine.push(...i.errors, ...r.errors, ...c.errors, ...v.errors)++ // A mod id is its identity in logs and budgets: never overridden, whatever `allowOverride` says.++ if (p.mods !== undefined && (!Array.isArray(p.mods) || p.mods.length > MAX_PACK_MODS)) {++ mine.push(`mods: must be an array of at most ${MAX_PACK_MODS}`)++ } else {++ ;(p.mods ?? []).forEach((raw, k) => {++ const m = ScriptModSchema.safeParse(raw)++ if (!m.success) mine.push(`mods[${k}]: ${m.error.message}`)++ else if (mods.some((d) => d.id === m.data.id))++ mine.push(`mod '${m.data.id}' already exists`)++ else mods.push(m.data)++ })++ }const ownItems = addDefs('item', items, i.out, allowOverride, mine)const ownRecipes = addDefs('recipe', recipes, r.out, allowOverride, mine)@@@ -168,7 +188,19 @@ export function mergePackDefs<B extends PackBase>(}errors.push(...mine.map((e) => where + e))})-- return { defs: { ...base, items, recipes, crops, npcs, ...(map ? { world: map } : {}) }, errors }++ return {++ defs: {++ ...base,++ items,++ recipes,++ crops,++ npcs,++ ...(map ? { world: map } : {}),++ // Absent rather than empty, so a def set without mods keeps the digest it always had.++ ...(mods.length > 0 ? { mods } : {}),++ },++ errors,++ }}/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */@@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'import { SkillDefSchema, type SkillDef } from './schema/skill.js'import { WorldDefSchema, type WorldDef } from './schema/world.js'++import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'import { contentDigest } from './pack.js'export interface ContentDefs {@@@ -25,6 +26,8 @@ export interface ContentDefs {markets: MarketDef[]jobs: JobDef[]world: WorldDef++ /** `games-quickjs@1` server script mods; absent when no pack carries one. */++ mods?: ScriptMod[]}/**@@@ -45,6 +48,7 @@ export class ContentRegistry {private readonly factions = new Map<string, FactionDef>()private readonly markets = new Map<string, MarketDef>()private readonly jobs = new Map<string, JobDef>()++ private readonly mods = new Map<string, ScriptMod>()readonly world: WorldDefconstructor(defs: ContentDefs) {@@@ -260,6 +264,13 @@ export class ContentRegistry {}}++ for (const raw of defs.mods ?? []) {++ const parsed = ScriptModSchema.safeParse(raw)++ if (!parsed.success) errors.push(`mod '${raw.id}': ${parsed.error.message}`)++ else if (this.mods.has(parsed.data.id)) errors.push(`duplicate mod id '${parsed.data.id}'`)++ else this.mods.set(parsed.data.id, parsed.data)++ }++if (errors.length > 0) {throw new Error(`Content validation failed:\n - ${errors.join('\n - ')}`)}@@@ -339,6 +350,11 @@ export class ContentRegistry {return [...this.factions.values()]}++ /** The `games-quickjs@1` script mods in the def set (run only where the place enables them). */++ allMods(): readonly ScriptMod[] {++ return [...this.mods.values()]++ }++market(id: string): MarketDef | undefined {return this.markets.get(id)}nnew file mode 100644@@@ -0,0 +1,61 @@++import { z } from 'zod'++++/**++ * `games-quickjs@1`: a server script mod carried by a content pack. The++ * manifest and the source travel together, so the def-set digest (pack.ts)++ * pins both: a client whose copy of a mod differs by one byte is refused at++ * the handshake like any other content difference.++ *++ * The server runs it in a QuickJS sandbox (apps/server/src/mods/script) only++ * when the place enables script mods; see docs/mods.md for the host API.++ */++export const SCRIPT_MOD_FORMAT = 'games-quickjs@1'++++/** The hooks a mod may declare; the host calls only declared hooks. */++export const SCRIPT_MOD_HOOKS = ['onTick', 'onPlayerJoin', 'onPlayerLeave'] as const++export type ScriptModHook = (typeof SCRIPT_MOD_HOOKS)[number]++++/**++ * Budget defaults and ceilings, per mod. `cpuMs` is per tick (every hook call++ * the mod gets in one tick shares it) and caps any single call; `memoryMb` is++ * the heap the mod may hold beyond the engine's fixed 16 MiB base; `stackKb`++ * its maximum stack.++ */++export const SCRIPT_MOD_BUDGETS = {++ cpuMs: { default: 2, max: 10 },++ memoryMb: { default: 8, max: 32 },++ // Past ~256 KiB of QuickJS stack the host's own (native) stack runs out first.++ stackKb: { default: 128, max: 256 },++} as const++++/** Longest source a mod may carry, in UTF-16 code units. */++export const MAX_SCRIPT_SOURCE = 64 * 1024++++const slug = /^[a-z0-9][a-z0-9_-]{0,39}$/++++export const ScriptModSchema = z++ .object({++ format: z.literal(SCRIPT_MOD_FORMAT),++ id: z.string().regex(slug),++ version: z.string().regex(/^\d{1,4}\.\d{1,4}\.\d{1,4}$/),++ /** The script's file name, used in stack traces; v1 mods are one script (no module loader). */++ entry: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}\.js$/),++ source: z.string().min(1).max(MAX_SCRIPT_SOURCE),++ hooks: z++ .array(z.enum(SCRIPT_MOD_HOOKS))++ .min(1)++ .max(SCRIPT_MOD_HOOKS.length)++ .refine((h) => new Set(h).size === h.length, 'hooks must be unique'),++ /** Requested budgets; anything omitted takes the default. */++ budgets: z++ .object({++ cpuMs: z.number().positive().max(SCRIPT_MOD_BUDGETS.cpuMs.max).optional(),++ memoryMb: z.number().int().min(1).max(SCRIPT_MOD_BUDGETS.memoryMb.max).optional(),++ stackKb: z.number().int().min(64).max(SCRIPT_MOD_BUDGETS.stackKb.max).optional(),++ })++ .strict()++ .optional(),++ })++ .strict()++++export type ScriptMod = z.infer<typeof ScriptModSchema>@@@ -123,6 +123,9 @@ importers:pg:specifier: ^8.23.0version: 8.23.0++ quickjs-emscripten:++ specifier: 0.32.0++ version: 0.32.0tsx:specifier: ^4.19.3version: 4.23.11@@@ -659,6 +662,21 @@ packages:'@iovalkey/[email protected]':resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==}++ '@jitl/[email protected]':++ resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==}++++ '@jitl/[email protected]':++ resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==}++++ '@jitl/[email protected]':++ resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==}++++ '@jitl/[email protected]':++ resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==}++++ '@jitl/[email protected]':++ resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==}++'@jridgewell/[email protected]':resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}@@@ -1456,6 +1474,13 @@ packages:resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}engines: {node: '>=6'}++ resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==}++++ resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==}++ engines: {node: '>=16.0.0'}++resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}engines: {node: '>=4'}@@@ -2072,6 +2097,24 @@ snapshots:'@iovalkey/[email protected]': {}++ '@jitl/[email protected]': {}++++ '@jitl/[email protected]':++ dependencies:++ '@jitl/quickjs-ffi-types': 0.32.0++++ '@jitl/[email protected]':++ dependencies:++ '@jitl/quickjs-ffi-types': 0.32.0++++ '@jitl/[email protected]':++ dependencies:++ '@jitl/quickjs-ffi-types': 0.32.0++++ '@jitl/[email protected]':++ dependencies:++ '@jitl/quickjs-ffi-types': 0.32.0++'@jridgewell/[email protected]': {}'@napi-rs/[email protected]':@@@ -2879,6 +2922,18 @@ snapshots:++ dependencies:++ '@jitl/quickjs-ffi-types': 0.32.0++++ dependencies:++ '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0++ '@jitl/quickjs-wasmfile-debug-sync': 0.32.0++ '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0++ '@jitl/quickjs-wasmfile-release-sync': 0.32.0++ quickjs-emscripten-core: 0.32.0++nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/mods/script/host.test.ts'++// covers: 'apps/server/src/mods/script'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/mods/script/quickjs.test.ts'++// covers: 'apps/server/src/mods/script'++// Generated by node test/run.js --index; do not edit.
@@ -17,16 +17,16 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
@@ -17,16 +17,16 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under
props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under
`packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)
`packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)
-- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit) and the
- `games.*` events
+- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the
+ `games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
## Does not own
## Does not own
- identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local
- identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local
copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events)
copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events)
-- mod grants' authority (Network keeps them, `mods.grant.manage`) and executable mods (they wait for
- OpenVibe.Host Stage C)
+- mod grants' authority (Network keeps them, `mods.grant.manage`) and installable executable mods
+ (they wait for OpenVibe.Host Stage C; script mods ship in the place's own packs)
## Depends on
## Depends on
@@ -191,8 +191,12 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -191,8 +191,12 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
requested capabilities; every runtime binding checks it at call time, a
requested capabilities; every runtime binding checks it at call time, a
revoked install or capability stops affecting the world on the next tick,
revoked install or capability stops affecting the world on the next tick,
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
- metadata only. Executable mods (scripts) are refused until sandboxed
- execution exists in OpenVibe.Host (Stage C). API: `GET /api/v1/mods`,
+ metadata only. Executable mods are refused by the mods API until sandboxed
+ execution exists in OpenVibe.Host (Stage C); `games-quickjs@1` script mods
+ ship in the place's own packs instead, pinned by the def-set digest, and run
+ in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets
+ `cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget
+ disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`,
`GET /api/v1/mods/:id` (public); `POST /api/v1/mods`,
`GET /api/v1/mods/:id` (public); `POST /api/v1/mods`,
`POST /api/v1/mods/:id/enable|disable|revoke|grants`,
`POST /api/v1/mods/:id/enable|disable|revoke|grants`,
`DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit`
`DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit`
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
+ "server script mods (games-quickjs@1 in packs, pinned by the def-set digest): one QuickJS WASM sandbox per mod, cpuMs/memoryMb/stack budgets, intents only; off unless GAMES_SCRIPT_MODS=1",
"players keyed by Network subjects",
"players keyed by Network subjects",
"games.* events through a transactional outbox",
"games.* events through a transactional outbox",
"games.progress.summary user module",
"games.progress.summary user module",
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"start": "tsx src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc --build",
"build": "tsc --build",
- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
+ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:quickjs-emscripten --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
},
},
"dependencies": {
"dependencies": {
"@dimforge/rapier3d-deterministic-compat": "0.21.0",
"@dimforge/rapier3d-deterministic-compat": "0.21.0",
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
+ "quickjs-emscripten": "0.32.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
},
},
@@ -59,3 +59,12 @@ describe('loadConfig migrationsDir', () => {
@@ -59,3 +59,12 @@ describe('loadConfig migrationsDir', () => {
expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)
expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)
})
})
})
})
+
+describe('loadConfig scriptMods', () => {
+ it('is off unless the place enables it', () => {
+ expect(loadConfig({}).scriptMods).toBe(false)
+ expect(loadConfig({ GAMES_SCRIPT_MODS: '0' }).scriptMods).toBe(false)
+ expect(loadConfig({ GAMES_SCRIPT_MODS: '1' }).scriptMods).toBe(true)
+ expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)
+ })
+})
@@ -63,6 +63,11 @@ export interface ServerConfig {
@@ -63,6 +63,11 @@ export interface ServerConfig {
metricsLogSeconds: number
metricsLogSeconds: number
/** Multiplier on world-event cadences (tests shrink it). */
/** Multiplier on world-event cadences (tests shrink it). */
eventIntervalScale: number
eventIntervalScale: number
+ /**
+ * GAMES_SCRIPT_MODS=1: run the def set's games-quickjs@1 script mods on this
+ * place. Off by default; the mods stay in the def set (and its digest) either way.
+ */
+ scriptMods: boolean
}
}
export interface PlatformConfig {
export interface PlatformConfig {
@@ -155,6 +160,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -155,6 +160,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,
eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,
+ scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',
}
}
}
}
@@ -11,12 +11,14 @@
@@ -11,12 +11,14 @@
* - lifecycle events commit with the state they describe and reach
* - lifecycle events commit with the state they describe and reach
* OpenVibe.Events (mock) with the games service token;
* OpenVibe.Events (mock) with the games service token;
* - a restart restores the authoritative state: characters, world props,
* - a restart restores the authoritative state: characters, world props,
- * the world clock and the mod registry — without duplicating mod props.
+ * the world clock and the mod registry — without duplicating mod props;
+ * - a games-quickjs@1 script mod changes the world only through the intents
+ * the gameplay modules apply.
*/
*/
import { mkdtempSync } from 'node:fs'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
-import { createContent } from '@openvibe/content'
+import { createContent, type ContentRegistry } from '@openvibe/content'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
import { openTestDb } from '@openvibe/persistence/testing'
import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
@@ -36,6 +38,8 @@ import { hashGuestKey } from '../net/guestIdentity.js'
@@ -36,6 +38,8 @@ import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRuntime } from '../mods/runtime.js'
import { ModRuntime } from '../mods/runtime.js'
+import { ScriptModHost } from '../mods/script/host.js'
+import { createQuickJsSandboxFactory } from '../mods/script/quickjs.js'
import { sampleManifest } from '../mods/testFixtures.js'
import { sampleManifest } from '../mods/testFixtures.js'
import { ServerMetrics } from '../observability/metrics.js'
import { ServerMetrics } from '../observability/metrics.js'
import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'
import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'
@@ -82,8 +86,11 @@ interface Boot {
@@ -82,8 +86,11 @@ interface Boot {
dispose(): void
dispose(): void
}
}
-async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
- const content = createContent()
+async function boot(
+ platform: ReturnType<typeof createMockPlatform>,
+ opts: { content?: ContentRegistry; scripts?: ScriptModHost } = {},
+): Promise<Boot> {
+ const content = opts.content ?? createContent()
const physics = createRapierWorld(rapier)
const physics = createRapierWorld(rapier)
// The store rides on the shared database; close() is never called (the instance is shared).
// The store rides on the shared database; close() is never called (the instance is shared).
const store = openPgStore(db, { placeId: config.placeId })
const store = openPgStore(db, { placeId: config.placeId })
@@ -112,6 +119,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
@@ -112,6 +119,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
const game = new GameServer(config, world, store, new ServerMetrics(), log, {
const game = new GameServer(config, world, store, new ServerMetrics(), log, {
events: recorder,
events: recorder,
mods: runtime,
mods: runtime,
+ ...(opts.scripts ? { scripts: opts.scripts } : {}),
})
})
await game.load()
await game.load()
return {
return {
@@ -126,7 +134,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
@@ -126,7 +134,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
}
}
}
}
-function connect(game: GameServer) {
+function connect(game: GameServer, contentDigest = createContent().digest) {
const inbox: ServerMessage[] = []
const inbox: ServerMessage[] = []
let closed: { code: number; reason: string } | null = null
let closed: { code: number; reason: string } | null = null
// The session system now requires an identity stamped on the connection
// The session system now requires an identity stamped on the connection
@@ -145,7 +153,7 @@ function connect(game: GameServer) {
@@ -145,7 +153,7 @@ function connect(game: GameServer) {
const msg = {
const msg = {
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
- contentDigest: createContent().digest,
+ contentDigest,
slot: 0,
slot: 0,
name: 'Ana',
name: 'Ana',
appearance: defaultAppearance(),
appearance: defaultAppearance(),
@@ -206,8 +214,24 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -206,8 +214,24 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
// Reject before loading a character or writing any world state. This also
// Reject before loading a character or writing any world state. This also
- // covers an older hello that has no digest at all.
- for (const offered of [undefined, 'v2-00000000']) {
+ // covers an older hello that has no digest at all, and a client whose def
+ // set carries a games-quickjs@1 mod the server does not (the digest pins
+ // mod manifests and sources like every other definition).
+ const withMod = createContent([
+ {
+ mods: [
+ {
+ format: 'games-quickjs@1',
+ id: 'extra',
+ version: '1.0.0',
+ entry: 'extra.js',
+ source: 'function onTick() {}',
+ hooks: ['onTick'],
+ },
+ ],
+ },
+ ]).digest
+ for (const offered of [undefined, 'v2-00000000', withMod]) {
const stale = connect(a.game)
const stale = connect(a.game)
stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
a.game.onMessage(stale.conn, {
a.game.onMessage(stale.conn, {
@@ -382,3 +406,53 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -382,3 +406,53 @@ describe('Games on platform identity, events and mods (real server, restart)', (
c.dispose()
c.dispose()
}, 60_000)
}, 60_000)
})
})
+
+describe('games-quickjs@1 script mods on the real server', () => {
+ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })
+
+ it('a mod changes game state only through intents the gameplay modules apply', async () => {
+ const content = createContent([
+ {
+ mods: [
+ {
+ format: 'games-quickjs@1',
+ id: 'welcome-gift',
+ version: '1.0.0',
+ entry: 'welcome-gift.js',
+ hooks: ['onPlayerJoin'],
+ source: `function onPlayerJoin(e) {
+ e.player.name = 'not a rename';
+ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'scrap_metal', count: 3 });
+ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'no_such_item', count: 1 });
+ game.emit({ kind: 'announce', text: 'welcome ' + e.player.name });
+ }`,
+ },
+ ],
+ },
+ ])
+ const scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), log)
+ const b = await boot(platform, { content, scripts })
+ await scripts.start()
+ const p = connect(b.game, content.digest)
+ const { welcome } = await p.hello('guest-script-mod')
+ expect(welcome).toBeDefined()
+
+ const scrap = (m: ServerMessage) =>
+ m.t === 'inventory'
+ ? m.inv.slots.reduce((n, s) => n + (s.stack.def === 'scrap_metal' ? s.stack.count : 0), 0)
+ : 0
+ // A fresh character carries no scrap: all of it is the gift, through the game's inventory.
+ const inventories = p.inbox.filter((m) => m.t === 'inventory')
+ expect(inventories.length).toBeGreaterThan(0)
+ expect(scrap(inventories.at(-1)!)).toBe(3)
+ expect(p.inbox).toContainEqual({ t: 'announce', text: 'welcome not a rename' })
+ // The unknown item was refused by the game, not by trusting the mod.
+ expect(scripts.status()[0]).toMatchObject({
+ state: 'running',
+ intentsApplied: 2,
+ intentsRejected: 1,
+ })
+ scripts.dispose()
+ b.dispose()
+ })
+})
@@ -6,6 +6,7 @@ import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
@@ -6,6 +6,7 @@ import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
import type { ServerConfig } from '../../config.js'
import type { ServerConfig } from '../../config.js'
import type { TicketIdentity } from '../../net/wsTicket.js'
import type { TicketIdentity } from '../../net/wsTicket.js'
import type { ModRuntime } from '../../mods/runtime.js'
import type { ModRuntime } from '../../mods/runtime.js'
+import type { ScriptModHost } from '../../mods/script/host.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
@@ -64,6 +65,8 @@ export interface GameIntegrations {
@@ -64,6 +65,8 @@ export interface GameIntegrations {
events?: GameEventRecorder
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime
mods?: ModRuntime
+ /** games-quickjs@1 script mods; present only where the place enables them (GAMES_SCRIPT_MODS). */
+ scripts?: ScriptModHost
/** The person's games.progress.summary user module on Network, written when a character leaves. */
/** The person's games.progress.summary user module on Network, written when a character leaves. */
progressSummary?: ProgressSummaryWriter
progressSummary?: ProgressSummaryWriter
}
}
@@ -1,11 +1,23 @@
@@ -1,11 +1,23 @@
import { terrainHeight } from '@openvibe/content'
import { terrainHeight } from '@openvibe/content'
import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'
import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'
import type { ModHost } from '../../mods/runtime.js'
import type { ModHost } from '../../mods/runtime.js'
+import type { ScriptPlayer, ScriptWorld } from '../../mods/script/host.js'
+import type { PlayerSession } from '../playerSession.js'
import type { ServerContext } from './context.js'
import type { ServerContext } from './context.js'
import type { System } from './system.js'
import type { System } from './system.js'
+function scriptPlayer(s: PlayerSession): ScriptPlayer {
+ return {
+ id: s.playerId,
+ name: s.name,
+ pos: [s.move.pos.x, s.move.pos.y, s.move.pos.z],
+ yaw: s.yaw,
+ }
+}
+
/**
/**
- * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent.
+ * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent,
+ * and, where the place enables them, the script mods' tick, join/leave hooks and world.
*/
*/
export class ModSystem implements System {
export class ModSystem implements System {
readonly name = 'mods'
readonly name = 'mods'
@@ -41,11 +53,64 @@ export class ModSystem implements System {
@@ -41,11 +53,64 @@ export class ModSystem implements System {
entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,
entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,
}
}
- constructor(private readonly ctx: ServerContext) {}
+ /**
+ * What script mods read and change. Changes arrive as validated intents and
+ * go through the same inventory and broadcast paths the game itself uses.
+ */
+ readonly scriptWorld: ScriptWorld = {
+ players: () => [...this.ctx.sessions.values()].map(scriptPlayer),
+ player: (id) => {
+ const session = this.ctx.sessions.get(id)
+ return session ? scriptPlayer(session) : null
+ },
+ apply: (_modId, intent) => {
+ switch (intent.kind) {
+ case 'announce':
+ this.ctx.net.broadcastAll({ t: 'announce', text: intent.text })
+ return null
+ case 'giveItem': {
+ const session = this.ctx.sessions.get(intent.playerId)
+ if (!session) return 'no such player'
+ if (!this.ctx.world.content.item(intent.item)) return 'unknown item'
+ if (!session.inventory.canFit(intent.item, intent.count)) return 'inventory_full'
+ session.inventory.add(intent.item, intent.count)
+ session.dirty = true
+ this.ctx.net.sendInventory(session)
+ return null
+ }
+ }
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {
+ ctx.integrations.scripts?.bind(this.scriptWorld)
+ }
- tick(ctx: ServerContext, _dt: number): void {
+ tick(ctx: ServerContext, dt: number): void {
// Reconcile installs (a revoked or disabled mod's effects are
// Reconcile installs (a revoked or disabled mod's effects are
// retracted on the first tick after the change).
// retracted on the first tick after the change).
ctx.integrations.mods?.tick(ctx.clock.tick, this.host)
ctx.integrations.mods?.tick(ctx.clock.tick, this.host)
+ const scripts = ctx.integrations.scripts
+ if (scripts) {
+ scripts.tick(ctx.clock.tick, dt)
+ let running = 0
+ let failures = 0
+ const status = scripts.status()
+ for (const s of status) {
+ if (s.state === 'running') running++
+ failures += s.failures.cpu + s.failures.memory + s.failures.stack + s.failures.error
+ }
+ ctx.metrics.scriptModsRunning = running
+ ctx.metrics.scriptModsDisabled = status.length - running
+ ctx.metrics.scriptModFailures = failures
+ }
+ }
+
+ onJoin(session: PlayerSession): void {
+ this.ctx.integrations.scripts?.playerJoined(scriptPlayer(session))
+ }
+
+ onLeave(session: PlayerSession): void {
+ this.ctx.integrations.scripts?.playerLeft(session.playerId)
}
}
}
}
@@ -34,6 +34,7 @@ import { createNetworkModGrants } from './mods/networkGrants.js'
@@ -34,6 +34,7 @@ import { createNetworkModGrants } from './mods/networkGrants.js'
import { createGamesActorLimits } from './net/actorLimits.js'
import { createGamesActorLimits } from './net/actorLimits.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
+import { ScriptModHost } from './mods/script/host.js'
import { isGuestToken } from './platform/accounts.js'
import { isGuestToken } from './platform/accounts.js'
import { ProgressSummaryWriter } from './platform/progressSummary.js'
import { ProgressSummaryWriter } from './platform/progressSummary.js'
import {
import {
@@ -201,13 +202,25 @@ async function main(): Promise<void> {
@@ -201,13 +202,25 @@ async function main(): Promise<void> {
const progressSummary = platform
const progressSummary = platform
? new ProgressSummaryWriter(platform.client, platformLog.child({ system: 'progress-summary' }))
? new ProgressSummaryWriter(platform.client, platformLog.child({ system: 'progress-summary' }))
: null
: null
+ // games-quickjs@1 script mods run only where the place enables them; the engine (WASM) is not
+ // even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.
+ const scriptLog = log.child({ system: 'script-mods' })
+ let scripts: ScriptModHost | undefined
+ if (config.scriptMods && content.allMods().length > 0) {
+ const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
+ scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), scriptLog)
+ } else if (content.allMods().length > 0) {
+ scriptLog.info('script mods off for this place', { mods: content.allMods().length })
+ }
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
mods: modRuntime,
+ ...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
})
})
// Async boot: env, markets and the NPC population load before the tick loop starts.
// Async boot: env, markets and the NPC population load before the tick loop starts.
await game.load()
await game.load()
+ await scripts?.start()
const accountData = platform
const accountData = platform
? createAccountData({
? createAccountData({
@@ -373,6 +386,7 @@ async function main(): Promise<void> {
@@ -373,6 +386,7 @@ async function main(): Promise<void> {
mods: {
mods: {
installed: mods.list().length,
installed: mods.list().length,
active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
+ scripts: { enabled: scripts !== undefined, mods: scripts?.status() ?? [] },
},
},
}),
}),
)
)
@@ -514,6 +528,7 @@ async function main(): Promise<void> {
@@ -514,6 +528,7 @@ async function main(): Promise<void> {
// (that checkpoint plus every disconnect save queued before it) drain.
// (that checkpoint plus every disconnect save queued before it) drain.
game.shutdown()
game.shutdown()
const drained = await game.drain()
const drained = await game.drain()
+ scripts?.dispose()
const cut = await httpDone
const cut = await httpDone
await within(2000, mirrorDone)
await within(2000, mirrorDone)
await within(1000, progressSummary?.settle())
await within(1000, progressSummary?.settle())
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+import { describe, expect, it } from 'vitest'
+import type { ScriptMod } from '@openvibe/content'
+import type { Logger } from '@openvibe/shared'
+import {
+ ERROR_STRIKES,
+ parseIntent,
+ ScriptModHost,
+ type ScriptIntent,
+ type ScriptWorld,
+} from './host.js'
+import type { Json, ModSandboxFactory, SandboxHostFunctions, SandboxOutcome } from './sandbox.js'
+
+/** A fake engine: each mod's "script" is a host-side function driving the lent `game` functions. */
+type FakeScript = (
+ game: SandboxHostFunctions,
+ hook: string,
+ payload: Json,
+ cpuMs: number,
+ clock: { t: number },
+) => SandboxOutcome
+
+function fakeFactory(scripts: Record<string, FakeScript>, clock: { t: number }) {
+ const disposed: string[] = []
+ const factory: ModSandboxFactory = {
+ engine: 'fake',
+ async create(spec) {
+ const script = scripts[spec.id]
+ if (!script) throw new Error(`no script ${spec.id}`)
+ return {
+ ok: true,
+ sandbox: {
+ call: (hook, payload, cpuMs) => script(spec.host, hook, payload, cpuMs, clock),
+ dispose: () => void disposed.push(spec.id),
+ },
+ }
+ },
+ }
+ return { factory, disposed }
+}
+
+function mod(id: string, budgets?: ScriptMod['budgets']): ScriptMod {
+ return {
+ format: 'games-quickjs@1',
+ id,
+ version: '1.0.0',
+ entry: `${id}.js`,
+ source: '/* fake */',
+ hooks: ['onTick', 'onPlayerJoin'],
+ ...(budgets ? { budgets } : {}),
+ }
+}
+
+function recordingWorld(): ScriptWorld & { applied: [string, ScriptIntent][] } {
+ const applied: [string, ScriptIntent][] = []
+ return {
+ applied,
+ players: () => [{ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 }],
+ player: (id) => (id === 'p1' ? { id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 } : null),
+ apply: (modId, intent) => {
+ applied.push([modId, intent])
+ return null
+ },
+ }
+}
+
+const logs: [string, string, unknown][] = []
+const log: Logger = {
+ debug: (m, f) => void logs.push(['debug', m, f]),
+ info: (m, f) => void logs.push(['info', m, f]),
+ warn: (m, f) => void logs.push(['warn', m, f]),
+ error: (m, f) => void logs.push(['error', m, f]),
+ child: () => log,
+}
+
+async function boot(scripts: Record<string, FakeScript>, mods: ScriptMod[]) {
+ const clock = { t: 0 }
+ const { factory, disposed } = fakeFactory(scripts, clock)
+ const host = new ScriptModHost(mods, factory, log, () => clock.t)
+ const world = recordingWorld()
+ host.bind(world)
+ await host.start()
+ return { host, world, clock, disposed }
+}
+
+describe('ScriptModHost', () => {
+ it('applies intents only after the call completes; a failed call changes nothing', async () => {
+ const { host, world } = await boot(
+ {
+ good: (game, hook) => {
+ if (hook === 'onTick') game.emit!({ kind: 'announce', text: 'tick' })
+ return { ok: true }
+ },
+ half: (game) => {
+ game.emit!({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1 })
+ return { ok: false, reason: 'error', message: 'boom after emitting' }
+ },
+ },
+ [mod('good'), mod('half')],
+ )
+ host.tick(1, 1 / 30)
+ expect(world.applied).toEqual([['good', { kind: 'announce', text: 'tick' }]])
+ expect(host.status().find((s) => s.id === 'half')).toMatchObject({
+ state: 'running',
+ failures: { error: 1 },
+ intentsApplied: 0,
+ })
+ })
+
+ it('disables a mod over its cpuMs for this instance and keeps ticking the others', async () => {
+ const seen: number[] = []
+ const { host, world, disposed } = await boot(
+ {
+ hog: (_game, _hook, _payload, cpuMs, clock) => {
+ clock.t += cpuMs
+ return { ok: false, reason: 'cpu', message: 'interrupted' }
+ },
+ fine: (game, _hook, _payload, cpuMs) => {
+ seen.push(cpuMs)
+ game.emit!({ kind: 'announce', text: 'still here' })
+ return { ok: true }
+ },
+ },
+ [mod('hog', { cpuMs: 3 }), mod('fine')],
+ )
+ host.tick(1, 1 / 30)
+ host.tick(2, 1 / 30)
+ const hog = host.status().find((s) => s.id === 'hog')!
+ expect(hog).toMatchObject({ state: 'disabled', reason: 'cpu', calls: 1, failures: { cpu: 1 } })
+ expect(disposed).toEqual(['hog'])
+ expect(seen).toEqual([2, 2]) // the default budget, offered whole each tick
+ expect(world.applied).toHaveLength(2)
+ expect(logs.some(([l, m]) => l === 'warn' && m === 'script mod disabled')).toBe(true)
+ })
+
+ it('shares cpuMs across every call in a tick and disables a mod that spent it', async () => {
+ const offered: number[] = []
+ const { host } = await boot(
+ {
+ busy: (_g, _h, _p, cpuMs, clock) => {
+ offered.push(cpuMs)
+ clock.t += 1.5
+ return { ok: true }
+ },
+ },
+ [mod('busy', { cpuMs: 3 })],
+ )
+ host.tick(1, 1 / 30)
+ host.playerJoined({ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 })
+ expect(offered).toEqual([3, 1.5])
+ host.playerJoined({ id: 'p2', name: 'Bo', pos: [0, 1, 0], yaw: 0 })
+ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'cpu' })
+ })
+
+ it('a throwing mod never throws into the tick, and is disabled after ERROR_STRIKES in a row', async () => {
+ const { host } = await boot(
+ {
+ thrower: () => {
+ throw new Error('engine bug')
+ },
+ },
+ [mod('thrower')],
+ )
+ for (let i = 1; i < ERROR_STRIKES; i++) expect(() => host.tick(i, 1 / 30)).not.toThrow()
+ expect(host.status()[0]).toMatchObject({
+ state: 'running',
+ failures: { error: ERROR_STRIKES - 1 },
+ })
+ host.tick(ERROR_STRIKES, 1 / 30)
+ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'error' })
+ })
+
+ it('a mod whose sandbox cannot be created is disabled and the rest load', async () => {
+ const { host } = await boot({ ok: () => ({ ok: true }) }, [mod('missing'), mod('ok')])
+ expect(host.status().map((s) => [s.id, s.state])).toEqual([
+ ['missing', 'disabled'],
+ ['ok', 'running'],
+ ])
+ })
+
+ it('refuses bad intents inside the call and caps intents per call', async () => {
+ const errors: string[] = []
+ const { host, world } = await boot(
+ {
+ spam: (game) => {
+ for (const bad of [{ kind: 'teleport' }, { kind: 'announce', text: '' }, 'x']) {
+ try {
+ game.emit!(bad)
+ } catch (err) {
+ errors.push((err as Error).message)
+ }
+ }
+ try {
+ for (let i = 0; i < 100; i++) game.emit!({ kind: 'announce', text: `n${i}` })
+ } catch (err) {
+ errors.push((err as Error).message)
+ }
+ return { ok: true }
+ },
+ },
+ [mod('spam')],
+ )
+ host.tick(1, 1 / 30)
+ expect(errors).toHaveLength(4)
+ expect(world.applied).toHaveLength(16)
+ })
+
+ it('game.emit outside a hook throws', async () => {
+ let captured: SandboxHostFunctions | null = null
+ const { host } = await boot(
+ {
+ keep: (game) => {
+ captured = game
+ return { ok: true }
+ },
+ },
+ [mod('keep')],
+ )
+ host.tick(1, 1 / 30)
+ expect(() => captured!.emit!({ kind: 'announce', text: 'late' })).toThrow(/inside a hook/)
+ })
+})
+
+describe('parseIntent', () => {
+ it('rebuilds the intent from known fields only', () => {
+ const raw = JSON.parse(
+ '{"kind":"giveItem","playerId":"p1","item":"scrap","count":2,"admin":true,"__proto__":{"polluted":1}}',
+ ) as Json
+ const intent = parseIntent(raw)
+ expect(intent).toEqual({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 })
+ expect(Object.getPrototypeOf(intent)).toBe(Object.prototype)
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
+ expect(parseIntent({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1000 })).toMatch(
+ /count/,
+ )
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,351 @@
@@ -0,0 +1,351 @@
+/**
+ * The script mod host: runs the `games-quickjs@1` mods of the def set, one
+ * sandbox each, from the tick and the join/leave hooks.
+ *
+ * A mod reads the world and asks for changes; it never makes them. What it
+ * can reach is the frozen global `game` (see docs/mods.md):
+ *
+ * game.players() every player in the world: { id, name, pos, yaw }
+ * game.player(id) one player, or null
+ * game.emit(intent) queue an intent (validated on the spot, throws if bad)
+ * game.log(...values) a line in the server log (a few per tick)
+ *
+ * Intents queued during a call are applied after the call returns, through
+ * `ScriptWorld.apply` (the gameplay modules), and only if the call completed:
+ * a call stopped for its budget or by a throw changes nothing.
+ *
+ * Budgets: `cpuMs` is per tick (every call the mod gets in one tick shares it)
+ * and caps each call; `memoryMb` and `stackKb` bound the mod's engine. A
+ * budget breach stops the call, disables the mod for this instance (its
+ * sandbox is freed), logs it and counts it; so does an engine failure, and
+ * ERROR_STRIKES throws in a row. The tick goes on either way; nothing a mod does reaches the
+ * caller as an exception.
+ */
+import { performance } from 'node:perf_hooks'
+import { SCRIPT_MOD_BUDGETS, type ScriptMod, type ScriptModHook } from '@openvibe/content'
+import type { Logger } from '@openvibe/shared'
+import type {
+ Json,
+ ModSandbox,
+ ModSandboxFactory,
+ SandboxFailureReason,
+ SandboxHostFunctions,
+} from './sandbox.js'
+
+/** The load (top-level code) may use this many ticks' worth of cpuMs. */
+export const LOAD_CPU_FACTOR = 25
+/** Throws in a row that disable a mod. */
+export const ERROR_STRIKES = 5
+export const MAX_INTENTS_PER_CALL = 16
+export const MAX_LOGS_PER_TICK = 4
+
+export interface ScriptPlayer {
+ id: string
+ name: string
+ pos: [number, number, number]
+ yaw: number
+}
+
+/** Everything a mod may ask for. Each is applied by the gameplay modules, never by the mod. */
+export type ScriptIntent =
+ | { kind: 'announce'; text: string }
+ | { kind: 'giveItem'; playerId: string; item: string; count: number }
+
+/** What the game lends the host: reads, and one door for changes. */
+export interface ScriptWorld {
+ players(): ScriptPlayer[]
+ player(id: string): ScriptPlayer | null
+ /** Applies a validated intent; returns why it was refused, or null. */
+ apply(modId: string, intent: ScriptIntent): string | null
+}
+
+export interface ScriptModBudget {
+ cpuMs: number
+ memoryMb: number
+ stackKb: number
+}
+
+export type ScriptModState = 'running' | 'disabled'
+
+export interface ScriptModStatus {
+ id: string
+ version: string
+ hooks: ScriptModHook[]
+ budget: ScriptModBudget
+ state: ScriptModState
+ /** Why it was disabled (a budget, or `error` after ERROR_STRIKES throws). */
+ reason: SandboxFailureReason | null
+ calls: number
+ failures: Record<SandboxFailureReason, number>
+ intentsApplied: number
+ intentsRejected: number
+}
+
+/** The manifest's requested budgets, with defaults for what it left out. */
+export function resolveBudget(mod: ScriptMod): ScriptModBudget {
+ return {
+ cpuMs: mod.budgets?.cpuMs ?? SCRIPT_MOD_BUDGETS.cpuMs.default,
+ memoryMb: mod.budgets?.memoryMb ?? SCRIPT_MOD_BUDGETS.memoryMb.default,
+ stackKb: mod.budgets?.stackKb ?? SCRIPT_MOD_BUDGETS.stackKb.default,
+ }
+}
+
+const isObject = (v: Json): v is { [key: string]: Json } =>
+ typeof v === 'object' && v !== null && !Array.isArray(v)
+const shortString = (v: Json | undefined, max: number): v is string =>
+ typeof v === 'string' && v.length > 0 && v.length <= max
+
+/**
+ * An intent from script JSON, rebuilt field by field: nothing the script put
+ * on the object besides the known fields (a `__proto__` key included) survives.
+ */
+export function parseIntent(raw: Json): ScriptIntent | string {
+ if (!isObject(raw)) return 'an intent must be an object'
+ switch (raw.kind) {
+ case 'announce': {
+ const text = typeof raw.text === 'string' ? raw.text.trim() : ''
+ if (!shortString(text, 200)) return 'announce: text must be 1-200 characters'
+ return { kind: 'announce', text }
+ }
+ case 'giveItem': {
+ const { playerId, item, count } = raw
+ if (!shortString(playerId, 64)) return 'giveItem: playerId must be a string'
+ if (!shortString(item, 64)) return 'giveItem: item must be a string'
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > 100)
+ return 'giveItem: count must be an integer 1-100'
+ return { kind: 'giveItem', playerId, item, count }
+ }
+ default:
+ return `unknown intent kind ${JSON.stringify(raw.kind ?? null)}`
+ }
+}
+
+interface ModSlot {
+ mod: ScriptMod
+ budget: ScriptModBudget
+ hooks: Set<ScriptModHook>
+ sandbox: ModSandbox | null
+ state: ScriptModState
+ reason: SandboxFailureReason | null
+ /** CPU spent since the current tick began. */
+ spentMs: number
+ logsThisTick: number
+ strikes: number
+ calls: number
+ failures: Record<SandboxFailureReason, number>
+ intentsApplied: number
+ intentsRejected: number
+}
+
+export class ScriptModHost {
+ private readonly slots: ModSlot[]
+ private world: ScriptWorld | null = null
+ /** The call in progress: whose intents `game.emit` queues, and where. */
+ private current: { slot: ModSlot; intents: ScriptIntent[] } | null = null
+
+ constructor(
+ mods: readonly ScriptMod[],
+ private readonly factory: ModSandboxFactory,
+ private readonly log: Logger,
+ private readonly now: () => number = () => performance.now(),
+ ) {
+ this.slots = mods.map((mod) => ({
+ mod,
+ budget: resolveBudget(mod),
+ hooks: new Set(mod.hooks),
+ sandbox: null,
+ state: 'running',
+ reason: null,
+ spentMs: 0,
+ logsThisTick: 0,
+ strikes: 0,
+ calls: 0,
+ failures: { cpu: 0, memory: 0, stack: 0, error: 0 },
+ intentsApplied: 0,
+ intentsRejected: 0,
+ }))
+ }
+
+ /** The world the mods read and change; the game server binds it once. */
+ bind(world: ScriptWorld): void {
+ this.world = world
+ }
+
+ /** Creates every sandbox and evaluates each mod once. A mod that fails to load is disabled. */
+ async start(): Promise<void> {
+ for (const slot of this.slots) {
+ const { mod, budget } = slot
+ let created: Awaited<ReturnType<ModSandboxFactory['create']>>
+ try {
+ created = await this.factory.create({
+ id: mod.id,
+ filename: mod.entry,
+ source: mod.source,
+ limits: { memoryBytes: budget.memoryMb * 1024 * 1024, stackBytes: budget.stackKb * 1024 },
+ loadCpuMs: budget.cpuMs * LOAD_CPU_FACTOR,
+ host: this.hostFunctions(slot),
+ })
+ } catch (err) {
+ created = {
+ ok: false,
+ reason: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ }
+ }
+ if (!created.ok) {
+ slot.failures[created.reason]++
+ this.disable(slot, created.reason, `load: ${created.message}`)
+ continue
+ }
+ slot.sandbox = created.sandbox
+ this.log.info('script mod loaded', {
+ mod: mod.id,
+ version: mod.version,
+ engine: this.factory.engine,
+ hooks: mod.hooks.join(','),
+ cpuMs: budget.cpuMs,
+ memoryMb: budget.memoryMb,
+ stackKb: budget.stackKb,
+ })
+ }
+ }
+
+ tick(tick: number, dtSeconds: number): void {
+ for (const slot of this.slots) {
+ slot.spentMs = 0
+ slot.logsThisTick = 0
+ }
+ for (const slot of this.slots) this.run(slot, 'onTick', { tick, dt: dtSeconds })
+ }
+
+ playerJoined(player: ScriptPlayer): void {
+ for (const slot of this.slots) this.run(slot, 'onPlayerJoin', { player: { ...player } })
+ }
+
+ playerLeft(playerId: string): void {
+ for (const slot of this.slots) this.run(slot, 'onPlayerLeave', { playerId })
+ }
+
+ status(): ScriptModStatus[] {
+ return this.slots.map((s) => ({
+ id: s.mod.id,
+ version: s.mod.version,
+ hooks: [...s.mod.hooks],
+ budget: { ...s.budget },
+ state: s.state,
+ reason: s.reason,
+ calls: s.calls,
+ failures: { ...s.failures },
+ intentsApplied: s.intentsApplied,
+ intentsRejected: s.intentsRejected,
+ }))
+ }
+
+ /** Frees every sandbox (shutdown). */
+ dispose(): void {
+ for (const slot of this.slots) {
+ slot.sandbox?.dispose()
+ slot.sandbox = null
+ }
+ }
+
+ private run(slot: ModSlot, hook: ScriptModHook, payload: Json): void {
+ const sandbox = slot.sandbox
+ if (slot.state !== 'running' || !sandbox || !slot.hooks.has(hook)) return
+ const remaining = slot.budget.cpuMs - slot.spentMs
+ if (remaining <= 0) {
+ slot.failures.cpu++
+ this.disable(slot, 'cpu', `${hook}: this tick's ${slot.budget.cpuMs} cpuMs are spent`)
+ return
+ }
+ const intents: ScriptIntent[] = []
+ this.current = { slot, intents }
+ const started = this.now()
+ let outcome: ReturnType<ModSandbox['call']>
+ try {
+ outcome = sandbox.call(hook, payload, remaining)
+ } catch (err) {
+ outcome = {
+ ok: false,
+ reason: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ }
+ } finally {
+ this.current = null
+ }
+ slot.spentMs += this.now() - started
+ slot.calls++
+ if (!outcome.ok) {
+ slot.failures[outcome.reason]++
+ if (outcome.reason !== 'error' || outcome.fatal) {
+ this.disable(slot, outcome.reason, `${hook}: ${outcome.message}`)
+ return
+ }
+ slot.strikes++
+ if (slot.strikes >= ERROR_STRIKES) {
+ this.disable(
+ slot,
+ 'error',
+ `${hook}: ${ERROR_STRIKES} throws in a row, last: ${outcome.message}`,
+ )
+ } else if (slot.strikes === 1) {
+ this.log.warn('script mod threw', { mod: slot.mod.id, hook, error: outcome.message })
+ }
+ return
+ }
+ slot.strikes = 0
+ for (const intent of intents) {
+ const refused = this.world ? this.world.apply(slot.mod.id, intent) : 'no world bound'
+ if (refused === null) slot.intentsApplied++
+ else {
+ slot.intentsRejected++
+ this.log.debug('script mod intent refused', {
+ mod: slot.mod.id,
+ kind: intent.kind,
+ refused,
+ })
+ }
+ }
+ }
+
+ private disable(slot: ModSlot, reason: SandboxFailureReason, message: string): void {
+ slot.state = 'disabled'
+ slot.reason = reason
+ slot.sandbox?.dispose()
+ slot.sandbox = null
+ this.log.warn('script mod disabled', {
+ mod: slot.mod.id,
+ reason,
+ message: message.slice(0, 500),
+ })
+ }
+
+ /** The functions behind `game`; each knows its mod, so no mod can act as another. */
+ private hostFunctions(slot: ModSlot): SandboxHostFunctions {
+ return {
+ players: () => (this.world?.players() ?? []) as unknown as Json,
+ player: (id) => {
+ if (typeof id !== 'string') throw new Error('game.player(id): id must be a string')
+ return (this.world?.player(id) ?? null) as unknown as Json
+ },
+ emit: (raw) => {
+ const call = this.current
+ if (!call || call.slot !== slot)
+ throw new Error('game.emit is only available inside a hook')
+ if (call.intents.length >= MAX_INTENTS_PER_CALL)
+ throw new Error(`at most ${MAX_INTENTS_PER_CALL} intents per call`)
+ const intent = parseIntent(raw ?? null)
+ if (typeof intent === 'string') throw new Error(intent)
+ call.intents.push(intent)
+ return undefined
+ },
+ log: (...values) => {
+ if (slot.logsThisTick >= MAX_LOGS_PER_TICK) return undefined
+ slot.logsThisTick++
+ const text = values.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
+ this.log.info('script mod log', { mod: slot.mod.id, text: text.slice(0, 500) })
+ return undefined
+ },
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,297 @@
@@ -0,0 +1,297 @@
+/**
+ * The QuickJS sandbox against real scripts: escapes, budgets, and the host
+ * driving it end to end. Timings use generous slack (software CI) but stay far
+ * below what an unbounded script would take.
+ */
+import { performance } from 'node:perf_hooks'
+import { describe, expect, it } from 'vitest'
+import type { ScriptMod } from '@openvibe/content'
+import type { Logger } from '@openvibe/shared'
+import { ScriptModHost, type ScriptIntent, type ScriptPlayer, type ScriptWorld } from './host.js'
+import { createQuickJsSandboxFactory } from './quickjs.js'
+import type { Json, ModSandbox, SandboxHostFunctions, SandboxSpec } from './sandbox.js'
+
+const factory = createQuickJsSandboxFactory()
+const MB = 1024 * 1024
+
+async function sandbox(
+ source: string,
+ host: SandboxHostFunctions = {},
+ limits: Partial<SandboxSpec['limits']> = {},
+): Promise<ModSandbox> {
+ const made = await factory.create({
+ id: 'test',
+ filename: 'test.js',
+ source,
+ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024, ...limits },
+ loadCpuMs: 200,
+ host,
+ })
+ if (!made.ok) throw new Error(`load failed: ${made.reason} ${made.message}`)
+ return made.sandbox
+}
+
+/** A sandbox whose `report(v)` collects values, for scripts that answer questions. */
+async function asking(source: string): Promise<{ box: ModSandbox; answers: Json[] }> {
+ const answers: Json[] = []
+ const box = await sandbox(source, { report: (v) => void answers.push(v ?? null) })
+ return { box, answers }
+}
+
+const quiet: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => quiet,
+}
+
+describe('QuickJS sandbox: escapes', () => {
+ it('exposes no require, process, timers, module system or host objects', async () => {
+ const { box, answers } = await asking(`
+ function onTick() {
+ const names = ['require', 'process', 'module', 'exports', '__dirname', 'global', 'Buffer',
+ 'setTimeout', 'setInterval', 'setImmediate', 'fetch', 'XMLHttpRequest', 'WebSocket',
+ 'std', 'os', 'scriptArgs', 'print', 'Deno', 'Bun', 'WebAssembly'];
+ game.report(names.filter((n) => typeof globalThis[n] !== 'undefined'));
+ // The Function constructor reaches this context's global, never the host's.
+ game.report(Function('return typeof process')());
+ game.report(game.report.constructor('return typeof require')());
+ // Everything on the global that is not a language intrinsic.
+ game.report(Object.getOwnPropertyNames(globalThis).filter((n) => /^[a-z_$]/.test(n) && ![
+ 'globalThis', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'decodeURI', 'decodeURIComponent',
+ 'encodeURI', 'encodeURIComponent', 'escape', 'unescape', 'eval', 'undefined', 'onTick',
+ 'game'].includes(n)));
+ // The lent object is frozen and cannot be replaced.
+ try { game.report = null } catch (e) {}
+ try { globalThis.game = {} } catch (e) {}
+ game.report([Object.isFrozen(game), typeof game.report]);
+ }`)
+ expect(box.call('onTick', null, 100)).toEqual({ ok: true })
+ expect(answers).toEqual([[], 'undefined', 'undefined', [], [true, 'function']])
+ box.dispose()
+ })
+
+ it('cannot pollute host prototypes or reach host objects through the bridge', async () => {
+ const seen: Json[] = []
+ const box = await sandbox(
+ `function onTick() {
+ Object.prototype.polluted = 'yes';
+ Array.prototype.push = function () { return 'hijacked' };
+ JSON.parse = function () { return { admin: true } };
+ const got = game.echo({ __proto__: { admin: true }, constructor: { prototype: { admin: true } } });
+ game.echo(JSON.stringify(Object.keys(got)));
+ }`,
+ { echo: (v) => (seen.push(v ?? null), v ?? null) },
+ )
+ expect(box.call('onTick', null, 100)).toEqual({ ok: true })
+ const plain: Record<string, unknown> = {}
+ expect(plain.polluted).toBeUndefined()
+ expect(plain.admin).toBeUndefined()
+ const host: number[] = []
+ expect(host.push(1)).toBe(1)
+ expect(JSON.parse('{}')).toEqual({})
+ // Only data arrived, as fresh host objects with the host's own prototype.
+ expect(Object.getPrototypeOf(seen[0])).toBe(Object.prototype)
+ expect((seen[0] as Record<string, unknown>).admin).toBeUndefined()
+ box.dispose()
+ })
+
+ it("cannot read or change another mod's state", async () => {
+ const a = await asking(`
+ globalThis.secret = 'a-only';
+ Object.prototype.leak = 'from a';
+ function onTick() { game.report([typeof secret, ({}).leak ?? null]) }`)
+ const b = await asking(`
+ function onTick() {
+ game.report([typeof secret, ({}).leak ?? null, typeof globalThis.onTick]);
+ }`)
+ expect(b.box.call('onTick', null, 100)).toEqual({ ok: true })
+ expect(a.box.call('onTick', null, 100)).toEqual({ ok: true })
+ expect(b.answers).toEqual([['undefined', null, 'function']])
+ expect(a.answers).toEqual([['string', 'from a']])
+ a.box.dispose()
+ b.box.dispose()
+ })
+})
+
+describe('QuickJS sandbox: budgets', () => {
+ it('stops an infinite loop within its cpuMs', async () => {
+ const box = await sandbox('function onTick() { for (;;) {} }')
+ const started = performance.now()
+ const outcome = box.call('onTick', null, 5)
+ const took = performance.now() - started
+ expect(outcome).toMatchObject({ ok: false, reason: 'cpu' })
+ expect(took).toBeLessThan(5 + 100)
+ box.dispose()
+ })
+
+ it('stops a promise-job loop, even one that swallows the interrupt, within the same cpuMs', async () => {
+ for (const source of [
+ 'function onTick() { const spin = () => Promise.resolve().then(spin); spin() }',
+ 'function onTick() { const spin = () => Promise.resolve().then(spin, spin); spin() }',
+ 'async function onTick() { for (;;) {} }',
+ ]) {
+ const box = await sandbox(source)
+ const started = performance.now()
+ expect(box.call('onTick', null, 5)).toMatchObject({ ok: false, reason: 'cpu' })
+ expect(performance.now() - started).toBeLessThan(5 + 100)
+ box.dispose()
+ }
+ })
+
+ it('stops an allocation bomb at memoryMb, even one that catches the error', async () => {
+ for (const source of [
+ 'function onTick() { const keep = []; for (;;) keep.push(new Array(1 << 16).fill(1.5)) }',
+ 'function onTick() { const keep = []; for (;;) keep.push({ n: keep.length }) }',
+ `function onTick() {
+ const keep = [];
+ try { for (;;) keep.push(new Float64Array(1 << 16)) } catch (e) { keep.length = 0 }
+ }`,
+ ]) {
+ const box = await sandbox(source, {}, { memoryBytes: 4 * MB })
+ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'memory' })
+ box.dispose()
+ }
+ })
+
+ it('refuses a single allocation larger than memoryMb', async () => {
+ const box = await sandbox(
+ 'function onTick() { new Float64Array(1 << 20) }',
+ {},
+ {
+ memoryBytes: 4 * MB,
+ },
+ )
+ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, reason: 'memory' })
+ box.dispose()
+ })
+
+ it("survives an engine path that exhausts the host's own stack, as a fatal stack breach", async () => {
+ const other = await asking('function onTick() { game.report("still here") }')
+ const box = await sandbox(
+ 'function onTick() { let a = []; for (let i = 0; i < 1e5; i++) a = [a]; JSON.stringify(a) }',
+ {},
+ { memoryBytes: 32 * MB },
+ )
+ expect(box.call('onTick', null, 2000)).toMatchObject({
+ ok: false,
+ reason: 'stack',
+ fatal: true,
+ })
+ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, fatal: true })
+ expect(() => box.dispose()).not.toThrow()
+ expect(other.box.call('onTick', null, 100)).toEqual({ ok: true })
+ expect(other.answers).toEqual(['still here'])
+ other.box.dispose()
+ })
+
+ it('stops runaway recursion at the stack limit', async () => {
+ const box = await sandbox('function onTick() { const f = (n) => f(n + 1) + 1; f(0) }')
+ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'stack' })
+ box.dispose()
+ })
+
+ it('reports a top-level infinite loop as a load failure', async () => {
+ const made = await factory.create({
+ id: 'boot-loop',
+ filename: 'boot-loop.js',
+ source: 'while (true) {}',
+ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024 },
+ loadCpuMs: 10,
+ host: {},
+ })
+ expect(made).toMatchObject({ ok: false, reason: 'cpu' })
+ })
+})
+
+describe('ScriptModHost on QuickJS', () => {
+ const base = { format: 'games-quickjs@1', version: '1.0.0', hooks: ['onTick'] } as const
+ const scriptMod = (id: string, source: string, extra: Partial<ScriptMod> = {}): ScriptMod => ({
+ ...base,
+ hooks: [...base.hooks],
+ id,
+ entry: `${id}.js`,
+ source,
+ ...extra,
+ })
+
+ function world(): ScriptWorld & { applied: ScriptIntent[]; ana: ScriptPlayer } {
+ const ana: ScriptPlayer = { id: 'p1', name: 'Ana', pos: [1, 2, 3], yaw: 0 }
+ const applied: ScriptIntent[] = []
+ return {
+ ana,
+ applied,
+ players: () => [ana],
+ player: (id) => (id === ana.id ? ana : null),
+ apply: (_mod, intent) => {
+ applied.push(intent)
+ if (intent.kind === 'giveItem') ana.name = `${ana.name}+${intent.count}${intent.item}`
+ return null
+ },
+ }
+ }
+
+ it('a valid mod changes game state through intents only', async () => {
+ const w = world()
+ const host = new ScriptModHost(
+ [
+ scriptMod(
+ 'gifts',
+ `function onTick(e) {
+ const [p] = game.players();
+ p.name = 'renamed in the sandbox'; // a copy: changes nothing outside
+ p.pos[0] = 999;
+ game.emit({ kind: 'giveItem', playerId: p.id, item: 'scrap', count: 2 });
+ game.log('gave scrap on tick', e.tick);
+ }`,
+ ),
+ ],
+ factory,
+ quiet,
+ )
+ host.bind(w)
+ await host.start()
+ host.tick(7, 1 / 30)
+ expect(w.applied).toEqual([{ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 }])
+ expect(w.ana).toEqual({ id: 'p1', name: 'Ana+2scrap', pos: [1, 2, 3], yaw: 0 })
+ expect(host.status()[0]).toMatchObject({ state: 'running', intentsApplied: 1 })
+ host.dispose()
+ })
+
+ it('a throwing or runaway mod does not stop the tick or the other mods', async () => {
+ const w = world()
+ const host = new ScriptModHost(
+ [
+ scriptMod('thrower', 'function onTick() { throw new TypeError("nope") }'),
+ scriptMod('looper', 'function onTick() { for (;;) {} }', { budgets: { cpuMs: 3 } }),
+ scriptMod(
+ 'bomb',
+ 'function onTick() { const k = []; for (;;) k.push(new Array(65536).fill(0)) }',
+ {
+ budgets: { memoryMb: 4, cpuMs: 10 },
+ },
+ ),
+ scriptMod('steady', 'function onTick() { game.emit({ kind: "announce", text: "ok" }) }'),
+ ],
+ factory,
+ quiet,
+ )
+ host.bind(w)
+ await host.start()
+ expect(() => {
+ host.tick(1, 1 / 30)
+ host.tick(2, 1 / 30)
+ }).not.toThrow()
+ const byId = Object.fromEntries(host.status().map((s) => [s.id, s]))
+ expect(byId.thrower).toMatchObject({ state: 'running', failures: { error: 2 } })
+ expect(byId.looper).toMatchObject({ state: 'disabled', reason: 'cpu' })
+ expect(byId.bomb).toMatchObject({ state: 'disabled', reason: 'memory' })
+ expect(byId.steady).toMatchObject({ state: 'running', intentsApplied: 2 })
+ expect(w.applied).toEqual([
+ { kind: 'announce', text: 'ok' },
+ { kind: 'announce', text: 'ok' },
+ ])
+ host.dispose()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,327 @@
@@ -0,0 +1,327 @@
+/**
+ * The QuickJS implementation of the sandbox adapter (sandbox.ts), on
+ * quickjs-emscripten: QuickJS compiled to WASM, no native build.
+ *
+ * Every mod gets its own WASM module instance and linear memory, runtime and
+ * context, so a mod cannot see another's heap even through an engine bug, and
+ * an engine abort poisons only that mod's instance. A fresh context holds the
+ * language intrinsics only — QuickJS's std/os libraries are not part of the
+ * build — and the host adds exactly one thing, the frozen global `game`.
+ *
+ * Budgets:
+ * - cpuMs: the interrupt handler stops a call at its deadline (QuickJS polls
+ * it every few thousand operations). An interrupt a promise job or async
+ * hook swallows into a rejection is still a breach: the flag is ours.
+ * - memory: QuickJS's own malloc limit cannot count bytes under emscripten
+ * (no malloc_usable_size: it sees single allocations and a per-block
+ * overhead, not the total), so the bound is the WASM memory itself, capped
+ * at the engine's base heap plus `memoryBytes` (HeapCeiling). The malloc
+ * limit stays as a second line against any single allocation that large.
+ * - stack: the runtime's max stack size turns deep recursion into a catchable
+ * stack overflow. A few engine paths recurse in C without that check (a
+ * deeply nested JSON.stringify) and exhaust the host's own stack instead;
+ * that RangeError is caught here, reported as a stack breach, and the
+ * instance is treated as dead.
+ */
+import { performance } from 'node:perf_hooks'
+import {
+ newQuickJSWASMModule,
+ newVariant,
+ RELEASE_SYNC,
+ type QuickJSContext,
+ type QuickJSHandle,
+ type QuickJSRuntime,
+} from 'quickjs-emscripten'
+import type {
+ Json,
+ ModSandbox,
+ ModSandboxFactory,
+ SandboxFailure,
+ SandboxFailureReason,
+ SandboxHostFunctions,
+ SandboxOutcome,
+ SandboxSpec,
+} from './sandbox.js'
+
+/** Must match the exact version pinned in apps/server/package.json. */
+export const QUICKJS_ENGINE = '[email protected]'
+
+/** The linear memory this build declares it starts with; QuickJS's own state lives in it. */
+export const ENGINE_BASE_BYTES = 16 * 1024 * 1024
+const WASM_PAGE = 64 * 1024
+
+/** Longest JSON a host call may pass either way; past it the call throws inside the script. */
+const MAX_HOST_JSON = 64 * 1024
+/** Longest failure message kept from a script (it chooses what it throws). */
+const MAX_MESSAGE = 500
+
+/**
+ * Runs before the mod, with the native bridge as its only argument; the bridge
+ * never becomes a global. It builds the frozen `game` object (one wrapper per
+ * lent function, JSON in and out) and returns the hook dispatcher the host
+ * keeps. Nothing it does at call time looks up a global or a prototype method
+ * the mod could have replaced (`JSON` is captured, arguments are copied by index).
+ */
+const PRELUDE = `(function (bridge, namesJson) {
+ 'use strict';
+ const parse = JSON.parse, stringify = JSON.stringify, freeze = Object.freeze;
+ const game = {};
+ const names = parse(namesJson);
+ for (let i = 0; i < names.length; i++) {
+ const name = names[i];
+ game[name] = function () {
+ const args = [];
+ for (let k = 0; k < arguments.length; k++) args[k] = arguments[k];
+ const out = bridge(name, stringify(args));
+ return out === undefined ? undefined : parse(out);
+ };
+ freeze(game[name]);
+ }
+ freeze(game);
+ Object.defineProperty(globalThis, 'game', { value: game, writable: false, enumerable: false, configurable: false });
+ return function dispatch(hook, payload) {
+ const fn = globalThis[hook];
+ if (typeof fn === 'function') fn(parse(payload));
+ };
+})`
+
+/** The part of WebAssembly.Memory used here; the server's TS lib (ES2022, no DOM) does not declare it. */
+interface WasmMemory {
+ readonly buffer: ArrayBuffer
+ grow(deltaPages: number): number
+}
+const { Memory } = (
+ globalThis as unknown as {
+ WebAssembly: { Memory: new (d: { initial: number; maximum: number }) => WasmMemory }
+ }
+).WebAssembly
+
+/**
+ * A mod's linear memory, capped at ENGINE_BASE_BYTES + `memoryBytes`. Past the
+ * cap the engine's allocator is refused and the allocation fails inside the
+ * script ("out of memory"). `exceeded` stays true from the moment an
+ * allocation needed more than the cap — even if the script caught the error —
+ * because the memory never shrinks and can no longer grow.
+ */
+class HeapCeiling {
+ readonly memory: WasmMemory
+ private refusedAt = -1
+
+ constructor(memoryBytes: number) {
+ const base = ENGINE_BASE_BYTES / WASM_PAGE
+ const memory = new Memory({
+ initial: base,
+ maximum: base + Math.ceil(memoryBytes / WASM_PAGE),
+ })
+ // The allocator asks for a generous step first and smaller ones after a
+ // refusal; only a refusal not followed by a successful grow is a breach.
+ const grow = memory.grow.bind(memory)
+ memory.grow = (delta) => {
+ try {
+ return grow(delta)
+ } catch (err) {
+ this.refusedAt = memory.buffer.byteLength
+ throw err
+ }
+ }
+ this.memory = memory
+ }
+
+ get exceeded(): boolean {
+ return this.refusedAt === this.memory.buffer.byteLength
+ }
+}
+
+function failure(reason: SandboxFailureReason, message: string, fatal = false): SandboxFailure {
+ return { ok: false, reason, message: message.slice(0, MAX_MESSAGE), ...(fatal && { fatal }) }
+}
+
+/** A script exception that is not a breach of ours: the engine's own limits, or a throw. */
+function classify(message: string): SandboxFailure {
+ if (/out of memory/i.test(message)) return failure('memory', message)
+ if (/stack overflow/i.test(message)) return failure('stack', message)
+ return failure('error', message)
+}
+
+/** A thrown script value as one line of text (an Error's name, message and first stack frame). */
+function describe(ctx: QuickJSContext, handle: QuickJSHandle): string {
+ try {
+ const v: unknown = ctx.dump(handle)
+ if (v && typeof v === 'object' && 'message' in v) {
+ const e = v as { name?: unknown; message?: unknown; stack?: unknown }
+ const at = typeof e.stack === 'string' ? e.stack.trim().split('\n')[0] : ''
+ return `${String(e.name ?? 'Error')}: ${String(e.message)}${at ? ` (${at.trim()})` : ''}`
+ }
+ return String(v)
+ } catch {
+ return 'an exception the engine could not describe'
+ } finally {
+ handle.dispose()
+ }
+}
+
+class QuickJsSandbox implements ModSandbox {
+ private deadline = Infinity
+ private cpuMs = 0
+ /** Set by us, never by the script: the interrupt handler stops the call while it is set. */
+ private breach: 'cpu' | 'memory' | null = null
+ /** The engine threw into the host (an abort, the host's stack): nothing in it can be trusted. */
+ private poisoned = false
+ private disposed = false
+ private dispatch: QuickJSHandle | null = null
+
+ constructor(
+ private readonly runtime: QuickJSRuntime,
+ private readonly ctx: QuickJSContext,
+ private readonly heap: HeapCeiling,
+ ) {
+ runtime.setInterruptHandler(() => {
+ if (this.breach === null) {
+ if (heap.exceeded) this.breach = 'memory'
+ else if (performance.now() > this.deadline) this.breach = 'cpu'
+ }
+ return this.breach !== null
+ })
+ }
+
+ /** Installs `game`, then evaluates the mod once. */
+ load(spec: SandboxSpec): SandboxOutcome {
+ const ctx = this.ctx
+ return this.run(spec.loadCpuMs, () => {
+ const bridge = ctx.newFunction('bridge', (nameH, argsH) =>
+ bridgeCall(ctx, spec.host, nameH, argsH),
+ )
+ const names = ctx.newString(JSON.stringify(Object.keys(spec.host)))
+ const prelude = ctx.evalCode(PRELUDE, 'host-prelude.js')
+ if (prelude.error) {
+ bridge.dispose()
+ names.dispose()
+ return prelude.error
+ }
+ const made = ctx.callFunction(prelude.value, ctx.undefined, bridge, names)
+ prelude.value.dispose()
+ bridge.dispose()
+ names.dispose()
+ if (made.error) return made.error
+ this.dispatch = made.value
+ const evaluated = ctx.evalCode(spec.source, spec.filename)
+ if (evaluated.error) return evaluated.error
+ evaluated.value.dispose()
+ return null
+ })
+ }
+
+ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome {
+ if (this.disposed || this.poisoned || !this.dispatch)
+ return failure('error', 'the sandbox is no longer usable', true)
+ const ctx = this.ctx
+ const dispatch = this.dispatch
+ return this.run(cpuMs, () => {
+ const hookH = ctx.newString(hook)
+ const payloadH = ctx.newString(JSON.stringify(payload))
+ const r = ctx.callFunction(dispatch, ctx.undefined, hookH, payloadH)
+ hookH.dispose()
+ payloadH.dispose()
+ if (r.error) return r.error
+ r.value.dispose()
+ return null
+ })
+ }
+
+ /**
+ * Runs `body` (which returns the script's exception handle, or null) and the
+ * promise jobs it queued, one at a time, all before one deadline.
+ */
+ private run(cpuMs: number, body: () => QuickJSHandle | null): SandboxOutcome {
+ this.breach = null
+ this.cpuMs = cpuMs
+ this.deadline = performance.now() + cpuMs
+ try {
+ let thrown = body()
+ while (!thrown && this.breach === null && this.runtime.hasPendingJob()) {
+ if (performance.now() > this.deadline) this.breach = 'cpu'
+ else {
+ const jobs = this.runtime.executePendingJobs(1)
+ if (jobs.error) thrown = jobs.error
+ }
+ }
+ if (this.breach === null && this.heap.exceeded) this.breach = 'memory'
+ const message = thrown ? describe(this.ctx, thrown) : ''
+ if (this.breach === 'cpu') return failure('cpu', `interrupted: over its ${this.cpuMs} cpuMs`)
+ if (this.breach === 'memory')
+ return failure('memory', `heap over its memory budget${message ? `: ${message}` : ''}`)
+ return thrown ? classify(message) : { ok: true }
+ } catch (err) {
+ this.poisoned = true
+ const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
+ if (err instanceof RangeError && /call stack/i.test(message))
+ return failure('stack', `the host's stack ran out: ${message}`, true)
+ return failure(this.breach ?? 'error', `the engine failed: ${message}`, true)
+ } finally {
+ this.deadline = Infinity
+ }
+ }
+
+ dispose(): void {
+ if (this.disposed) return
+ this.disposed = true
+ // A poisoned engine is only dropped: its teardown would trip the engine's
+ // own consistency checks. The WASM instance is this mod's alone.
+ if (this.poisoned) return
+ try {
+ this.dispatch?.dispose()
+ this.ctx.dispose()
+ this.runtime.dispose()
+ } catch {
+ // Same: a runtime stopped mid-allocation can fail its teardown checks.
+ }
+ }
+}
+
+/** The native side of `game.<name>(...)`: JSON in, JSON out, errors as script exceptions. */
+function bridgeCall(
+ ctx: QuickJSContext,
+ host: SandboxHostFunctions,
+ nameH: QuickJSHandle | undefined,
+ argsH: QuickJSHandle | undefined,
+): QuickJSHandle | { error: QuickJSHandle } {
+ try {
+ if (!nameH || !argsH) throw new Error('bad bridge call')
+ const name = ctx.getString(nameH)
+ const raw = ctx.getString(argsH)
+ if (raw.length > MAX_HOST_JSON) throw new Error(`arguments to game.${name} are too large`)
+ const fn = Object.prototype.hasOwnProperty.call(host, name) ? host[name] : undefined
+ if (!fn) throw new Error(`game.${name} is not a function`)
+ const args = JSON.parse(raw) as Json[]
+ const out = fn(...args)
+ if (out === undefined) return ctx.undefined
+ const json = JSON.stringify(out)
+ if (json.length > MAX_HOST_JSON) throw new Error(`game.${name} returned too much`)
+ return ctx.newString(json)
+ } catch (err) {
+ // Only the message crosses: never the host Error object or its stack.
+ return { error: ctx.newError(err instanceof Error ? err.message : String(err)) }
+ }
+}
+
+export function createQuickJsSandboxFactory(): ModSandboxFactory {
+ return {
+ engine: QUICKJS_ENGINE,
+ async create(spec) {
+ const heap = new HeapCeiling(spec.limits.memoryBytes)
+ const module = await newQuickJSWASMModule(
+ newVariant(RELEASE_SYNC, { wasmMemory: heap.memory }),
+ )
+ const runtime = module.newRuntime()
+ runtime.setMemoryLimit(spec.limits.memoryBytes)
+ runtime.setMaxStackSize(spec.limits.stackBytes)
+ const sandbox = new QuickJsSandbox(runtime, runtime.newContext(), heap)
+ const loaded = sandbox.load(spec)
+ if (!loaded.ok) {
+ sandbox.dispose()
+ return loaded
+ }
+ return { ok: true, sandbox }
+ },
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+/**
+ * The script sandbox adapter: the whole contract between the mod host and a
+ * script engine. QuickJS (quickjs.ts) is the first implementation; OpenVibe.Run
+ * can implement the same interface later (ADR-036) and the host does not change.
+ *
+ * The contract, which every implementation must keep:
+ * - one isolated context per mod: no state, prototype or object identity is
+ * shared between mods or with the host;
+ * - only JSON crosses the boundary, in both directions: a host function gets
+ * JSON arguments and returns JSON, a hook gets a JSON payload. No host
+ * object, function or prototype is ever reachable from the script;
+ * - nothing but the lent functions: no require/import, fs, net, process,
+ * timers or clock beyond what the engine's language intrinsics provide;
+ * - the host functions appear in the script as the frozen global `game`;
+ * - limits are enforced by the engine, not by trusting the script: `cpuMs`
+ * per call (wall clock on the single-threaded tick), a heap of
+ * `memoryBytes` (beyond the engine's own fixed base), a stack of
+ * `stackBytes`; a breach the script catches or swallows is still a breach;
+ * - `call` and `dispose` never throw: every failure is a `SandboxFailure`.
+ */
+
+export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
+
+/** Functions lent to a script, named as they appear on `game`. A throw becomes an Error inside the script. */
+export type SandboxHostFunctions = Readonly<Record<string, (...args: Json[]) => Json | undefined>>
+
+export interface SandboxLimits {
+ memoryBytes: number
+ stackBytes: number
+}
+
+/** Why a call (or the load) did not complete. Every reason but `error` is a budget breach. */
+export type SandboxFailureReason = 'cpu' | 'memory' | 'stack' | 'error'
+
+export interface SandboxFailure {
+ ok: false
+ reason: SandboxFailureReason
+ message: string
+ /** The sandbox cannot be used again (the engine itself failed); the host must dispose it. */
+ fatal?: boolean
+}
+
+export type SandboxOutcome = { ok: true } | SandboxFailure
+
+export interface ModSandbox {
+ /**
+ * Calls the script's global function `hook` with `payload`, letting it run
+ * for at most `cpuMs`; pending promise jobs it queued run inside the same
+ * budget. A missing hook is a success.
+ */
+ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome
+ /** Frees the context and everything in it. Idempotent. */
+ dispose(): void
+}
+
+export interface SandboxSpec {
+ /** The mod id, for diagnostics only. */
+ id: string
+ /** File name shown in the script's stack traces. */
+ filename: string
+ source: string
+ limits: SandboxLimits
+ /** CPU budget for evaluating the source once (top-level code). */
+ loadCpuMs: number
+ host: SandboxHostFunctions
+}
+
+export interface ModSandboxFactory {
+ /** Engine name and exact version, for logs and /api/status. */
+ readonly engine: string
+ /** A fresh, isolated context with `host` installed and `source` evaluated once. */
+ create(spec: SandboxSpec): Promise<{ ok: true; sandbox: ModSandbox } | SandboxFailure>
+}
@@ -43,6 +43,10 @@ export class ServerMetrics {
@@ -43,6 +43,10 @@ export class ServerMetrics {
mapZones = 0
mapZones = 0
/** Cumulative map-layer body rebuilds; identical saves must not move it. */
/** Cumulative map-layer body rebuilds; identical saves must not move it. */
mapRebuilds = 0
mapRebuilds = 0
+ /** games-quickjs@1 script mods: running, disabled for this instance, and failed calls (all reasons). */
+ scriptModsRunning = 0
+ scriptModsDisabled = 0
+ scriptModFailures = 0
private emaAlpha = 0.05
private emaAlpha = 0.05
@@ -78,6 +82,9 @@ export class ServerMetrics {
@@ -78,6 +82,9 @@ export class ServerMetrics {
mapTerrains: this.mapTerrains,
mapTerrains: this.mapTerrains,
mapZones: this.mapZones,
mapZones: this.mapZones,
mapRebuilds: this.mapRebuilds,
mapRebuilds: this.mapRebuilds,
+ scriptModsRunning: this.scriptModsRunning,
+ scriptModsDisabled: this.scriptModsDisabled,
+ scriptModFailures: this.scriptModFailures,
memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),
memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),
}
}
}
}
@@ -121,6 +121,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -121,6 +121,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
- **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.
- **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.
- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
+ - **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** `games-quickjs@1` manifests ride in packs and in the def-set digest; `apps/server/src/mods/script` runs each mod in its own `quickjs-emscripten` WASM instance behind the small adapter in `sandbox.ts` (the one OpenVibe.Run implements later); the host API is `game.players/player/emit/log`, changes arrive only as validated intents applied after a completed call; `cpuMs`/`memoryMb`/`stackKb` are enforced by the interrupt handler, a per-mod capped `WebAssembly.Memory` (QuickJS's malloc limit cannot count bytes under emscripten) and the max stack, and a breach disables the mod for the instance. Off unless the place sets `GAMES_SCRIPT_MODS=1`. Open: capability API v2, seeded randomness, hot reload, installing script mods through the mods API.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+# Mods
+
+Games has two mod kinds. Both travel in a content pack (`ContentPackV2`,
+`packages/content/src/pack.ts`) and both are part of the def set, so the
+`hello`/`welcome` digest covers them.
+
+- **`games-content@2`**: declarative data (definitions, a map, announcements,
+ props). Installable through the mods API with per-capability grants; see the
+ README's Mods section.
+- **`games-quickjs@1`**: server script mods, described here (ADR-0007 decision 6,
+ tier T1).
+
+## `games-quickjs@1`
+
+A pack carries script mods in `mods: [...]`. Each entry is a manifest and its
+source together (`packages/content/src/schema/scriptMod.ts`, strict, so an
+unknown field is refused):
+
+| field | rule |
+| --------- | ------------------------------------------------------------------------ |
+| `format` | `"games-quickjs@1"` |
+| `id` | `^[a-z0-9][a-z0-9_-]{0,39}$`, unique across all packs (never overridden) |
+| `version` | `major.minor.patch` |
+| `entry` | the script's file name (`*.js`), shown in stack traces |
+| `source` | the script, at most 64 Ki characters |
+| `hooks` | one or more of `onTick`, `onPlayerJoin`, `onPlayerLeave` |
+| `budgets` | optional `{ cpuMs, memoryMb, stackKb }`, each up to its ceiling |
+
+A pack may carry up to 8 mods. v1 mods are a single script: there is no module
+loader, so `import` and `require` do not exist.
+
+**Pinned by the digest.** Manifests and sources are merged into `ContentDefs.mods`
+and hashed by `contentDigest` with every other definition. A client whose copy of
+a mod differs by one byte (or that lacks it) is refused with `content_mismatch`
+before any player state is created. A def set without mods keeps the digest it
+had before mods existed.
+
+**Where mods come from.** Packs passed to `createContent` (the built-in packs in
+`packages/content/src/packs`). The mods API still refuses executable installs: a
+script mod ships with the code of the place, reviewed like code.
+
+**Off unless the place enables it.** `GAMES_SCRIPT_MODS=1` (or `true`) runs the
+def set's script mods on this instance. Without it the engine is never loaded
+and the server logs `script mods off for this place`; the mods stay in the def
+set either way, so the digest does not depend on the flag.
+
+### Writing a mod
+
+The script runs once at load (top-level code), then the host calls the global
+functions named by `hooks`:
+
+```js
+// greeter.js — hooks: ["onPlayerJoin"]
+function onPlayerJoin(e) {
+ game.emit({ kind: 'announce', text: `Welcome, ${e.player.name}` })
+}
+```
+
+| hook | payload |
+| --------------- | ------------------------------------ |
+| `onTick` | `{ tick, dt }` (dt in seconds) |
+| `onPlayerJoin` | `{ player: { id, name, pos, yaw } }` |
+| `onPlayerLeave` | `{ playerId }` |
+
+A declared hook that the script does not define is skipped.
+
+### Host API
+
+The only thing the host adds to a fresh QuickJS context is the frozen global
+`game`. Everything else is the language's own intrinsics: no `require`,
+`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.
+
+| call | does |
+| --------------------- | ---------------------------------------------------------------- |
+| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
+| `game.player(id)` | one player, or `null` |
+| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
+| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
+
+Intents (at most 16 per call):
+
+| intent | applied by |
+| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `{ kind: 'announce', text }` (1–200 characters) | the same broadcast as server announcements |
+| `{ kind: 'giveItem', playerId, item, count }` (1–100) | the player's inventory (`canFit`, `add`, saved, sent to the client); refused if the player is gone, the item unknown or the inventory full |
+
+Intents are applied after the call returns, and only if it completed: a call
+stopped by a budget or ended by a throw changes nothing. An intent is rebuilt
+from its known fields, so nothing else the script put on it (a `__proto__`
+key included) reaches the host. Only JSON crosses the boundary, in both
+directions.
+
+### Budgets
+
+| budget | default | ceiling | enforced by |
+| ---------- | ------- | ------- | -------------------------------------------------------------- |
+| `cpuMs` | 2 | 10 | the QuickJS interrupt handler (wall clock on the tick thread) |
+| `memoryMb` | 8 | 32 | the mod's WASM memory, capped at the 16 MiB engine base + this |
+| `stackKb` | 128 | 256 | the runtime's max stack size |
+
+`cpuMs` is per tick: every call the mod gets in one tick (its `onTick` and any
+join/leave hooks) shares it, and it caps each call. Promise jobs a call queued
+run inside the same deadline, one job at a time. The top-level code gets
+25 × `cpuMs` once.
+
+`memoryMb` is the heap a mod may hold beyond the engine's own fixed base.
+QuickJS's malloc limit cannot count bytes under emscripten (it has no
+`malloc_usable_size`), so the bound is the linear memory itself: each mod's
+`WebAssembly.Memory` has a maximum, a refused grow fails the allocation inside
+the script, and that refusal is a breach even if the script catches the
+`out of memory`. The malloc limit stays as a second line against one huge
+allocation. `stackKb` stops at 256 because past it the host's own (native)
+stack runs out first; a path that recurses in C without QuickJS's check (a
+deeply nested `JSON.stringify`) that exhausts the host's stack is caught,
+reported as a `stack` breach and the instance dropped.
+
+An interrupt or out-of-memory that the script catches, or that an async hook
+turns into a rejection, still counts: the breach flag is the host's, not the
+script's.
+
+A mod that goes over a budget is stopped mid-call and **disabled for this
+instance**: its sandbox is freed, `script mod disabled` is logged with the
+reason (`cpu`, `memory`, `stack`), and it is counted. Five throws in a row
+disable it the same way (`error`); a single throw is logged and counted. The
+tick goes on either way. A disabled mod comes back only with a restart.
+
+Counters: `scriptModsRunning`, `scriptModsDisabled`, `scriptModFailures` in the
+metrics log line; per-mod state, budgets, calls, failures by reason and intents
+applied/refused under `mods.scripts` in `GET /api/v1/platform`.
+
+### Isolation
+
+Each mod gets its own WASM module instance, runtime and context
+(`apps/server/src/mods/script/quickjs.ts`), so no heap, global or prototype is
+shared between mods or with the host. Escape tests
+(`apps/server/src/mods/script/quickjs.test.ts`) check that no host object or
+Node global is reachable, that prototype pollution inside a sandbox never
+reaches host objects, and that one mod cannot see another's state.
+
+### The adapter
+
+`apps/server/src/mods/script/sandbox.ts` is the whole contract between the host
+and an engine — `ModSandboxFactory.create(spec)` returning a `ModSandbox` with
+`call(hook, payload, cpuMs)` and `dispose()` — so OpenVibe.Run can implement the
+same interface later (ADR-036) without the host changing. QuickJS
+(`quickjs-emscripten`, exact version pinned in `apps/server/package.json`) is the
+first implementation.
+
+### Not yet
+
+Capability API v2 (per-mod grants for script bindings, key/value storage),
+seeded randomness, hot reload, multi-file mods, and installing script mods
+through the mods API.
@@ -7,6 +7,7 @@ export * from './schema/recipe.js'
@@ -7,6 +7,7 @@ export * from './schema/recipe.js'
export * from './schema/skill.js'
export * from './schema/skill.js'
export * from './schema/resourceNode.js'
export * from './schema/resourceNode.js'
export * from './schema/world.js'
export * from './schema/world.js'
+export * from './schema/scriptMod.js'
export * from './surface.js'
export * from './surface.js'
export * from './mapFileV2.js'
export * from './mapFileV2.js'
export * from './mapDiff.js'
export * from './mapDiff.js'
@@ -210,3 +210,55 @@ describe('Scraplandia default pack', () => {
@@ -210,3 +210,55 @@ describe('Scraplandia default pack', () => {
expect(createContent().world.resourceNodes.map((n) => [n.node, n.pos])).toEqual([])
expect(createContent().world.resourceNodes.map((n) => [n.node, n.pos])).toEqual([])
})
})
})
})
+
+describe('games-quickjs@1 mods in a pack', () => {
+ const mod = {
+ format: 'games-quickjs@1',
+ id: 'greeter',
+ version: '1.0.0',
+ entry: 'greeter.js',
+ source:
+ 'function onPlayerJoin(e) { game.emit({ kind: "announce", text: "hi " + e.player.name }) }',
+ hooks: ['onPlayerJoin'],
+ budgets: { cpuMs: 1 },
+ }
+
+ it('validates the manifest and carries the mod into the registry', () => {
+ const content = createContent([{ mods: [mod] }])
+ expect(content.allMods()).toEqual([mod])
+ expect(createContent().allMods()).toEqual([])
+ const bad = mergePackDefs(base, [
+ {
+ mods: [
+ { ...mod, hooks: ['onEverything'] },
+ { ...mod, id: 'b', budgets: { cpuMs: 50 } },
+ ],
+ },
+ {
+ mods: [
+ { ...mod, extra: true },
+ { ...mod, format: 'games-quickjs@2' },
+ ],
+ },
+ ])
+ expect(bad.errors).toHaveLength(4)
+ expect(bad.defs.mods).toBeUndefined()
+ })
+
+ it('refuses a mod id defined twice, even with allowOverride', () => {
+ const r = mergePackDefs(base, [{ mods: [mod] }, { mods: [mod] }], { allowOverride: true })
+ expect(r.errors).toEqual(["pack 1: mod 'greeter' already exists"])
+ })
+
+ it('pins the source and manifest in the def-set digest', () => {
+ const plain = createContent().digest
+ const withMod = createContent([{ mods: [mod] }]).digest
+ const edited = createContent([{ mods: [{ ...mod, source: mod.source + ' ' }] }]).digest
+ const rebudgeted = createContent([{ mods: [{ ...mod, budgets: { cpuMs: 2 } }] }]).digest
+ expect(new Set([plain, withMod, edited, rebudgeted]).size).toBe(4)
+ // A def set without mods keeps the digest it had before mods existed.
+ expect(contentDigest(base)).toBe(
+ contentDigest({ ...base, mods: undefined } as unknown as ContentDefs),
+ )
+ })
+})
@@ -22,6 +22,7 @@ import { ItemDefSchema, type ItemDef } from './schema/item.js'
@@ -22,6 +22,7 @@ import { ItemDefSchema, type ItemDef } from './schema/item.js'
import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { RecipeSchema, type Recipe } from './schema/recipe.js'
import type { ContentDefs } from './registry.js'
import type { ContentDefs } from './registry.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
+import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'
/** The pack format version both sides must agree on. */
/** The pack format version both sides must agree on. */
export const CONTENT_PACK_VERSION = 2
export const CONTENT_PACK_VERSION = 2
@@ -29,6 +30,9 @@ export const CONTENT_PACK_VERSION = 2
@@ -29,6 +30,9 @@ export const CONTENT_PACK_VERSION = 2
/** Most defs of each kind one pack may carry (the manifest schema uses the same bounds). */
/** Most defs of each kind one pack may carry (the manifest schema uses the same bounds). */
export const MAX_PACK_DEFS = { items: 200, recipes: 200, crops: 100, npcs: 100 } as const
export const MAX_PACK_DEFS = { items: 200, recipes: 200, crops: 100, npcs: 100 } as const
+/** Most `games-quickjs@1` script mods one pack may carry. */
+export const MAX_PACK_MODS = 8
+
/** The def sections a pack may carry. */
/** The def sections a pack may carry. */
export type PackDefKind = keyof typeof MAX_PACK_DEFS
export type PackDefKind = keyof typeof MAX_PACK_DEFS
@@ -38,10 +42,13 @@ export interface ContentPackV2 {
@@ -38,10 +42,13 @@ export interface ContentPackV2 {
map?: WorldDef
map?: WorldDef
announcements?: { text: string; everySeconds: number }[]
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
+ /** `games-quickjs@1` server script mods (schema/scriptMod.ts); they are part of the def set. */
+ mods?: unknown[]
}
}
/** What a merge reads from the base: the four def lists a pack extends and the factions npcs may join. */
/** What a merge reads from the base: the four def lists a pack extends and the factions npcs may join. */
-export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'>
+export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'> &
+ Pick<Partial<ContentDefs>, 'mods'>
export interface MergeOptions {
export interface MergeOptions {
/** Let a pack def replace a base or earlier-pack def with the same id (default: refused). */
/** Let a pack def replace a base or earlier-pack def with the same id (default: refused). */
@@ -111,6 +118,7 @@ export function mergePackDefs<B extends PackBase>(
@@ -111,6 +118,7 @@ export function mergePackDefs<B extends PackBase>(
const recipes = [...base.recipes]
const recipes = [...base.recipes]
const crops = [...base.crops]
const crops = [...base.crops]
const npcs = [...base.npcs]
const npcs = [...base.npcs]
+ const mods: ScriptMod[] = [...(base.mods ?? [])]
const errors: string[] = []
const errors: string[] = []
let map = 'world' in base ? (base as B & { world: WorldDef }).world : undefined
let map = 'world' in base ? (base as B & { world: WorldDef }).world : undefined
let mapSeen = false
let mapSeen = false
@@ -136,6 +144,18 @@ export function mergePackDefs<B extends PackBase>(
@@ -136,6 +144,18 @@ export function mergePackDefs<B extends PackBase>(
const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
const v = parseDefs<NpcArchetype>('npcs', NpcArchetypeSchema, p.defs?.npcs)
const v = parseDefs<NpcArchetype>('npcs', NpcArchetypeSchema, p.defs?.npcs)
mine.push(...i.errors, ...r.errors, ...c.errors, ...v.errors)
mine.push(...i.errors, ...r.errors, ...c.errors, ...v.errors)
+ // A mod id is its identity in logs and budgets: never overridden, whatever `allowOverride` says.
+ if (p.mods !== undefined && (!Array.isArray(p.mods) || p.mods.length > MAX_PACK_MODS)) {
+ mine.push(`mods: must be an array of at most ${MAX_PACK_MODS}`)
+ } else {
+ ;(p.mods ?? []).forEach((raw, k) => {
+ const m = ScriptModSchema.safeParse(raw)
+ if (!m.success) mine.push(`mods[${k}]: ${m.error.message}`)
+ else if (mods.some((d) => d.id === m.data.id))
+ mine.push(`mod '${m.data.id}' already exists`)
+ else mods.push(m.data)
+ })
+ }
const ownItems = addDefs('item', items, i.out, allowOverride, mine)
const ownItems = addDefs('item', items, i.out, allowOverride, mine)
const ownRecipes = addDefs('recipe', recipes, r.out, allowOverride, mine)
const ownRecipes = addDefs('recipe', recipes, r.out, allowOverride, mine)
@@ -168,7 +188,19 @@ export function mergePackDefs<B extends PackBase>(
@@ -168,7 +188,19 @@ export function mergePackDefs<B extends PackBase>(
}
}
errors.push(...mine.map((e) => where + e))
errors.push(...mine.map((e) => where + e))
})
})
- return { defs: { ...base, items, recipes, crops, npcs, ...(map ? { world: map } : {}) }, errors }
+ return {
+ defs: {
+ ...base,
+ items,
+ recipes,
+ crops,
+ npcs,
+ ...(map ? { world: map } : {}),
+ // Absent rather than empty, so a def set without mods keeps the digest it always had.
+ ...(mods.length > 0 ? { mods } : {}),
+ },
+ errors,
+ }
}
}
/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
+import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'
import { contentDigest } from './pack.js'
import { contentDigest } from './pack.js'
export interface ContentDefs {
export interface ContentDefs {
@@ -25,6 +26,8 @@ export interface ContentDefs {
@@ -25,6 +26,8 @@ export interface ContentDefs {
markets: MarketDef[]
markets: MarketDef[]
jobs: JobDef[]
jobs: JobDef[]
world: WorldDef
world: WorldDef
+ /** `games-quickjs@1` server script mods; absent when no pack carries one. */
+ mods?: ScriptMod[]
}
}
/**
/**
@@ -45,6 +48,7 @@ export class ContentRegistry {
@@ -45,6 +48,7 @@ export class ContentRegistry {
private readonly factions = new Map<string, FactionDef>()
private readonly factions = new Map<string, FactionDef>()
private readonly markets = new Map<string, MarketDef>()
private readonly markets = new Map<string, MarketDef>()
private readonly jobs = new Map<string, JobDef>()
private readonly jobs = new Map<string, JobDef>()
+ private readonly mods = new Map<string, ScriptMod>()
readonly world: WorldDef
readonly world: WorldDef
constructor(defs: ContentDefs) {
constructor(defs: ContentDefs) {
@@ -260,6 +264,13 @@ export class ContentRegistry {
@@ -260,6 +264,13 @@ export class ContentRegistry {
}
}
}
}
+ for (const raw of defs.mods ?? []) {
+ const parsed = ScriptModSchema.safeParse(raw)
+ if (!parsed.success) errors.push(`mod '${raw.id}': ${parsed.error.message}`)
+ else if (this.mods.has(parsed.data.id)) errors.push(`duplicate mod id '${parsed.data.id}'`)
+ else this.mods.set(parsed.data.id, parsed.data)
+ }
+
if (errors.length > 0) {
if (errors.length > 0) {
throw new Error(`Content validation failed:\n - ${errors.join('\n - ')}`)
throw new Error(`Content validation failed:\n - ${errors.join('\n - ')}`)
}
}
@@ -339,6 +350,11 @@ export class ContentRegistry {
@@ -339,6 +350,11 @@ export class ContentRegistry {
return [...this.factions.values()]
return [...this.factions.values()]
}
}
+ /** The `games-quickjs@1` script mods in the def set (run only where the place enables them). */
+ allMods(): readonly ScriptMod[] {
+ return [...this.mods.values()]
+ }
+
market(id: string): MarketDef | undefined {
market(id: string): MarketDef | undefined {
return this.markets.get(id)
return this.markets.get(id)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+import { z } from 'zod'
+
+/**
+ * `games-quickjs@1`: a server script mod carried by a content pack. The
+ * manifest and the source travel together, so the def-set digest (pack.ts)
+ * pins both: a client whose copy of a mod differs by one byte is refused at
+ * the handshake like any other content difference.
+ *
+ * The server runs it in a QuickJS sandbox (apps/server/src/mods/script) only
+ * when the place enables script mods; see docs/mods.md for the host API.
+ */
+export const SCRIPT_MOD_FORMAT = 'games-quickjs@1'
+
+/** The hooks a mod may declare; the host calls only declared hooks. */
+export const SCRIPT_MOD_HOOKS = ['onTick', 'onPlayerJoin', 'onPlayerLeave'] as const
+export type ScriptModHook = (typeof SCRIPT_MOD_HOOKS)[number]
+
+/**
+ * Budget defaults and ceilings, per mod. `cpuMs` is per tick (every hook call
+ * the mod gets in one tick shares it) and caps any single call; `memoryMb` is
+ * the heap the mod may hold beyond the engine's fixed 16 MiB base; `stackKb`
+ * its maximum stack.
+ */
+export const SCRIPT_MOD_BUDGETS = {
+ cpuMs: { default: 2, max: 10 },
+ memoryMb: { default: 8, max: 32 },
+ // Past ~256 KiB of QuickJS stack the host's own (native) stack runs out first.
+ stackKb: { default: 128, max: 256 },
+} as const
+
+/** Longest source a mod may carry, in UTF-16 code units. */
+export const MAX_SCRIPT_SOURCE = 64 * 1024
+
+const slug = /^[a-z0-9][a-z0-9_-]{0,39}$/
+
+export const ScriptModSchema = z
+ .object({
+ format: z.literal(SCRIPT_MOD_FORMAT),
+ id: z.string().regex(slug),
+ version: z.string().regex(/^\d{1,4}\.\d{1,4}\.\d{1,4}$/),
+ /** The script's file name, used in stack traces; v1 mods are one script (no module loader). */
+ entry: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}\.js$/),
+ source: z.string().min(1).max(MAX_SCRIPT_SOURCE),
+ hooks: z
+ .array(z.enum(SCRIPT_MOD_HOOKS))
+ .min(1)
+ .max(SCRIPT_MOD_HOOKS.length)
+ .refine((h) => new Set(h).size === h.length, 'hooks must be unique'),
+ /** Requested budgets; anything omitted takes the default. */
+ budgets: z
+ .object({
+ cpuMs: z.number().positive().max(SCRIPT_MOD_BUDGETS.cpuMs.max).optional(),
+ memoryMb: z.number().int().min(1).max(SCRIPT_MOD_BUDGETS.memoryMb.max).optional(),
+ stackKb: z.number().int().min(64).max(SCRIPT_MOD_BUDGETS.stackKb.max).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+
+export type ScriptMod = z.infer<typeof ScriptModSchema>
@@ -123,6 +123,9 @@ importers:
@@ -123,6 +123,9 @@ importers:
pg:
pg:
specifier: ^8.23.0
specifier: ^8.23.0
version: 8.23.0
version: 8.23.0
+ quickjs-emscripten:
+ specifier: 0.32.0
+ version: 0.32.0
tsx:
tsx:
specifier: ^4.19.3
specifier: ^4.19.3
version: 4.23.11
version: 4.23.11
@@ -659,6 +662,21 @@ packages:
@@ -659,6 +662,21 @@ packages:
'@iovalkey/[email protected]':
'@iovalkey/[email protected]':
resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==}
resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==}
+ '@jitl/[email protected]':
+ resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==}
+
+ '@jitl/[email protected]':
+ resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==}
+
+ '@jitl/[email protected]':
+ resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==}
+
+ '@jitl/[email protected]':
+ resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==}
+
+ '@jitl/[email protected]':
+ resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==}
+
'@jridgewell/[email protected]':
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -1456,6 +1474,13 @@ packages:
@@ -1456,6 +1474,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
engines: {node: '>=6'}
+ resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==}
+
+ resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==}
+ engines: {node: '>=16.0.0'}
+
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
engines: {node: '>=4'}
@@ -2072,6 +2097,24 @@ snapshots:
@@ -2072,6 +2097,24 @@ snapshots:
'@iovalkey/[email protected]': {}
'@iovalkey/[email protected]': {}
+ '@jitl/[email protected]': {}
+
+ '@jitl/[email protected]':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/[email protected]':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/[email protected]':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/[email protected]':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
'@napi-rs/[email protected]':
'@napi-rs/[email protected]':
@@ -2879,6 +2922,18 @@ snapshots:
@@ -2879,6 +2922,18 @@ snapshots:
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ dependencies:
+ '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0
+ '@jitl/quickjs-wasmfile-debug-sync': 0.32.0
+ '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0
+ '@jitl/quickjs-wasmfile-release-sync': 0.32.0
+ quickjs-emscripten-core: 0.32.0
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/mods/script/host.test.ts'
+// covers: 'apps/server/src/mods/script'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/mods/script/quickjs.test.ts'
+// covers: 'apps/server/src/mods/script'
+// Generated by node test/run.js --index; do not edit.