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)

OP OpenVibers committed 17 days ago · 0e51c8bf36 · 16 files, +630 −19

Changes

ddiff --git a/README.md b/README.md
iindex a971bab..3e5028d 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/apps/client/src/pages/worlds.ts b/apps/client/src/pages/worlds.ts
iindex c7d9a39..0f00ffb 100644
---- a/apps/client/src/pages/worlds.ts
++++ b/apps/client/src/pages/worlds.ts
@@@ -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 = false
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(
b: HTMLButtonElement,
id: string,
ddiff --git a/apps/client/worlds.html b/apps/client/worlds.html
iindex ee03451..f68f8ce 100644
---- a/apps/client/worlds.html
++++ b/apps/client/worlds.html
@@@ -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>
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex a6ef365..5570b51 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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: boolean
maxPlayers: number
persistFlushSeconds: number
metricsLogSeconds: 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',
}
}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 0482bef..1a872a8 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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
}
ddiff --git a/apps/server/src/worlds/creatorWorlds.test.ts b/apps/server/src/worlds/creatorWorlds.test.ts
iindex e322c6d..54334e5 100644
---- a/apps/server/src/worlds/creatorWorlds.test.ts
++++ b/apps/server/src/worlds/creatorWorlds.test.ts
@@@ -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, re
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)
ddiff --git a/apps/server/src/worlds/startWorldHub.ts b/apps/server/src/worlds/startWorldHub.ts
iindex 1ddfcd8..6961aaa 100644
---- a/apps/server/src/worlds/startWorldHub.ts
++++ b/apps/server/src/worlds/startWorldHub.ts
@@@ -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: ServerConfig
metrics: ServerMetrics
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 {
export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
const { db, rapier, config, metrics, log } = deps
return 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)
++ : undefined
const 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()
},
}
ddiff --git a/apps/server/src/worlds/worldHost.ts b/apps/server/src/worlds/worldHost.ts
iindex 7cf8bb7..734d567 100644
---- a/apps/server/src/worlds/worldHost.ts
++++ b/apps/server/src/worlds/worldHost.ts
@@@ -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()
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex f7e3b66..55003a3 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -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', {
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 2732aec..35368da 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -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?: boolean
now?: () => 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] ?? ''
ddiff --git a/docs/games.md b/docs/games.md
iindex ed995fc..89c6ec4 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -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
`/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").
++- **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 owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
ddiff --git a/docs/mods.md b/docs/mods.md
iindex 320425a..1f10a1e 100644
---- a/docs/mods.md
++++ b/docs/mods.md
@@@ -203,3 +203,24 @@ first implementation.
Script storage writes are durable after the write-behind queue drains; a
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.
ddiff --git a/packages/content/src/packs/creatorWorld.test.ts b/packages/content/src/packs/creatorWorld.test.ts
iindex 7c3c975..ea228b8 100644
---- a/packages/content/src/packs/creatorWorld.test.ts
++++ b/packages/content/src/packs/creatorWorld.test.ts
@@@ -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 MapFileV2
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)
++ })
})
ddiff --git a/packages/content/src/packs/creatorWorld.ts b/packages/content/src/packs/creatorWorld.ts
iindex 2e3468c..be278ff 100644
---- a/packages/content/src/packs/creatorWorld.ts
++++ b/packages/content/src/packs/creatorWorld.ts
@@@ -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 }
++}
ddiff --git a/packages/persistence/migrations/0014_world_scripts.sql b/packages/persistence/migrations/0014_world_scripts.sql
nnew file mode 100644
iindex 0000000..f7a136a
---- /dev/null
++++ b/packages/persistence/migrations/0014_world_scripts.sql
@@@ -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;
ddiff --git a/packages/persistence/src/pg/creatorWorlds.ts b/packages/persistence/src/pg/creatorWorlds.ts
iindex 32c60b2..8e9461a 100644
---- a/packages/persistence/src/pg/creatorWorlds.ts
++++ b/packages/persistence/src/pg/creatorWorlds.ts
@@@ -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}`,