openvibers/openvibe.games Commits
Scripts in creator worlds: a world's own games-quickjs@1 mods, written at /worlds, checked like an installed pack's within creator limits, run in its rooms' QuickJS host when GAMES_CREATOR_SCRIPTS=1 (off by default) (#160)
Changes
@@@ -249,6 +249,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)| `GAMES_CREATOR_WORLDS_PER_PERSON` | Worlds one account keeps (default 5) || `GAMES_CREATOR_WORLD_IDLE_SECONDS` | An empty creator world stops after this long (default 180) || `GAMES_CREATOR_WORLD_PLAYERS` | Players per creator world (default 16) |++| `GAMES_CREATOR_SCRIPTS` | `1` runs creator worlds' own scripts in their rooms (QuickJS sandbox; off by default) |## Capabilities@@@ -205,6 +205,7 @@ function mineCard(world: World): HTMLElement {if (status === 'published')actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))actions.append(++ button('Scripts', () => void openScripts(world)),button('Rename', (b) => {const title = prompt('New title (up to 60 characters)', world.title)if (title !== null && title.trim() && title !== world.title)@@@ -235,6 +236,155 @@ function mineCard(world: World): HTMLElement {let staff = falselet page = 1++// ── Scripts: a world's own games-quickjs@1 mods (docs/mods.md "Scripts in creator worlds") ──++interface WorldScript {++ format: 'games-quickjs@1'++ id: string++ version: string++ entry: string++ source: string++ /** A multi-file mod made through the API: the editor shows (and saves) its entry file as the script. */++ files?: Record<string, string>++ hooks: string[]++ capabilities?: string[]++}++const HOOKS = ['onTick', 'onPlayerJoin', 'onPlayerLeave']++const CAPABILITIES: [string, string][] = [++ ['players.read', 'See the players'],++ ['player.message', 'Message a player'],++ ['world.emit', 'Announce and give items'],++ ['storage.kv', 'Remember things'],++]++const STARTER = `// Runs in your world's sandbox. game.message(id, text) needs "Message a player".++function onPlayerJoin(e) {++ game.message(e.player.id, 'Welcome, ' + e.player.name + '!')++}++`++++function checkbox(label: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] {++ const box = el('input')++ box.type = 'checkbox'++ box.checked = checked++ const wrap = el('label', { className: 'check' }, box, document.createTextNode(` ${label}`))++ return [wrap, box]++}++++/** The scripts editor for one of your worlds: each script's name, hooks, what it may do and its code, then Save. */++async function openScripts(world: World): Promise<void> {++ let data: { scripts: WorldScript[]; running: boolean; limits: { mods: number } }++ try {++ data = await api(`/api/worlds/${world.id}/scripts`)++ } catch (err) {++ mineStatus.textContent = err instanceof Error ? err.message : 'Scripts could not be loaded.'++ return++ }++ const dialog = el('dialog', { className: 'scripts' })++ const list = el('div', { className: 'script-list' })++ const status = el('p', { className: 'status' })++ const rows: (() => WorldScript)[] = []++ const addRow = (script: WorldScript) => {++ const name = el('input')++ name.value = script.id++ name.maxLength = 40++ name.setAttribute('aria-label', 'Script name')++ const hooks = HOOKS.map((h) => checkbox(h, script.hooks.includes(h)))++ const caps = CAPABILITIES.map(([c, label]) =>++ checkbox(label, (script.capabilities ?? []).includes(c)),++ )++ const code = el('textarea', { className: 'code' })++ code.value = script.source ?? script.files?.[script.entry] ?? ''++ code.spellcheck = false++ code.setAttribute('aria-label', 'Script code')++ const card = el(++ 'fieldset',++ { className: 'script' },++ el('label', { text: 'Name (a-z, 0-9, - and _)' }, name),++ el('div', { className: 'checks' }, ...hooks.map(([w]) => w)),++ el('div', { className: 'checks' }, ...caps.map(([w]) => w)),++ code,++ )++ const read = (): WorldScript => ({++ format: 'games-quickjs@1',++ id: name.value.trim(),++ version: script.version || '1.0.0',++ entry: script.entry || `${name.value.trim() || 'main'}.js`,++ source: code.value,++ hooks: HOOKS.filter((_, i) => hooks[i]?.[1].checked),++ capabilities: CAPABILITIES.filter((_, i) => caps[i]?.[1].checked).map(([c]) => c),++ })++ const remove = button('Remove this script', () => {++ card.remove()++ rows.splice(rows.indexOf(read), 1)++ })++ card.append(remove)++ rows.push(read)++ list.append(card)++ }++ data.scripts.forEach(addRow)++ const add = button('Add a script', () => {++ if (rows.length >= data.limits.mods) {++ status.textContent = `A world takes at most ${data.limits.mods} scripts.`++ return++ }++ addRow({++ format: 'games-quickjs@1',++ id: `script-${rows.length + 1}`,++ version: '1.0.0',++ entry: `script-${rows.length + 1}.js`,++ source: STARTER,++ hooks: ['onPlayerJoin'],++ capabilities: ['players.read', 'player.message'],++ })++ })++ const save = button(++ 'Save scripts',++ (b) =>++ void (async () => {++ b.disabled = true++ status.textContent = 'Saving…'++ try {++ // A script's entry follows its name, so renaming one keeps it loadable.++ const scripts = rows++ .map((read) => read())++ .map((s) => ({ ...s, entry: `${s.id || 'main'}.js` }))++ await api(`/api/worlds/${world.id}/scripts`, {++ method: 'PUT',++ body: JSON.stringify({ scripts }),++ })++ status.textContent = data.running++ ? 'Saved. Anyone in the world now was asked to join again; the new scripts run from the next join.'++ : 'Saved. Scripts are kept with your world and run once this server switches creator scripts on.'++ } catch (err) {++ status.textContent = err instanceof Error ? err.message : 'That did not work.'++ } finally {++ b.disabled = false++ }++ })(),++ 'primary',++ )++ dialog.append(++ el('h2', { text: `Scripts for ${world.title}` }),++ el('p', {++ className: 'status',++ text: data.running++ ? 'Scripts run in your world when someone joins it, in a sandbox, each with only what you tick for it.'++ : 'Scripts are not switched on for creator worlds on this server yet. You can write and save them now.',++ }),++ list,++ el(++ 'div',++ { className: 'row' },++ add,++ save,++ button('Close', () => dialog.close()),++ ),++ status,++ )++ dialog.addEventListener('close', () => dialog.remove())++ document.body.append(dialog)++ dialog.showModal()++}++async function change(b: HTMLButtonElement,id: string,@@@ -294,6 +294,54 @@#more-row {margin-top: 14px;}++ /* The scripts editor (worlds.ts openScripts). */++ dialog.scripts {++ width: min(760px, calc(100vw - 32px));++ max-height: calc(100vh - 48px);++ padding: 20px;++ border: 1px solid var(--border);++ border-radius: 14px;++ background: var(--bg);++ color: var(--text);++ }++ dialog.scripts::backdrop {++ background: rgba(0, 0, 0, 0.55);++ }++ .script-list {++ display: grid;++ gap: 12px;++ margin: 12px 0;++ }++ fieldset.script {++ display: grid;++ gap: 8px;++ margin: 0;++ padding: 12px;++ border: 1px solid var(--border);++ border-radius: 10px;++ }++ .checks {++ display: flex;++ flex-wrap: wrap;++ gap: 6px 14px;++ }++ label.check {++ display: inline-flex;++ align-items: center;++ gap: 6px;++ font-weight: 500;++ }++ label.check input {++ width: auto;++ }++ fieldset.script > button {++ justify-self: start;++ }++ textarea.code {++ min-height: 160px;++ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;++ font-size: 13px;++ }</style><script type="module" src="/src/pages/worlds.ts"></script></head>@@@ -79,6 +79,12 @@ export interface ServerConfig {creatorWorldIdleSeconds: number/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */creatorWorldPlayers: number++ /**++ * GAMES_CREATOR_SCRIPTS=1: run creator worlds' own games-quickjs@1 scripts in their rooms (QuickJS sandbox, the++ * creator limits). Off by default: worlds keep their scripts either way, and with this off a world's def set and++ * digest have none, exactly as before scripts existed.++ */++ creatorScripts: booleanmaxPlayers: numberpersistFlushSeconds: numbermetricsLogSeconds: number@@@ -228,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {creatorWorldsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_WORLDS_PER_PERSON', 5),creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),++ creatorScripts: env.GAMES_CREATOR_SCRIPTS === '1' || env.GAMES_CREATOR_SCRIPTS === 'true',}}@@@ -8,6 +8,7 @@ import {compileMapFileV2,createContent,creatorWorldPack,++ creatorScriptsOf,parseMapFile,SCRAPLANDIA_PACK,setMapOverride,@@@ -86,7 +87,7 @@ import { casualLeaderboard, pruneCasualActions } from './casual/store.js'import { createPortalHome, readPlayTitles } from './net/portalHome.js'import { CASUAL_GAMES, validatePack } from '@openvibe/casual'import { CreatorWorldHost } from './worlds/worldHost.js'--import { createWorldHubStarter } from './worlds/startWorldHub.js'++import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'import { createWorldRoutes } from './worlds/worldRoutes.js'import { createWorldPage } from './worlds/worldPage.js'import { createSteamCatalog } from './steam/steamCatalog.js'@@@ -599,12 +600,27 @@ async function main(): Promise<void> {// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.const creatorWorlds = createCreatorWorldStore(db)-- const creatorContent = new Map<string, ContentRegistry>()-- const contentForWorld = (worldId: string): ContentRegistry => {++ // A world's def set: the creator pack, plus its own scripts when they run here (GAMES_CREATOR_SCRIPTS). The client++ // builds the same set from /api/v1/content?world= (its packs carry the same mods), so the digests agree.++ const creatorContent = new Map<string, Promise<ContentRegistry>>()++ const worldScriptMods = async (worldId: string) => {++ if (!config.creatorScripts) return []++ const stored = await creatorWorlds.scripts(worldId).catch(() => null)++ const checked = creatorScriptsOf(stored ?? [])++ if (checked.issues.length) {++ log.warn('creator world scripts refused at load', { world: worldId, issues: checked.issues.length })++ return []++ }++ return checked.scripts++ }++ const contentForWorld = (worldId: string): Promise<ContentRegistry> => {let built = creatorContent.get(worldId)if (!built) {if (creatorContent.size >= 256) creatorContent.clear()-- built = createContent([], creatorWorldPack(worldId))++ built = worldScriptMods(worldId).then((mods) =>++ createContent(mods.length ? [{ mods }] : [], creatorWorldPack(worldId)),++ )++ built.catch(() => creatorContent.delete(worldId))creatorContent.set(worldId, built)}return built@@@ -616,6 +632,7 @@ async function main(): Promise<void> {metrics,log,contentFor: contentForWorld,++ ...(config.creatorScripts ? { scriptsFor: worldScriptsFactory(scriptLog) } : {}),})const worldHost = new CreatorWorldHost({store: creatorWorlds,@@@ -808,7 +825,13 @@ async function main(): Promise<void> {await within?.(t)})}),++ scriptsChanged: (id) =>++ router.serialize(async () => {++ creatorContent.delete(id)++ await worldHost.scriptsChanged(id)++ }),},++ scriptsRun: config.creatorScripts,resolveUser: (token) =>config.networkAuthUrl? resolveNetworkUser(config.networkAuthUrl, token)@@@ -921,14 +944,23 @@ async function main(): Promise<void> {res.end('{"error":"world_not_found"}')return true}-- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })-- res.end(-- JSON.stringify({-- digest: contentForWorld(requestedWorld).digest,-- game: 'creator',-- world: requestedWorld,-- packs: [],-- }),++ void contentForWorld(requestedWorld).then(++ (worldContent) => {++ const mods = worldContent.allMods()++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })++ res.end(++ JSON.stringify({++ digest: worldContent.digest,++ game: 'creator',++ world: requestedWorld,++ packs: mods.length ? [{ mods }] : [],++ }),++ )++ },++ () => {++ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' })++ res.end('{"error":"content_unavailable"}')++ },)return true}@@@ -1,5 +1,6 @@import {createContent,++ creatorScriptsOf,creatorWorldPack,emptyMapV2,SCRAPLANDIA_PACK,@@@ -24,7 +25,7 @@ import { GameWorld } from '../game/gameWorld.js'import { InstanceHub } from '../game/instanceHub.js'import { PlaceRouter } from '../game/placeRouter.js'import { ServerMetrics } from '../observability/metrics.js'--import { createWorldHubStarter } from './startWorldHub.js'++import { createWorldHubStarter, worldScriptsFactory } from './startWorldHub.js'import { CreatorWorldHost, mayJoin } from './worldHost.js'const log = createConsoleLogger({ app: 'creator-worlds-test' }, 'error')@@@ -219,3 +220,83 @@ it('starts a published creator world on the first join, sandboxes characters, reawait router.shutdown()}})++++it("runs a world's own scripts in its room, each with only what it asked for (GAMES_CREATOR_SCRIPTS)", async () => {++ const db = await openTestDb()++ const rapier = await loadRapier()++ const config = loadConfig({ GAMES_PLACE_ID: 'scraplandia', GAMES_CREATOR_SCRIPTS: '1' })++ expect(config.creatorScripts).toBe(true)++ const worlds = createCreatorWorldStore(db)++ const world = await worlds.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Scripted',++ summary: '',++ map: emptyMapV2(),++ now: 1,++ })++ await worlds.update(world.id, { status: 'published' }, 2)++ // Two scripts as a creator saves them: one greets the player (asked for players.read and player.message), one tries++ // to message without asking (the host gives it no game.message, so its hook fails and nothing reaches the player).++ await worlds.saveScripts(++ world.id,++ [++ {++ format: 'games-quickjs@1',++ id: 'greet',++ version: '1.0.0',++ entry: 'greet.js',++ hooks: ['onPlayerJoin'],++ capabilities: ['players.read', 'player.message'],++ source: `function onPlayerJoin(e) { game.message(e.player.id, 'hi ' + e.player.name + ', welcome to my world') }`,++ },++ {++ format: 'games-quickjs@1',++ id: 'sneaky',++ version: '1.0.0',++ entry: 'sneaky.js',++ hooks: ['onPlayerJoin'],++ source: `function onPlayerJoin(e) { game.message(e.player.id, 'not allowed') }`,++ },++ ],++ 3,++ )++ const { scripts, issues } = creatorScriptsOf(await worlds.scripts(world.id))++ expect(issues).toEqual([])++ const content = createContent([{ mods: scripts }], creatorWorldPack(world.id))++ const start = createWorldHubStarter({++ db,++ rapier,++ config,++ metrics: new ServerMetrics(),++ log,++ contentFor: async () => content,++ scriptsFor: worldScriptsFactory(log),++ })++ const hub = await start(world as CreatorWorld, `world:${world.id}`)++ if (!hub) throw new Error('the world did not start')++ const inbox: ServerMessage[] = []++ const conn: GameConnection = {++ identity: { guestKeyHash: 'e'.repeat(64) },++ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),++ close: () => undefined,++ }++ try {++ hub.onMessage(conn, {++ t: 'hello',++ v: PROTOCOL_VERSION,++ place: `world:${world.id}`,++ contentDigest: content.digest,++ slot: 0,++ name: 'ana',++ appearance: defaultAppearance(),++ })++ await until(() => inbox.some((m) => m.t === 'announce'))++ const said = inbox.filter((m) => m.t === 'announce').map((m) => (m as { text: string }).text)++ expect(said).toContain('hi ana, welcome to my world')++ expect(said).not.toContain('not allowed')++ } finally {++ hub.onDisconnect(conn)++ await hub.shutdown()++ }++}, 30_000)@@@ -1,5 +1,10 @@--import type { ContentRegistry } from '@openvibe/content'--import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'++import type { ContentRegistry, ScriptCapability } from '@openvibe/content'++import {++ InstanceRegistry,++ openPgStore,++ type InstanceLease,++ type PersistenceStore,++} from '@openvibe/persistence'import { createRapierWorld, type RapierModule } from '@openvibe/physics/rapier'import type { Logger } from '@openvibe/shared'import type { Db } from 'openvibe-sdk/db'@@@ -7,6 +12,8 @@ import type { ServerConfig } from '../config.js'import { GameServer } from '../game/gameServer.js'import { GameWorld } from '../game/gameWorld.js'import { InstanceHub } from '../game/instanceHub.js'++import { ScriptModHost } from '../mods/script/host.js'++import { PgScriptStorage } from '../mods/script/storage.js'import type { ServerMetrics } from '../observability/metrics.js'import { sandboxStore } from './sandboxStore.js'import type { StartWorldHub } from './worldHost.js'@@@ -17,8 +24,42 @@ export interface WorldHubDeps {config: ServerConfigmetrics: ServerMetricslog: Logger-- /** The creator pack's def set for a world (the one /api/v1/content?world= reports). */-- contentFor: (worldId: string) => ContentRegistry++ /** The creator pack's def set for a world, with its scripts when they run (the one /api/v1/content?world= reports). */++ contentFor: (worldId: string) => ContentRegistry | Promise<ContentRegistry>++ /**++ * The QuickJS host for one room of a world with scripts (main.ts, GAMES_CREATOR_SCRIPTS), or undefined: no scripts++ * run there. Its storage is the world's own place.++ */++ scriptsFor?: (++ content: ContentRegistry,++ placeId: string,++ slot: number,++ store: PersistenceStore,++ ) => Promise<ScriptModHost | undefined>++}++++/**++ * The QuickJS host for a room of a world with scripts (GAMES_CREATOR_SCRIPTS): each mod has what it asked for (all four++ * capabilities are scoped to the room and the world's own storage); budgets and strikes are the host's own.++ */++export function worldScriptsFactory(log: Logger): NonNullable<WorldHubDeps['scriptsFor']> {++ return async (content, placeId, slot, store) => {++ const { createQuickJsSandboxFactory } = await import('../mods/script/quickjs.js')++ const grants = Object.fromEntries(++ content.allMods().map((mod) => [mod.id, mod.granted ?? ['players.read', 'world.emit']]),++ ) as Record<string, ScriptCapability[]>++ return new ScriptModHost(++ content.allMods(),++ createQuickJsSandboxFactory(),++ log.child({ placeId, slot, creator: true }),++ undefined,++ {++ placeSeed: slot === 0 ? placeId : `${placeId}:${slot}`,++ grants,++ storage: new PgScriptStorage(store.mods, placeId, log),++ },++ )++ }}/**@@@ -29,7 +70,7 @@ export interface WorldHubDeps {export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {const { db, rapier, config, metrics, log } = depsreturn async (world, placeId) => {-- const worldContent = deps.contentFor(world.id)++ const worldContent = await deps.contentFor(world.id)const worldRegistry = new InstanceRegistry(db, placeId)const worldLog = log.child({ system: 'world', place: placeId })const createWorldRoom = async (slot: number, lease: InstanceLease) => {@@@ -46,6 +87,10 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {await roomWorld.seedOrRestore(roomStore)roomWorld.reconcileMapNodes()roomWorld.reconcileMapProps()++ const scripts =++ worldContent.allMods().length > 0++ ? await deps.scriptsFor?.(worldContent, placeId, slot, roomStore)++ : undefinedconst roomGame = new GameServer({...config,@@@ -58,14 +103,17 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {roomStore,metrics,log.child({ system: 'game', place: placeId }),-- {},++ scripts ? { scripts } : {},)await roomGame.load()++ await scripts?.start()return {lease,game: roomGame,world: roomWorld,dispose: async () => {++ await scripts?.flushWrites()++ scripts?.dispose()roomPhysics.dispose()},}@@@ -134,6 +134,18 @@ export class CreatorWorldHost implements DynamicPlaces {await this.stop(id)}++ /**++ * The world's scripts changed: a running world tells its players and stops, so the next join starts it with the new++ * scripts (main.ts drops its cached def set first).++ */++ async scriptsChanged(id: string): Promise<void> {++ const current = this.running.get(id)++ if (!current) return++ for (const room of current.hub.activeRooms)++ room.game.announce("This world's scripts were updated. Join again to play the new version.")++ await this.stop(id)++ }++/** One pass of the idle sweep (main.ts runs it in the router's admission chain every 30 s). */async sweep(): Promise<void> {const now = this.now()@@@ -39,6 +39,7 @@ async function setup(perPerson = 2) {changed: [] as (string | null)[],removed: [] as string[],events: [] as EventInput[],++ scripts: [] as string[],}const handler = createWorldRoutes({store,@@@ -48,6 +49,9 @@ async function setup(perPerson = 2) {changed: async (world: CreatorWorld | null, id: string) => {calls.changed.push(world ? `${id}:${world.status}` : null)},++ scriptsChanged: async (id) => {++ calls.scripts.push(id)++ },remove: async (id, within) => {calls.removed.push(id)await db.tx(async (t) => {@@@ -124,6 +128,55 @@ describe('/api/worlds', () => {expect(parseIfMatch('')).toBeNull()})++ it("keeps a world's scripts: the owner saves them through the content checks, staff read them, a save restarts it", async () => {++ const { call, calls } = await setup(2)++ const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Scripted' })).body.id)++ const hello = {++ format: 'games-quickjs@1',++ id: 'hello',++ version: '1.0.0',++ entry: 'main.js',++ source: 'export function onPlayerJoin(p) { emit("welcome", { id: p.id }) }',++ capabilities: ['players.read', 'world.emit'],++ hooks: ['onPlayerJoin'],++ }++ expect((await call('GET', `/api/worlds/${id}/scripts`)).status).toBe(401)++ expect((await call('GET', `/api/worlds/${id}/scripts`, 'lee')).status).toBe(404)++ const empty = await call('GET', `/api/worlds/${id}/scripts`, 'kim')++ expect(empty.body).toMatchObject({ scripts: [], limits: { mods: 4 }, running: false })++++ // The content package's checks, and the creator limits.++ const bad = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {++ scripts: [{ ...hello, hooks: ['onEverything'] }],++ })++ expect(bad.status).toBe(422)++ expect(bad.body.issues?.some((i) => i.startsWith('scripts[0].hooks'))).toBe(true)++ const twice = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', { scripts: [hello, hello] })++ expect(twice.body.issues).toEqual(['scripts[1].id: hello is used twice'])++ const five = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {++ scripts: [1, 2, 3, 4, 5].map((n) => ({ ...hello, id: `s${n}` })),++ })++ expect(five.body.issues?.[0]).toMatch(/more than the 4 a creator world takes/)++ expect(++ (await call('PUT', `/api/worlds/${id}/scripts`, 'lee', { scripts: [hello] })).status,++ ).toBe(404)++ expect(calls.scripts).toEqual([])++++ // A good save: stored as written (the server's `granted` never comes from the creator), the world restarts.++ const ok = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {++ scripts: [{ ...hello, granted: ['storage.kv', 'player.message'] }],++ })++ expect(ok.status).toBe(200)++ expect(ok.body.scripts).toEqual([hello])++ expect(calls.scripts).toEqual([id])++ expect((await call('GET', `/api/worlds/${id}/scripts`, 'kim')).body.scripts).toEqual([hello])++ expect((await call('GET', `/api/worlds/${id}/scripts`, 'staff')).body.scripts).toEqual([hello])++ // An empty list takes them out.++ expect(++ (await call('PUT', `/api/worlds/${id}/scripts`, 'kim', { scripts: [] })).body.scripts,++ ).toEqual([])++ })++it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {const { call } = await setup(2)const made = await call('POST', '/api/worlds', 'kim', {@@@ -1,8 +1,10 @@import type { IncomingMessage, ServerResponse } from 'node:http'import {CREATOR_MAP_LIMITS,++ CREATOR_SCRIPT_LIMITS,CREATOR_WORLD_ID,creatorMapIssues,++ creatorScriptsOf,emptyMapV2,parseMapFile,type MapFileV2,@@@ -46,6 +48,8 @@ export interface WorldHostView {* that removes it (a staff deletion's moderation event).*/remove(id: string, within?: (t: Tx) => Promise<void>): Promise<void>++ /** The world's scripts changed: a running world says so and stops, and the next join runs the new ones. */++ scriptsChanged(id: string): Promise<void>}export interface WorldRouteOptions {@@@ -64,6 +68,8 @@ export interface WorldRouteOptions {events?: { db: Db; sink: EventSink; siteUrl: string }/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */search?: WorldSearchIndex++ /** Whether worlds' scripts run here (GAMES_CREATOR_SCRIPTS); they are kept either way. */++ scriptsRun?: booleannow?: () => number}@@@ -345,6 +351,41 @@ export function createWorldRoutes(opts: WorldRouteOptions) {return}++ // GET/PUT /api/worlds/:id/scripts: the world's own games-quickjs@1 mods (docs/mods.md "Scripts in creator worlds"),++ // the owner's to change and staff's to read. A save that passes the content package's checks stops a running world++ // (it says so first) so the next join runs the new scripts. `running` says whether this server runs them at all.++ const scriptsMatch = /^\/api\/worlds\/([^/]+)\/scripts$/.exec(path)++ if (scriptsMatch && (method === 'GET' || method === 'PUT')) {++ const user = await signedIn(req)++ const sid = scriptsMatch[1] ?? ''++ if (method === 'GET') {++ const world = CREATOR_WORLD_ID.test(sid) ? await opts.store.get(sid) : null++ if (!world || (world.ownerSubject !== user.subjectId && !isStaff(user)))++ fail(404, 'world_not_found')++ send(res, 200, {++ scripts: (await opts.store.scripts(sid)) ?? [],++ limits: CREATOR_SCRIPT_LIMITS,++ running: opts.scriptsRun === true,++ })++ return++ }++ await owned(sid, user)++ if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return++ const { text } = await readBody(req, CREATOR_SCRIPT_LIMITS.bytes + 16 * 1024)++ const body = parseJson(text) as { scripts?: unknown } | null++ const checked = creatorScriptsOf(body?.scripts)++ if (checked.issues.length)++ fail(422, 'scripts_invalid', { issues: checked.issues.slice(0, 40) })++ // Stored as the creator wrote them; the server sets `granted` when it loads them.++ const stored = checked.scripts.map(({ granted: _granted, ...mod }) => mod)++ const saved = await opts.store.saveScripts(sid, stored, now())++ if (!saved) fail(404, 'world_not_found')++ await opts.host.scriptsChanged(sid)++ opts.log.info('creator world scripts saved', { world: sid, scripts: stored.length })++ send(res, 200, { scripts: stored, running: opts.scriptsRun === true })++ return++ }++const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)if (!match) fail(404, 'not_found')const id = match?.[1] ?? ''@@@ -94,6 +94,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built inand the original counts `remixes` from other people. A world page's **Remix it** link goes through`/worlds?remix=<id>`, which signs you in first if needed and opens the copy in the editor (owner, 2026-10-27: "otherpeople can make games just like the same way you can copy our game").++- **Scripts:** a world's own `games-quickjs@1` mods, written at `/worlds` → **Scripts**, checked like an installed++ pack's within the creator limits, and run in its rooms when `GAMES_CREATOR_SCRIPTS=1` (off by default); see++ `mods.md` "Scripts in creator worlds".- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event@@@ -203,3 +203,24 @@ first implementation.Script storage writes are durable after the write-behind queue drains; adatabase failure is logged and retried twice.++++## Scripts in creator worlds++++A creator world can carry its own `games-quickjs@1` mods, written by its owner (owner, 2026-10-27: an open, moddable++game engine like Garry's Mod, s&box or Roblox).++++- **Writing them:** `/worlds` → your world → **Scripts**. Each script has a name, its hooks, what it may do (the four++ capabilities: see the players, message a player, announce and give items, remember things) and its code. The API++ is `GET`/`PUT /api/worlds/:id/scripts` (`{ scripts: [...] }`); the owner saves, staff can read.++- **Checks:** `creatorScriptsOf` (`packages/content/src/packs/creatorWorld.ts`) runs each through the same++ `ScriptModSchema` as an installed pack (hooks, capabilities, budgets under their ceilings, imports inside the mod),++ and the creator limits: at most `CREATOR_SCRIPT_LIMITS.mods` (4) scripts and 128 KiB together. `granted` is never++ the creator's: the server sets it to what each script asked for. Scripts are stored as written (migration 0014,++ `creator_worlds.scripts`) and checked again when they load.++- **Running:** only with `GAMES_CREATOR_SCRIPTS=1` (off by default). Then a world's def set is the creator pack plus a++ pack of its scripts, `/api/v1/content?world=` sends that pack so the client builds the same digest, and each room runs++ them in its own QuickJS host (`worldScriptsFactory`, `apps/server/src/worlds/startWorldHub.ts`) with the world's own++ script storage. Everything a script does goes through the gameplay modules, as in any place, and a creator world is++ still a sandbox: nothing reaches characters, Scraplandia or Inventory.++- **Saving while it runs:** a save tells the players in the world that its scripts changed and stops it; the next join++ starts it with the new scripts. With the flag off, worlds keep their scripts and their def set has none.@@@ -3,7 +3,9 @@ import { createContent } from '../index.js'import { emptyMapV2, type MapFileV2 } from '../mapFileV2.js'import {CREATOR_MAP_LIMITS,++ CREATOR_SCRIPT_LIMITS,creatorMapIssues,++ creatorScriptsOf,creatorPlaceId,creatorWorldOf,creatorWorldPack,@@@ -65,4 +67,42 @@ describe('creator worlds', () => {} as unknown as MapFileV2expect(creatorMapIssues(painted, 10).some((i) => i.startsWith('statics:'))).toBe(true)})++++ it("checks a world's scripts like an installed pack's, within the creator limits, and grants what each asks", () => {++ const mod = {++ format: 'games-quickjs@1',++ id: 'hello',++ version: '1.0.0',++ entry: 'main.js',++ source: 'export function onTick() {}',++ hooks: ['onTick'],++ }++ expect(creatorScriptsOf([])).toEqual({ scripts: [], issues: [] })++ expect(creatorScriptsOf('nope').issues).toEqual(['scripts: a list of games-quickjs@1 mods'])++ const one = creatorScriptsOf([++ { ...mod, capabilities: ['storage.kv'], granted: ['player.message'] },++ ])++ expect(one.issues).toEqual([])++ expect(one.scripts[0]?.granted).toEqual(['storage.kv'])++ expect(creatorScriptsOf([mod]).scripts[0]?.granted).toEqual(['players.read', 'world.emit'])++ expect(creatorScriptsOf([{ ...mod, budgets: { cpuMs: 9999 } }]).issues[0]).toMatch(++ /^scripts\[0\]\.budgets\.cpuMs/,++ )++ expect(creatorScriptsOf([{ ...mod, extra: 1 }]).issues.length).toBeGreaterThan(0)++ expect(++ creatorScriptsOf([++ { ...mod, source: undefined, files: { 'main.js': "import x from 'fs'" } },++ ]).issues.some((i) => i.includes('fs')),++ ).toBe(true)++ const many = Array.from({ length: CREATOR_SCRIPT_LIMITS.mods + 1 }, (_, i) => ({++ ...mod,++ id: `m${i}`,++ }))++ expect(creatorScriptsOf(many).issues[0]).toMatch(/more than the 4/)++ expect(creatorScriptsOf(many).scripts).toEqual([])++ // A world with scripts is a def set with them, on both sides.++ const content = createContent([{ mods: one.scripts }], creatorWorldPack('wk3j9x0a2bq'))++ expect(content.allMods().map((m) => m.id)).toEqual(['hello'])++ expect(content.digest).not.toBe(createContent([], creatorWorldPack('wk3j9x0a2bq')).digest)++ })})@@@ -1,5 +1,6 @@import type { ContentPackV2 } from '../pack.js'import type { MapFileV2 } from '../mapFileV2.js'++import { ScriptModSchema, type ScriptMod } from '../schema/scriptMod.js'/*** Creator worlds: a place a signed-in player builds in /editor and publishes at /worlds. The server and@@@ -90,3 +91,53 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {issues.push('statics: painted surfaces are not available in creator worlds yet')return issues}++++/**++ * What a creator world's scripts may be: its own `games-quickjs@1` mods (docs/mods.md), at most four, together at++ * most 128 KiB of source. Each passes the content package's script schema, so a creator gets exactly the checks an++ * installed pack gets (hooks, capabilities, budgets under their ceilings, module imports inside the mod). The server++ * grants what each asks for: the capabilities are all scoped to the world's own room and storage.++ */++export const CREATOR_SCRIPT_LIMITS = { mods: 4, bytes: 128 * 1024 } as const++++/** A world's scripts, validated: the mods to run, or the reasons they cannot (an empty list is fine: no scripts). */++export function creatorScriptsOf(raw: unknown): { scripts: ScriptMod[]; issues: string[] } {++ if (!Array.isArray(raw))++ return { scripts: [], issues: ['scripts: a list of games-quickjs@1 mods'] }++ const issues: string[] = []++ if (raw.length > CREATOR_SCRIPT_LIMITS.mods)++ issues.push(++ `scripts: ${raw.length} is more than the ${CREATOR_SCRIPT_LIMITS.mods} a creator world takes`,++ )++ const bytes = new TextEncoder().encode(JSON.stringify(raw)).length++ if (bytes > CREATOR_SCRIPT_LIMITS.bytes)++ issues.push(++ `scripts: ${bytes} bytes is more than the ${CREATOR_SCRIPT_LIMITS.bytes} a creator world takes`,++ )++ const scripts: ScriptMod[] = []++ const ids = new Set<string>()++ raw.slice(0, CREATOR_SCRIPT_LIMITS.mods).forEach((item, i) => {++ // `granted` is the server's to set, never the creator's.++ const { granted: _granted, ...asked } = (++ item && typeof item === 'object' ? item : {}++ ) as Record<string, unknown>++ const parsed = ScriptModSchema.safeParse(asked)++ if (!parsed.success) {++ for (const issue of parsed.error.issues)++ issues.push(++ `scripts[${i}]${issue.path.length ? `.${issue.path.join('.')}` : ''}: ${issue.message}`,++ )++ return++ }++ if (ids.has(parsed.data.id)) {++ issues.push(`scripts[${i}].id: ${parsed.data.id} is used twice`)++ return++ }++ ids.add(parsed.data.id)++ scripts.push({++ ...parsed.data,++ granted: parsed.data.capabilities ?? ['players.read', 'world.emit'],++ })++ })++ return { scripts: issues.length ? [] : scripts, issues }++}nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Scripts in a creator world (owner, 2026-10-27: "an open, moddable … game engine like Garry's mod, s&box or++-- Roblox"): the world's own games-quickjs@1 mods, as the content package validates them, run in its room's QuickJS++-- sandbox with the creator limits (docs/mods.md "Scripts in creator worlds"). An empty list for every world so far.++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS scripts jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -106,6 +106,10 @@ export interface CreatorWorldStore {get(id: string): Promise<CreatorWorld | null>/** The stored map document and its revision. */map(id: string): Promise<{ map: unknown; revision: string } | null>++ /** The world's scripts as stored (validated before they were saved; migration 0014). Null when the world is gone. */++ scripts(id: string): Promise<unknown[] | null>++ /** Replaces the world's scripts (callers validate them first). Answers the world, or null when it is gone. */++ saveScripts(id: string, scripts: unknown[], now: number): Promise<CreatorWorld | null>countByOwner(ownerSubject: string): Promise<number>listByOwner(ownerSubject: string): Promise<CreatorWorld[]>/** Published worlds, newest first. */@@@ -159,6 +163,20 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {)return row ? { map: row.map, revision: row.revision } : null},++ async scripts(id) {++ const row = await db.maybe<{ scripts: unknown }>(++ sql`SELECT scripts FROM creator_worlds WHERE id = ${id}`,++ )++ if (!row) return null++ return Array.isArray(row.scripts) ? row.scripts : []++ },++ async saveScripts(id, scripts, now) {++ const row = await db.maybe<Row>(sql`++ UPDATE creator_worlds SET scripts = ${sql.json(scripts)}, updated_at = ${now}++ WHERE id = ${id} RETURNING ${COLUMNS}++ `)++ return row ? fromRow(row) : null++ },async countByOwner(ownerSubject) {const row = await db.one<{ n: number | string }>(sql`SELECT count(*)::int AS n FROM creator_worlds WHERE owner_subject = ${ownerSubject}`,
@@ -249,6 +249,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)
@@ -249,6 +249,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)
| `GAMES_CREATOR_WORLDS_PER_PERSON` | Worlds one account keeps (default 5) |
| `GAMES_CREATOR_WORLDS_PER_PERSON` | Worlds one account keeps (default 5) |
| `GAMES_CREATOR_WORLD_IDLE_SECONDS` | An empty creator world stops after this long (default 180) |
| `GAMES_CREATOR_WORLD_IDLE_SECONDS` | An empty creator world stops after this long (default 180) |
| `GAMES_CREATOR_WORLD_PLAYERS` | Players per creator world (default 16) |
| `GAMES_CREATOR_WORLD_PLAYERS` | Players per creator world (default 16) |
+| `GAMES_CREATOR_SCRIPTS` | `1` runs creator worlds' own scripts in their rooms (QuickJS sandbox; off by default) |
## Capabilities
## Capabilities
@@ -205,6 +205,7 @@ function mineCard(world: World): HTMLElement {
@@ -205,6 +205,7 @@ function mineCard(world: World): HTMLElement {
if (status === 'published')
if (status === 'published')
actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
actions.append(
actions.append(
+ button('Scripts', () => void openScripts(world)),
button('Rename', (b) => {
button('Rename', (b) => {
const title = prompt('New title (up to 60 characters)', world.title)
const title = prompt('New title (up to 60 characters)', world.title)
if (title !== null && title.trim() && title !== world.title)
if (title !== null && title.trim() && title !== world.title)
@@ -235,6 +236,155 @@ function mineCard(world: World): HTMLElement {
@@ -235,6 +236,155 @@ function mineCard(world: World): HTMLElement {
let staff = false
let staff = false
let page = 1
let page = 1
+// ── Scripts: a world's own games-quickjs@1 mods (docs/mods.md "Scripts in creator worlds") ──
+interface WorldScript {
+ format: 'games-quickjs@1'
+ id: string
+ version: string
+ entry: string
+ source: string
+ /** A multi-file mod made through the API: the editor shows (and saves) its entry file as the script. */
+ files?: Record<string, string>
+ hooks: string[]
+ capabilities?: string[]
+}
+const HOOKS = ['onTick', 'onPlayerJoin', 'onPlayerLeave']
+const CAPABILITIES: [string, string][] = [
+ ['players.read', 'See the players'],
+ ['player.message', 'Message a player'],
+ ['world.emit', 'Announce and give items'],
+ ['storage.kv', 'Remember things'],
+]
+const STARTER = `// Runs in your world's sandbox. game.message(id, text) needs "Message a player".
+function onPlayerJoin(e) {
+ game.message(e.player.id, 'Welcome, ' + e.player.name + '!')
+}
+`
+
+function checkbox(label: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] {
+ const box = el('input')
+ box.type = 'checkbox'
+ box.checked = checked
+ const wrap = el('label', { className: 'check' }, box, document.createTextNode(` ${label}`))
+ return [wrap, box]
+}
+
+/** The scripts editor for one of your worlds: each script's name, hooks, what it may do and its code, then Save. */
+async function openScripts(world: World): Promise<void> {
+ let data: { scripts: WorldScript[]; running: boolean; limits: { mods: number } }
+ try {
+ data = await api(`/api/worlds/${world.id}/scripts`)
+ } catch (err) {
+ mineStatus.textContent = err instanceof Error ? err.message : 'Scripts could not be loaded.'
+ return
+ }
+ const dialog = el('dialog', { className: 'scripts' })
+ const list = el('div', { className: 'script-list' })
+ const status = el('p', { className: 'status' })
+ const rows: (() => WorldScript)[] = []
+ const addRow = (script: WorldScript) => {
+ const name = el('input')
+ name.value = script.id
+ name.maxLength = 40
+ name.setAttribute('aria-label', 'Script name')
+ const hooks = HOOKS.map((h) => checkbox(h, script.hooks.includes(h)))
+ const caps = CAPABILITIES.map(([c, label]) =>
+ checkbox(label, (script.capabilities ?? []).includes(c)),
+ )
+ const code = el('textarea', { className: 'code' })
+ code.value = script.source ?? script.files?.[script.entry] ?? ''
+ code.spellcheck = false
+ code.setAttribute('aria-label', 'Script code')
+ const card = el(
+ 'fieldset',
+ { className: 'script' },
+ el('label', { text: 'Name (a-z, 0-9, - and _)' }, name),
+ el('div', { className: 'checks' }, ...hooks.map(([w]) => w)),
+ el('div', { className: 'checks' }, ...caps.map(([w]) => w)),
+ code,
+ )
+ const read = (): WorldScript => ({
+ format: 'games-quickjs@1',
+ id: name.value.trim(),
+ version: script.version || '1.0.0',
+ entry: script.entry || `${name.value.trim() || 'main'}.js`,
+ source: code.value,
+ hooks: HOOKS.filter((_, i) => hooks[i]?.[1].checked),
+ capabilities: CAPABILITIES.filter((_, i) => caps[i]?.[1].checked).map(([c]) => c),
+ })
+ const remove = button('Remove this script', () => {
+ card.remove()
+ rows.splice(rows.indexOf(read), 1)
+ })
+ card.append(remove)
+ rows.push(read)
+ list.append(card)
+ }
+ data.scripts.forEach(addRow)
+ const add = button('Add a script', () => {
+ if (rows.length >= data.limits.mods) {
+ status.textContent = `A world takes at most ${data.limits.mods} scripts.`
+ return
+ }
+ addRow({
+ format: 'games-quickjs@1',
+ id: `script-${rows.length + 1}`,
+ version: '1.0.0',
+ entry: `script-${rows.length + 1}.js`,
+ source: STARTER,
+ hooks: ['onPlayerJoin'],
+ capabilities: ['players.read', 'player.message'],
+ })
+ })
+ const save = button(
+ 'Save scripts',
+ (b) =>
+ void (async () => {
+ b.disabled = true
+ status.textContent = 'Saving…'
+ try {
+ // A script's entry follows its name, so renaming one keeps it loadable.
+ const scripts = rows
+ .map((read) => read())
+ .map((s) => ({ ...s, entry: `${s.id || 'main'}.js` }))
+ await api(`/api/worlds/${world.id}/scripts`, {
+ method: 'PUT',
+ body: JSON.stringify({ scripts }),
+ })
+ status.textContent = data.running
+ ? 'Saved. Anyone in the world now was asked to join again; the new scripts run from the next join.'
+ : 'Saved. Scripts are kept with your world and run once this server switches creator scripts on.'
+ } catch (err) {
+ status.textContent = err instanceof Error ? err.message : 'That did not work.'
+ } finally {
+ b.disabled = false
+ }
+ })(),
+ 'primary',
+ )
+ dialog.append(
+ el('h2', { text: `Scripts for ${world.title}` }),
+ el('p', {
+ className: 'status',
+ text: data.running
+ ? 'Scripts run in your world when someone joins it, in a sandbox, each with only what you tick for it.'
+ : 'Scripts are not switched on for creator worlds on this server yet. You can write and save them now.',
+ }),
+ list,
+ el(
+ 'div',
+ { className: 'row' },
+ add,
+ save,
+ button('Close', () => dialog.close()),
+ ),
+ status,
+ )
+ dialog.addEventListener('close', () => dialog.remove())
+ document.body.append(dialog)
+ dialog.showModal()
+}
+
async function change(
async function change(
b: HTMLButtonElement,
b: HTMLButtonElement,
id: string,
id: string,
@@ -294,6 +294,54 @@
@@ -294,6 +294,54 @@
#more-row {
#more-row {
margin-top: 14px;
margin-top: 14px;
}
}
+ /* The scripts editor (worlds.ts openScripts). */
+ dialog.scripts {
+ width: min(760px, calc(100vw - 32px));
+ max-height: calc(100vh - 48px);
+ padding: 20px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--bg);
+ color: var(--text);
+ }
+ dialog.scripts::backdrop {
+ background: rgba(0, 0, 0, 0.55);
+ }
+ .script-list {
+ display: grid;
+ gap: 12px;
+ margin: 12px 0;
+ }
+ fieldset.script {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ }
+ .checks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 14px;
+ }
+ label.check {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 500;
+ }
+ label.check input {
+ width: auto;
+ }
+ fieldset.script > button {
+ justify-self: start;
+ }
+ textarea.code {
+ min-height: 160px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px;
+ }
</style>
</style>
<script type="module" src="/src/pages/worlds.ts"></script>
<script type="module" src="/src/pages/worlds.ts"></script>
</head>
</head>
@@ -79,6 +79,12 @@ export interface ServerConfig {
@@ -79,6 +79,12 @@ export interface ServerConfig {
creatorWorldIdleSeconds: number
creatorWorldIdleSeconds: number
/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */
/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */
creatorWorldPlayers: number
creatorWorldPlayers: number
+ /**
+ * GAMES_CREATOR_SCRIPTS=1: run creator worlds' own games-quickjs@1 scripts in their rooms (QuickJS sandbox, the
+ * creator limits). Off by default: worlds keep their scripts either way, and with this off a world's def set and
+ * digest have none, exactly as before scripts existed.
+ */
+ creatorScripts: boolean
maxPlayers: number
maxPlayers: number
persistFlushSeconds: number
persistFlushSeconds: number
metricsLogSeconds: number
metricsLogSeconds: number
@@ -228,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -228,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
creatorWorldsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_WORLDS_PER_PERSON', 5),
creatorWorldsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_WORLDS_PER_PERSON', 5),
creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),
creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),
creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),
creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),
+ creatorScripts: env.GAMES_CREATOR_SCRIPTS === '1' || env.GAMES_CREATOR_SCRIPTS === 'true',
}
}
}
}
@@ -8,6 +8,7 @@ import {
@@ -8,6 +8,7 @@ import {
compileMapFileV2,
compileMapFileV2,
createContent,
createContent,
creatorWorldPack,
creatorWorldPack,
+ creatorScriptsOf,
parseMapFile,
parseMapFile,
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
setMapOverride,
setMapOverride,
@@ -86,7 +87,7 @@ import { casualLeaderboard, pruneCasualActions } from './casual/store.js'
@@ -86,7 +87,7 @@ import { casualLeaderboard, pruneCasualActions } from './casual/store.js'
import { createPortalHome, readPlayTitles } from './net/portalHome.js'
import { createPortalHome, readPlayTitles } from './net/portalHome.js'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { CreatorWorldHost } from './worlds/worldHost.js'
-import { createWorldHubStarter } from './worlds/startWorldHub.js'
+import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
@@ -599,12 +600,27 @@ async function main(): Promise<void> {
@@ -599,12 +600,27 @@ async function main(): Promise<void> {
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.
// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.
const creatorWorlds = createCreatorWorldStore(db)
const creatorWorlds = createCreatorWorldStore(db)
- const creatorContent = new Map<string, ContentRegistry>()
- const contentForWorld = (worldId: string): ContentRegistry => {
+ // A world's def set: the creator pack, plus its own scripts when they run here (GAMES_CREATOR_SCRIPTS). The client
+ // builds the same set from /api/v1/content?world= (its packs carry the same mods), so the digests agree.
+ const creatorContent = new Map<string, Promise<ContentRegistry>>()
+ const worldScriptMods = async (worldId: string) => {
+ if (!config.creatorScripts) return []
+ const stored = await creatorWorlds.scripts(worldId).catch(() => null)
+ const checked = creatorScriptsOf(stored ?? [])
+ if (checked.issues.length) {
+ log.warn('creator world scripts refused at load', { world: worldId, issues: checked.issues.length })
+ return []
+ }
+ return checked.scripts
+ }
+ const contentForWorld = (worldId: string): Promise<ContentRegistry> => {
let built = creatorContent.get(worldId)
let built = creatorContent.get(worldId)
if (!built) {
if (!built) {
if (creatorContent.size >= 256) creatorContent.clear()
if (creatorContent.size >= 256) creatorContent.clear()
- built = createContent([], creatorWorldPack(worldId))
+ built = worldScriptMods(worldId).then((mods) =>
+ createContent(mods.length ? [{ mods }] : [], creatorWorldPack(worldId)),
+ )
+ built.catch(() => creatorContent.delete(worldId))
creatorContent.set(worldId, built)
creatorContent.set(worldId, built)
}
}
return built
return built
@@ -616,6 +632,7 @@ async function main(): Promise<void> {
@@ -616,6 +632,7 @@ async function main(): Promise<void> {
metrics,
metrics,
log,
log,
contentFor: contentForWorld,
contentFor: contentForWorld,
+ ...(config.creatorScripts ? { scriptsFor: worldScriptsFactory(scriptLog) } : {}),
})
})
const worldHost = new CreatorWorldHost({
const worldHost = new CreatorWorldHost({
store: creatorWorlds,
store: creatorWorlds,
@@ -808,7 +825,13 @@ async function main(): Promise<void> {
@@ -808,7 +825,13 @@ async function main(): Promise<void> {
await within?.(t)
await within?.(t)
})
})
}),
}),
+ scriptsChanged: (id) =>
+ router.serialize(async () => {
+ creatorContent.delete(id)
+ await worldHost.scriptsChanged(id)
+ }),
},
},
+ scriptsRun: config.creatorScripts,
resolveUser: (token) =>
resolveUser: (token) =>
config.networkAuthUrl
config.networkAuthUrl
? resolveNetworkUser(config.networkAuthUrl, token)
? resolveNetworkUser(config.networkAuthUrl, token)
@@ -921,14 +944,23 @@ async function main(): Promise<void> {
@@ -921,14 +944,23 @@ async function main(): Promise<void> {
res.end('{"error":"world_not_found"}')
res.end('{"error":"world_not_found"}')
return true
return true
}
}
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
- res.end(
- JSON.stringify({
- digest: contentForWorld(requestedWorld).digest,
- game: 'creator',
- world: requestedWorld,
- packs: [],
- }),
+ void contentForWorld(requestedWorld).then(
+ (worldContent) => {
+ const mods = worldContent.allMods()
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
+ res.end(
+ JSON.stringify({
+ digest: worldContent.digest,
+ game: 'creator',
+ world: requestedWorld,
+ packs: mods.length ? [{ mods }] : [],
+ }),
+ )
+ },
+ () => {
+ res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' })
+ res.end('{"error":"content_unavailable"}')
+ },
)
)
return true
return true
}
}
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
import {
import {
createContent,
createContent,
+ creatorScriptsOf,
creatorWorldPack,
creatorWorldPack,
emptyMapV2,
emptyMapV2,
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
@@ -24,7 +25,7 @@ import { GameWorld } from '../game/gameWorld.js'
@@ -24,7 +25,7 @@ import { GameWorld } from '../game/gameWorld.js'
import { InstanceHub } from '../game/instanceHub.js'
import { InstanceHub } from '../game/instanceHub.js'
import { PlaceRouter } from '../game/placeRouter.js'
import { PlaceRouter } from '../game/placeRouter.js'
import { ServerMetrics } from '../observability/metrics.js'
import { ServerMetrics } from '../observability/metrics.js'
-import { createWorldHubStarter } from './startWorldHub.js'
+import { createWorldHubStarter, worldScriptsFactory } from './startWorldHub.js'
import { CreatorWorldHost, mayJoin } from './worldHost.js'
import { CreatorWorldHost, mayJoin } from './worldHost.js'
const log = createConsoleLogger({ app: 'creator-worlds-test' }, 'error')
const log = createConsoleLogger({ app: 'creator-worlds-test' }, 'error')
@@ -219,3 +220,83 @@ it('starts a published creator world on the first join, sandboxes characters, re
@@ -219,3 +220,83 @@ it('starts a published creator world on the first join, sandboxes characters, re
await router.shutdown()
await router.shutdown()
}
}
})
})
+
+it("runs a world's own scripts in its room, each with only what it asked for (GAMES_CREATOR_SCRIPTS)", async () => {
+ const db = await openTestDb()
+ const rapier = await loadRapier()
+ const config = loadConfig({ GAMES_PLACE_ID: 'scraplandia', GAMES_CREATOR_SCRIPTS: '1' })
+ expect(config.creatorScripts).toBe(true)
+ const worlds = createCreatorWorldStore(db)
+ const world = await worlds.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Scripted',
+ summary: '',
+ map: emptyMapV2(),
+ now: 1,
+ })
+ await worlds.update(world.id, { status: 'published' }, 2)
+ // Two scripts as a creator saves them: one greets the player (asked for players.read and player.message), one tries
+ // to message without asking (the host gives it no game.message, so its hook fails and nothing reaches the player).
+ await worlds.saveScripts(
+ world.id,
+ [
+ {
+ format: 'games-quickjs@1',
+ id: 'greet',
+ version: '1.0.0',
+ entry: 'greet.js',
+ hooks: ['onPlayerJoin'],
+ capabilities: ['players.read', 'player.message'],
+ source: `function onPlayerJoin(e) { game.message(e.player.id, 'hi ' + e.player.name + ', welcome to my world') }`,
+ },
+ {
+ format: 'games-quickjs@1',
+ id: 'sneaky',
+ version: '1.0.0',
+ entry: 'sneaky.js',
+ hooks: ['onPlayerJoin'],
+ source: `function onPlayerJoin(e) { game.message(e.player.id, 'not allowed') }`,
+ },
+ ],
+ 3,
+ )
+ const { scripts, issues } = creatorScriptsOf(await worlds.scripts(world.id))
+ expect(issues).toEqual([])
+ const content = createContent([{ mods: scripts }], creatorWorldPack(world.id))
+ const start = createWorldHubStarter({
+ db,
+ rapier,
+ config,
+ metrics: new ServerMetrics(),
+ log,
+ contentFor: async () => content,
+ scriptsFor: worldScriptsFactory(log),
+ })
+ const hub = await start(world as CreatorWorld, `world:${world.id}`)
+ if (!hub) throw new Error('the world did not start')
+ const inbox: ServerMessage[] = []
+ const conn: GameConnection = {
+ identity: { guestKeyHash: 'e'.repeat(64) },
+ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),
+ close: () => undefined,
+ }
+ try {
+ hub.onMessage(conn, {
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ place: `world:${world.id}`,
+ contentDigest: content.digest,
+ slot: 0,
+ name: 'ana',
+ appearance: defaultAppearance(),
+ })
+ await until(() => inbox.some((m) => m.t === 'announce'))
+ const said = inbox.filter((m) => m.t === 'announce').map((m) => (m as { text: string }).text)
+ expect(said).toContain('hi ana, welcome to my world')
+ expect(said).not.toContain('not allowed')
+ } finally {
+ hub.onDisconnect(conn)
+ await hub.shutdown()
+ }
+}, 30_000)
@@ -1,5 +1,10 @@
@@ -1,5 +1,10 @@
-import type { ContentRegistry } from '@openvibe/content'
-import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
+import type { ContentRegistry, ScriptCapability } from '@openvibe/content'
+import {
+ InstanceRegistry,
+ openPgStore,
+ type InstanceLease,
+ type PersistenceStore,
+} from '@openvibe/persistence'
import { createRapierWorld, type RapierModule } from '@openvibe/physics/rapier'
import { createRapierWorld, type RapierModule } from '@openvibe/physics/rapier'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { Db } from 'openvibe-sdk/db'
import type { Db } from 'openvibe-sdk/db'
@@ -7,6 +12,8 @@ import type { ServerConfig } from '../config.js'
@@ -7,6 +12,8 @@ import type { ServerConfig } from '../config.js'
import { GameServer } from '../game/gameServer.js'
import { GameServer } from '../game/gameServer.js'
import { GameWorld } from '../game/gameWorld.js'
import { GameWorld } from '../game/gameWorld.js'
import { InstanceHub } from '../game/instanceHub.js'
import { InstanceHub } from '../game/instanceHub.js'
+import { ScriptModHost } from '../mods/script/host.js'
+import { PgScriptStorage } from '../mods/script/storage.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
import { sandboxStore } from './sandboxStore.js'
import { sandboxStore } from './sandboxStore.js'
import type { StartWorldHub } from './worldHost.js'
import type { StartWorldHub } from './worldHost.js'
@@ -17,8 +24,42 @@ export interface WorldHubDeps {
@@ -17,8 +24,42 @@ export interface WorldHubDeps {
config: ServerConfig
config: ServerConfig
metrics: ServerMetrics
metrics: ServerMetrics
log: Logger
log: Logger
- /** The creator pack's def set for a world (the one /api/v1/content?world= reports). */
- contentFor: (worldId: string) => ContentRegistry
+ /** The creator pack's def set for a world, with its scripts when they run (the one /api/v1/content?world= reports). */
+ contentFor: (worldId: string) => ContentRegistry | Promise<ContentRegistry>
+ /**
+ * The QuickJS host for one room of a world with scripts (main.ts, GAMES_CREATOR_SCRIPTS), or undefined: no scripts
+ * run there. Its storage is the world's own place.
+ */
+ scriptsFor?: (
+ content: ContentRegistry,
+ placeId: string,
+ slot: number,
+ store: PersistenceStore,
+ ) => Promise<ScriptModHost | undefined>
+}
+
+/**
+ * The QuickJS host for a room of a world with scripts (GAMES_CREATOR_SCRIPTS): each mod has what it asked for (all four
+ * capabilities are scoped to the room and the world's own storage); budgets and strikes are the host's own.
+ */
+export function worldScriptsFactory(log: Logger): NonNullable<WorldHubDeps['scriptsFor']> {
+ return async (content, placeId, slot, store) => {
+ const { createQuickJsSandboxFactory } = await import('../mods/script/quickjs.js')
+ const grants = Object.fromEntries(
+ content.allMods().map((mod) => [mod.id, mod.granted ?? ['players.read', 'world.emit']]),
+ ) as Record<string, ScriptCapability[]>
+ return new ScriptModHost(
+ content.allMods(),
+ createQuickJsSandboxFactory(),
+ log.child({ placeId, slot, creator: true }),
+ undefined,
+ {
+ placeSeed: slot === 0 ? placeId : `${placeId}:${slot}`,
+ grants,
+ storage: new PgScriptStorage(store.mods, placeId, log),
+ },
+ )
+ }
}
}
/**
/**
@@ -29,7 +70,7 @@ export interface WorldHubDeps {
@@ -29,7 +70,7 @@ export interface WorldHubDeps {
export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
const { db, rapier, config, metrics, log } = deps
const { db, rapier, config, metrics, log } = deps
return async (world, placeId) => {
return async (world, placeId) => {
- const worldContent = deps.contentFor(world.id)
+ const worldContent = await deps.contentFor(world.id)
const worldRegistry = new InstanceRegistry(db, placeId)
const worldRegistry = new InstanceRegistry(db, placeId)
const worldLog = log.child({ system: 'world', place: placeId })
const worldLog = log.child({ system: 'world', place: placeId })
const createWorldRoom = async (slot: number, lease: InstanceLease) => {
const createWorldRoom = async (slot: number, lease: InstanceLease) => {
@@ -46,6 +87,10 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
@@ -46,6 +87,10 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
await roomWorld.seedOrRestore(roomStore)
await roomWorld.seedOrRestore(roomStore)
roomWorld.reconcileMapNodes()
roomWorld.reconcileMapNodes()
roomWorld.reconcileMapProps()
roomWorld.reconcileMapProps()
+ const scripts =
+ worldContent.allMods().length > 0
+ ? await deps.scriptsFor?.(worldContent, placeId, slot, roomStore)
+ : undefined
const roomGame = new GameServer(
const roomGame = new GameServer(
{
{
...config,
...config,
@@ -58,14 +103,17 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
@@ -58,14 +103,17 @@ export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
roomStore,
roomStore,
metrics,
metrics,
log.child({ system: 'game', place: placeId }),
log.child({ system: 'game', place: placeId }),
- {},
+ scripts ? { scripts } : {},
)
)
await roomGame.load()
await roomGame.load()
+ await scripts?.start()
return {
return {
lease,
lease,
game: roomGame,
game: roomGame,
world: roomWorld,
world: roomWorld,
dispose: async () => {
dispose: async () => {
+ await scripts?.flushWrites()
+ scripts?.dispose()
roomPhysics.dispose()
roomPhysics.dispose()
},
},
}
}
@@ -134,6 +134,18 @@ export class CreatorWorldHost implements DynamicPlaces {
@@ -134,6 +134,18 @@ export class CreatorWorldHost implements DynamicPlaces {
await this.stop(id)
await this.stop(id)
}
}
+ /**
+ * The world's scripts changed: a running world tells its players and stops, so the next join starts it with the new
+ * scripts (main.ts drops its cached def set first).
+ */
+ async scriptsChanged(id: string): Promise<void> {
+ const current = this.running.get(id)
+ if (!current) return
+ for (const room of current.hub.activeRooms)
+ room.game.announce("This world's scripts were updated. Join again to play the new version.")
+ await this.stop(id)
+ }
+
/** One pass of the idle sweep (main.ts runs it in the router's admission chain every 30 s). */
/** One pass of the idle sweep (main.ts runs it in the router's admission chain every 30 s). */
async sweep(): Promise<void> {
async sweep(): Promise<void> {
const now = this.now()
const now = this.now()
@@ -39,6 +39,7 @@ async function setup(perPerson = 2) {
@@ -39,6 +39,7 @@ async function setup(perPerson = 2) {
changed: [] as (string | null)[],
changed: [] as (string | null)[],
removed: [] as string[],
removed: [] as string[],
events: [] as EventInput[],
events: [] as EventInput[],
+ scripts: [] as string[],
}
}
const handler = createWorldRoutes({
const handler = createWorldRoutes({
store,
store,
@@ -48,6 +49,9 @@ async function setup(perPerson = 2) {
@@ -48,6 +49,9 @@ async function setup(perPerson = 2) {
changed: async (world: CreatorWorld | null, id: string) => {
changed: async (world: CreatorWorld | null, id: string) => {
calls.changed.push(world ? `${id}:${world.status}` : null)
calls.changed.push(world ? `${id}:${world.status}` : null)
},
},
+ scriptsChanged: async (id) => {
+ calls.scripts.push(id)
+ },
remove: async (id, within) => {
remove: async (id, within) => {
calls.removed.push(id)
calls.removed.push(id)
await db.tx(async (t) => {
await db.tx(async (t) => {
@@ -124,6 +128,55 @@ describe('/api/worlds', () => {
@@ -124,6 +128,55 @@ describe('/api/worlds', () => {
expect(parseIfMatch('')).toBeNull()
expect(parseIfMatch('')).toBeNull()
})
})
+ it("keeps a world's scripts: the owner saves them through the content checks, staff read them, a save restarts it", async () => {
+ const { call, calls } = await setup(2)
+ const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Scripted' })).body.id)
+ const hello = {
+ format: 'games-quickjs@1',
+ id: 'hello',
+ version: '1.0.0',
+ entry: 'main.js',
+ source: 'export function onPlayerJoin(p) { emit("welcome", { id: p.id }) }',
+ capabilities: ['players.read', 'world.emit'],
+ hooks: ['onPlayerJoin'],
+ }
+ expect((await call('GET', `/api/worlds/${id}/scripts`)).status).toBe(401)
+ expect((await call('GET', `/api/worlds/${id}/scripts`, 'lee')).status).toBe(404)
+ const empty = await call('GET', `/api/worlds/${id}/scripts`, 'kim')
+ expect(empty.body).toMatchObject({ scripts: [], limits: { mods: 4 }, running: false })
+
+ // The content package's checks, and the creator limits.
+ const bad = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {
+ scripts: [{ ...hello, hooks: ['onEverything'] }],
+ })
+ expect(bad.status).toBe(422)
+ expect(bad.body.issues?.some((i) => i.startsWith('scripts[0].hooks'))).toBe(true)
+ const twice = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', { scripts: [hello, hello] })
+ expect(twice.body.issues).toEqual(['scripts[1].id: hello is used twice'])
+ const five = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {
+ scripts: [1, 2, 3, 4, 5].map((n) => ({ ...hello, id: `s${n}` })),
+ })
+ expect(five.body.issues?.[0]).toMatch(/more than the 4 a creator world takes/)
+ expect(
+ (await call('PUT', `/api/worlds/${id}/scripts`, 'lee', { scripts: [hello] })).status,
+ ).toBe(404)
+ expect(calls.scripts).toEqual([])
+
+ // A good save: stored as written (the server's `granted` never comes from the creator), the world restarts.
+ const ok = await call('PUT', `/api/worlds/${id}/scripts`, 'kim', {
+ scripts: [{ ...hello, granted: ['storage.kv', 'player.message'] }],
+ })
+ expect(ok.status).toBe(200)
+ expect(ok.body.scripts).toEqual([hello])
+ expect(calls.scripts).toEqual([id])
+ expect((await call('GET', `/api/worlds/${id}/scripts`, 'kim')).body.scripts).toEqual([hello])
+ expect((await call('GET', `/api/worlds/${id}/scripts`, 'staff')).body.scripts).toEqual([hello])
+ // An empty list takes them out.
+ expect(
+ (await call('PUT', `/api/worlds/${id}/scripts`, 'kim', { scripts: [] })).body.scripts,
+ ).toEqual([])
+ })
+
it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {
it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {
const { call } = await setup(2)
const { call } = await setup(2)
const made = await call('POST', '/api/worlds', 'kim', {
const made = await call('POST', '/api/worlds', 'kim', {
@@ -1,8 +1,10 @@
@@ -1,8 +1,10 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
import {
CREATOR_MAP_LIMITS,
CREATOR_MAP_LIMITS,
+ CREATOR_SCRIPT_LIMITS,
CREATOR_WORLD_ID,
CREATOR_WORLD_ID,
creatorMapIssues,
creatorMapIssues,
+ creatorScriptsOf,
emptyMapV2,
emptyMapV2,
parseMapFile,
parseMapFile,
type MapFileV2,
type MapFileV2,
@@ -46,6 +48,8 @@ export interface WorldHostView {
@@ -46,6 +48,8 @@ export interface WorldHostView {
* that removes it (a staff deletion's moderation event).
* that removes it (a staff deletion's moderation event).
*/
*/
remove(id: string, within?: (t: Tx) => Promise<void>): Promise<void>
remove(id: string, within?: (t: Tx) => Promise<void>): Promise<void>
+ /** The world's scripts changed: a running world says so and stops, and the next join runs the new ones. */
+ scriptsChanged(id: string): Promise<void>
}
}
export interface WorldRouteOptions {
export interface WorldRouteOptions {
@@ -64,6 +68,8 @@ export interface WorldRouteOptions {
@@ -64,6 +68,8 @@ export interface WorldRouteOptions {
events?: { db: Db; sink: EventSink; siteUrl: string }
events?: { db: Db; sink: EventSink; siteUrl: string }
/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
search?: WorldSearchIndex
search?: WorldSearchIndex
+ /** Whether worlds' scripts run here (GAMES_CREATOR_SCRIPTS); they are kept either way. */
+ scriptsRun?: boolean
now?: () => number
now?: () => number
}
}
@@ -345,6 +351,41 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -345,6 +351,41 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return
return
}
}
+ // GET/PUT /api/worlds/:id/scripts: the world's own games-quickjs@1 mods (docs/mods.md "Scripts in creator worlds"),
+ // the owner's to change and staff's to read. A save that passes the content package's checks stops a running world
+ // (it says so first) so the next join runs the new scripts. `running` says whether this server runs them at all.
+ const scriptsMatch = /^\/api\/worlds\/([^/]+)\/scripts$/.exec(path)
+ if (scriptsMatch && (method === 'GET' || method === 'PUT')) {
+ const user = await signedIn(req)
+ const sid = scriptsMatch[1] ?? ''
+ if (method === 'GET') {
+ const world = CREATOR_WORLD_ID.test(sid) ? await opts.store.get(sid) : null
+ if (!world || (world.ownerSubject !== user.subjectId && !isStaff(user)))
+ fail(404, 'world_not_found')
+ send(res, 200, {
+ scripts: (await opts.store.scripts(sid)) ?? [],
+ limits: CREATOR_SCRIPT_LIMITS,
+ running: opts.scriptsRun === true,
+ })
+ return
+ }
+ await owned(sid, user)
+ if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
+ const { text } = await readBody(req, CREATOR_SCRIPT_LIMITS.bytes + 16 * 1024)
+ const body = parseJson(text) as { scripts?: unknown } | null
+ const checked = creatorScriptsOf(body?.scripts)
+ if (checked.issues.length)
+ fail(422, 'scripts_invalid', { issues: checked.issues.slice(0, 40) })
+ // Stored as the creator wrote them; the server sets `granted` when it loads them.
+ const stored = checked.scripts.map(({ granted: _granted, ...mod }) => mod)
+ const saved = await opts.store.saveScripts(sid, stored, now())
+ if (!saved) fail(404, 'world_not_found')
+ await opts.host.scriptsChanged(sid)
+ opts.log.info('creator world scripts saved', { world: sid, scripts: stored.length })
+ send(res, 200, { scripts: stored, running: opts.scriptsRun === true })
+ return
+ }
+
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
if (!match) fail(404, 'not_found')
if (!match) fail(404, 'not_found')
const id = match?.[1] ?? ''
const id = match?.[1] ?? ''
@@ -94,6 +94,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -94,6 +94,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
and the original counts `remixes` from other people. A world page's **Remix it** link goes through
and the original counts `remixes` from other people. A world page's **Remix it** link goes through
`/worlds?remix=<id>`, which signs you in first if needed and opens the copy in the editor (owner, 2026-10-27: "other
`/worlds?remix=<id>`, which signs you in first if needed and opens the copy in the editor (owner, 2026-10-27: "other
people can make games just like the same way you can copy our game").
people can make games just like the same way you can copy our game").
+- **Scripts:** a world's own `games-quickjs@1` mods, written at `/worlds` → **Scripts**, checked like an installed
+ pack's within the creator limits, and run in its rooms when `GAMES_CREATOR_SCRIPTS=1` (off by default); see
+ `mods.md` "Scripts in creator worlds".
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
@@ -203,3 +203,24 @@ first implementation.
@@ -203,3 +203,24 @@ first implementation.
Script storage writes are durable after the write-behind queue drains; a
Script storage writes are durable after the write-behind queue drains; a
database failure is logged and retried twice.
database failure is logged and retried twice.
+
+## Scripts in creator worlds
+
+A creator world can carry its own `games-quickjs@1` mods, written by its owner (owner, 2026-10-27: an open, moddable
+game engine like Garry's Mod, s&box or Roblox).
+
+- **Writing them:** `/worlds` → your world → **Scripts**. Each script has a name, its hooks, what it may do (the four
+ capabilities: see the players, message a player, announce and give items, remember things) and its code. The API
+ is `GET`/`PUT /api/worlds/:id/scripts` (`{ scripts: [...] }`); the owner saves, staff can read.
+- **Checks:** `creatorScriptsOf` (`packages/content/src/packs/creatorWorld.ts`) runs each through the same
+ `ScriptModSchema` as an installed pack (hooks, capabilities, budgets under their ceilings, imports inside the mod),
+ and the creator limits: at most `CREATOR_SCRIPT_LIMITS.mods` (4) scripts and 128 KiB together. `granted` is never
+ the creator's: the server sets it to what each script asked for. Scripts are stored as written (migration 0014,
+ `creator_worlds.scripts`) and checked again when they load.
+- **Running:** only with `GAMES_CREATOR_SCRIPTS=1` (off by default). Then a world's def set is the creator pack plus a
+ pack of its scripts, `/api/v1/content?world=` sends that pack so the client builds the same digest, and each room runs
+ them in its own QuickJS host (`worldScriptsFactory`, `apps/server/src/worlds/startWorldHub.ts`) with the world's own
+ script storage. Everything a script does goes through the gameplay modules, as in any place, and a creator world is
+ still a sandbox: nothing reaches characters, Scraplandia or Inventory.
+- **Saving while it runs:** a save tells the players in the world that its scripts changed and stops it; the next join
+ starts it with the new scripts. With the flag off, worlds keep their scripts and their def set has none.
@@ -3,7 +3,9 @@ import { createContent } from '../index.js'
@@ -3,7 +3,9 @@ import { createContent } from '../index.js'
import { emptyMapV2, type MapFileV2 } from '../mapFileV2.js'
import { emptyMapV2, type MapFileV2 } from '../mapFileV2.js'
import {
import {
CREATOR_MAP_LIMITS,
CREATOR_MAP_LIMITS,
+ CREATOR_SCRIPT_LIMITS,
creatorMapIssues,
creatorMapIssues,
+ creatorScriptsOf,
creatorPlaceId,
creatorPlaceId,
creatorWorldOf,
creatorWorldOf,
creatorWorldPack,
creatorWorldPack,
@@ -65,4 +67,42 @@ describe('creator worlds', () => {
@@ -65,4 +67,42 @@ describe('creator worlds', () => {
} as unknown as MapFileV2
} as unknown as MapFileV2
expect(creatorMapIssues(painted, 10).some((i) => i.startsWith('statics:'))).toBe(true)
expect(creatorMapIssues(painted, 10).some((i) => i.startsWith('statics:'))).toBe(true)
})
})
+
+ it("checks a world's scripts like an installed pack's, within the creator limits, and grants what each asks", () => {
+ const mod = {
+ format: 'games-quickjs@1',
+ id: 'hello',
+ version: '1.0.0',
+ entry: 'main.js',
+ source: 'export function onTick() {}',
+ hooks: ['onTick'],
+ }
+ expect(creatorScriptsOf([])).toEqual({ scripts: [], issues: [] })
+ expect(creatorScriptsOf('nope').issues).toEqual(['scripts: a list of games-quickjs@1 mods'])
+ const one = creatorScriptsOf([
+ { ...mod, capabilities: ['storage.kv'], granted: ['player.message'] },
+ ])
+ expect(one.issues).toEqual([])
+ expect(one.scripts[0]?.granted).toEqual(['storage.kv'])
+ expect(creatorScriptsOf([mod]).scripts[0]?.granted).toEqual(['players.read', 'world.emit'])
+ expect(creatorScriptsOf([{ ...mod, budgets: { cpuMs: 9999 } }]).issues[0]).toMatch(
+ /^scripts\[0\]\.budgets\.cpuMs/,
+ )
+ expect(creatorScriptsOf([{ ...mod, extra: 1 }]).issues.length).toBeGreaterThan(0)
+ expect(
+ creatorScriptsOf([
+ { ...mod, source: undefined, files: { 'main.js': "import x from 'fs'" } },
+ ]).issues.some((i) => i.includes('fs')),
+ ).toBe(true)
+ const many = Array.from({ length: CREATOR_SCRIPT_LIMITS.mods + 1 }, (_, i) => ({
+ ...mod,
+ id: `m${i}`,
+ }))
+ expect(creatorScriptsOf(many).issues[0]).toMatch(/more than the 4/)
+ expect(creatorScriptsOf(many).scripts).toEqual([])
+ // A world with scripts is a def set with them, on both sides.
+ const content = createContent([{ mods: one.scripts }], creatorWorldPack('wk3j9x0a2bq'))
+ expect(content.allMods().map((m) => m.id)).toEqual(['hello'])
+ expect(content.digest).not.toBe(createContent([], creatorWorldPack('wk3j9x0a2bq')).digest)
+ })
})
})
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
import type { ContentPackV2 } from '../pack.js'
import type { ContentPackV2 } from '../pack.js'
import type { MapFileV2 } from '../mapFileV2.js'
import type { MapFileV2 } from '../mapFileV2.js'
+import { ScriptModSchema, type ScriptMod } from '../schema/scriptMod.js'
/**
/**
* Creator worlds: a place a signed-in player builds in /editor and publishes at /worlds. The server and
* Creator worlds: a place a signed-in player builds in /editor and publishes at /worlds. The server and
@@ -90,3 +91,53 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
@@ -90,3 +91,53 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
issues.push('statics: painted surfaces are not available in creator worlds yet')
issues.push('statics: painted surfaces are not available in creator worlds yet')
return issues
return issues
}
}
+
+/**
+ * What a creator world's scripts may be: its own `games-quickjs@1` mods (docs/mods.md), at most four, together at
+ * most 128 KiB of source. Each passes the content package's script schema, so a creator gets exactly the checks an
+ * installed pack gets (hooks, capabilities, budgets under their ceilings, module imports inside the mod). The server
+ * grants what each asks for: the capabilities are all scoped to the world's own room and storage.
+ */
+export const CREATOR_SCRIPT_LIMITS = { mods: 4, bytes: 128 * 1024 } as const
+
+/** A world's scripts, validated: the mods to run, or the reasons they cannot (an empty list is fine: no scripts). */
+export function creatorScriptsOf(raw: unknown): { scripts: ScriptMod[]; issues: string[] } {
+ if (!Array.isArray(raw))
+ return { scripts: [], issues: ['scripts: a list of games-quickjs@1 mods'] }
+ const issues: string[] = []
+ if (raw.length > CREATOR_SCRIPT_LIMITS.mods)
+ issues.push(
+ `scripts: ${raw.length} is more than the ${CREATOR_SCRIPT_LIMITS.mods} a creator world takes`,
+ )
+ const bytes = new TextEncoder().encode(JSON.stringify(raw)).length
+ if (bytes > CREATOR_SCRIPT_LIMITS.bytes)
+ issues.push(
+ `scripts: ${bytes} bytes is more than the ${CREATOR_SCRIPT_LIMITS.bytes} a creator world takes`,
+ )
+ const scripts: ScriptMod[] = []
+ const ids = new Set<string>()
+ raw.slice(0, CREATOR_SCRIPT_LIMITS.mods).forEach((item, i) => {
+ // `granted` is the server's to set, never the creator's.
+ const { granted: _granted, ...asked } = (
+ item && typeof item === 'object' ? item : {}
+ ) as Record<string, unknown>
+ const parsed = ScriptModSchema.safeParse(asked)
+ if (!parsed.success) {
+ for (const issue of parsed.error.issues)
+ issues.push(
+ `scripts[${i}]${issue.path.length ? `.${issue.path.join('.')}` : ''}: ${issue.message}`,
+ )
+ return
+ }
+ if (ids.has(parsed.data.id)) {
+ issues.push(`scripts[${i}].id: ${parsed.data.id} is used twice`)
+ return
+ }
+ ids.add(parsed.data.id)
+ scripts.push({
+ ...parsed.data,
+ granted: parsed.data.capabilities ?? ['players.read', 'world.emit'],
+ })
+ })
+ return { scripts: issues.length ? [] : scripts, issues }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Scripts in a creator world (owner, 2026-10-27: "an open, moddable … game engine like Garry's mod, s&box or
+-- Roblox"): the world's own games-quickjs@1 mods, as the content package validates them, run in its room's QuickJS
+-- sandbox with the creator limits (docs/mods.md "Scripts in creator worlds"). An empty list for every world so far.
+ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS scripts jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -106,6 +106,10 @@ export interface CreatorWorldStore {
@@ -106,6 +106,10 @@ export interface CreatorWorldStore {
get(id: string): Promise<CreatorWorld | null>
get(id: string): Promise<CreatorWorld | null>
/** The stored map document and its revision. */
/** The stored map document and its revision. */
map(id: string): Promise<{ map: unknown; revision: string } | null>
map(id: string): Promise<{ map: unknown; revision: string } | null>
+ /** The world's scripts as stored (validated before they were saved; migration 0014). Null when the world is gone. */
+ scripts(id: string): Promise<unknown[] | null>
+ /** Replaces the world's scripts (callers validate them first). Answers the world, or null when it is gone. */
+ saveScripts(id: string, scripts: unknown[], now: number): Promise<CreatorWorld | null>
countByOwner(ownerSubject: string): Promise<number>
countByOwner(ownerSubject: string): Promise<number>
listByOwner(ownerSubject: string): Promise<CreatorWorld[]>
listByOwner(ownerSubject: string): Promise<CreatorWorld[]>
/** Published worlds, newest first. */
/** Published worlds, newest first. */
@@ -159,6 +163,20 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
@@ -159,6 +163,20 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
)
)
return row ? { map: row.map, revision: row.revision } : null
return row ? { map: row.map, revision: row.revision } : null
},
},
+ async scripts(id) {
+ const row = await db.maybe<{ scripts: unknown }>(
+ sql`SELECT scripts FROM creator_worlds WHERE id = ${id}`,
+ )
+ if (!row) return null
+ return Array.isArray(row.scripts) ? row.scripts : []
+ },
+ async saveScripts(id, scripts, now) {
+ const row = await db.maybe<Row>(sql`
+ UPDATE creator_worlds SET scripts = ${sql.json(scripts)}, updated_at = ${now}
+ WHERE id = ${id} RETURNING ${COLUMNS}
+ `)
+ return row ? fromRow(row) : null
+ },
async countByOwner(ownerSubject) {
async countByOwner(ownerSubject) {
const row = await db.one<{ n: number | string }>(
const row = await db.one<{ n: number | string }>(
sql`SELECT count(*)::int AS n FROM creator_worlds WHERE owner_subject = ${ownerSubject}`,
sql`SELECT count(*)::int AS n FROM creator_worlds WHERE owner_subject = ${ownerSubject}`,