openvibers/openvibe.games Commits
Games: creator worlds — players build a world in the editor, publish it at /worlds, anyone joins (#87)
Changes
@@@ -17,6 +17,11 @@ GAMES_INSTANCES_PER_PLACE=1MAX_PLAYERS=64GAMES_INTEREST_CELL=16GAMES_INTEREST_RADIUS=80++# Creator worlds (/worlds): running at once, kept per account, idle stop (seconds), players per world.++# GAMES_CREATOR_WORLDS_MAX=2++# GAMES_CREATOR_WORLDS_PER_PERSON=5++# GAMES_CREATOR_WORLD_IDLE_SECONDS=180++# GAMES_CREATOR_WORLD_PLAYERS=16# ── Valkey (optional): shared per-actor rate-limit counters across instances ──VALKEY_URL=@@@ -8,6 +8,8 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard andThe same Games process also hosts Salvage Rush, an on-foot timed collection game with its ownplace, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.See [docs/games.md](docs/games.md) for both games and local progression.++Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join++(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with**Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md).A persistent multiplayer physics sandbox for the browser: Source-style movement,@@@ -231,18 +233,22 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsPlatform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`):--| Variable | Purpose |--| --------------------------- | ------------------------------------------------------------------------------------------ |--| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |--| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |--| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |--| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |--| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |--| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |--| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |--| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local |--| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |--| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |++| Variable | Purpose |++| ---------------------------------- | ------------------------------------------------------------------------------------------ |++| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |++| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |++| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |++| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |++| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |++| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |++| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |++| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local |++| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |++| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |++| `GAMES_CREATOR_WORLDS_MAX` | Creator worlds running at once (default 2; 0 stops new ones starting) |++| `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) |## Capabilities@@@ -706,6 +706,84 @@</div></div></div>++ <div class="game-card wide">++ <div++ class="shot"++ role="img"++ aria-label="Creator worlds: a block of raised terrain with a tower, a lamp and a crate being placed"++ >++ <svg++ class="shot-art"++ viewBox="0 0 400 260"++ preserveAspectRatio="xMidYMid meet"++ aria-hidden="true"++ focusable="false"++ >++ <!-- an island of terrain, seen at an angle -->++ <path++ d="M200 70 340 140 200 210 60 140z"++ fill="#14233f"++ stroke="#3b82f6"++ stroke-width="2"++ />++ <path d="M60 140v26l140 70v-26z" fill="#0d1830" />++ <path d="M340 140v26l-140 70v-26z" fill="#0a1426" />++ <path++ d="M150 128q30-30 62-10t58 4"++ fill="none"++ stroke="#22d3ee"++ stroke-width="2"++ stroke-opacity=".6"++ />++ <!-- a tower -->++ <path++ d="M118 120l26-13 26 13v-46l-26-13-26 13z"++ fill="#1e3a66"++ stroke="#60a5fa"++ stroke-width="1.5"++ />++ <path d="M118 74l26 13 26-13" fill="none" stroke="#60a5fa" stroke-width="1.5" />++ <path d="M144 87v46" stroke="#60a5fa" stroke-width="1.5" />++ <!-- a lamp and its light -->++ <circle cx="262" cy="96" r="26" fill="#fbbf24" fill-opacity=".12" />++ <path d="M262 132v-34" stroke="#96a7c2" stroke-width="3" stroke-linecap="round" />++ <circle cx="262" cy="96" r="6" fill="#fde68a" />++ <!-- a crate being placed, with the editor's move gizmo -->++ <path++ d="M210 150l18-9 18 9-18 9z"++ fill="#3b82f6"++ fill-opacity=".35"++ stroke="#60a5fa"++ stroke-dasharray="4 3"++ />++ <path++ d="M210 130l18-9 18 9v20l-18 9-18-9z"++ fill="#2563eb"++ fill-opacity=".55"++ stroke="#93c5fd"++ stroke-width="1.5"++ />++ <path d="M228 120V96" stroke="#34d399" stroke-width="2.5" />++ <path d="M223 100l5-8 5 8z" fill="#34d399" />++ <path d="M246 130l20-10" stroke="#f87171" stroke-width="2.5" />++ <path d="M262 117l8-1-4 7z" fill="#f87171" />++ </svg>++ </div>++ <div class="game-body">++ <span class="badge live">● NEW</span>++ <h3>CREATOR WORLDS</h3>++ <p>++ Build your own world in the map editor: terrain, buildings, lights, zones and props.++ Publish it and anyone can join from their browser, or browse what other players made.++ </p>++ <div class="tags">++ <span>map editor</span><span>publish</span><span>multiplayer</span>++ </div>++ <div class="cta-row">++ <a class="play-cta" href="/worlds">▶ BROWSE & BUILD</a>++ </div>++ </div>++ </div></section><section class="account">@@@ -30,6 +30,8 @@ export interface AssetControllerOptions {history: CommandHistory<EditorDocument>modelCache: ModelCacheeditorToken: () => string++ /** The upload endpoint; null where uploads are off (a creator world). Default /api/map-assets. */++ assetsUrl?: string | null/** Re-register custom textures with the renderer after a change. */onAssetsChanged: () => voidsetMessage: (m: string) => void@@@ -119,8 +121,10 @@ export class AssetController {/** POST raw bytes to the content-addressed store. */private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {++ const url = this.opts.assetsUrl === undefined ? '/api/map-assets' : this.opts.assetsUrl++ if (url === null) return 'forbidden'try {-- const resp = await fetch('/api/map-assets', {++ const resp = await fetch(url, {method: 'POST',headers: { authorization: `Bearer ${this.opts.editorToken()}` },body: blob,@@@ -133,6 +137,12 @@ export class AssetController {}}++ private forbiddenMessage(): string {++ return this.opts.assetsUrl === null++ ? '⛔ creator worlds use the built-in materials for now (uploads are staff-only)'++ : '⛔ import needs your admin token (top right)'++ }++textureUsage(): Map<string, TextureUsage> {return collectTextureUsage(this.opts.doc)}@@@ -148,7 +158,7 @@ export class AssetController {setMessage('🖼 uploading texture…')const uploaded = await this.upload(file)if (uploaded === 'forbidden') {-- setMessage('⛔ import needs your admin token (top right)')++ setMessage(this.forbiddenMessage())return null}if (!uploaded) {@@@ -177,7 +187,7 @@ export class AssetController {setMessage('🗿 uploading model…')const uploaded = await this.upload(file)if (uploaded === 'forbidden') {-- setMessage('⛔ import needs your admin token (top right)')++ setMessage(this.forbiddenMessage())return null}if (!uploaded) {nnew file mode 100644@@@ -0,0 +1,35 @@++import type { EditorConnection } from './editorConnection.js'++++/**++ * The editor's connection when nobody else can be editing: a creator world has one editor, its owner, and no++ * collaboration socket (/editor-ws is staff-only). Mutations still go through the same lock gate; every request is++ * granted at once, so `acquire` answers true (proceed now) and never calls `then`.++ */++export function createSoloConnection(): EditorConnection {++ const held = new Set<string>()++ return {++ connect: () => {},++ disconnect: () => {},++ sendSelection: () => {},++ canInspect: () => true,++ isLockedByOther: () => false,++ owns: (id) => held.has(id),++ ownsAll: (ids) => ids.every((id) => held.has(id)),++ acquire: (ids) => {++ for (const id of ids) held.add(id)++ return true++ },++ release: (ids) => {++ for (const id of ids) held.delete(id)++ },++ releaseAll: () => held.clear(),++ lockOwner: () => null,++ lockOwners: () => new Map(),++ lockColors: () => new Map(),++ remoteSelectionColors: () => new Map(),++ peers: () => [],++ peerCount: () => 0,++ myPeerId: () => 0,++ connected: () => true,++ }++}@@@ -72,6 +72,7 @@ import { PaintSurfaceRegistry } from './materials/paintSurfaceRegistry.js'import { buildShell } from './ui/editorShell.js'import { createEditorUi } from './ui/editorUi.js'import { createSaveController } from './net/saveController.js'++import { fetchMap, mapEndpoints } from './net/mapEndpoints.js'import { editorToken, ssoToken } from './net/editorToken.js'import { ActionRouter } from './input/actionRouter.js'import { ACTIONS, loadBindings, type Binding } from './bindings.js'@@@ -113,7 +114,9 @@ export async function bootEditor(): Promise<void> {// /map.json is the v2 document, so the editor only ever holds v2 — and a// missing or invalid map is emptyMapV2(), never a fabricated starter// heightfield.-- const bootResp = await fetch('/map.json')++ // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor).++ const endpoints = mapEndpoints()++ const bootResp = await fetchMap(endpoints)const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''const bootParsed = parseMapFile(await bootResp.json().catch(() => null))if (!bootParsed.ok) console.warn('[editor] map rejected, starting empty:', bootParsed.issues)@@@ -730,31 +733,33 @@ export async function bootEditor(): Promise<void> {},paintSurfaces,setMessage: (m) => ui.setMessage(m),++ endpoints,})-- const { createEditorConnection } = await import('./collaboration/editorConnection.js')-- const connection = createEditorConnection({-- scene,-- peersEl: document.getElementById('peers') as HTMLElement,-- camera,-- keyOf: editorToken,-- nameOf: defaultEditorName,-- onRemoteSaved: () => void saveController.pollRemote(),-- onLockDenied: (owner) => ui.setMessage(`🔒 held by ${owner} — you have read-only access`),-- onChange: () => {-- refreshVisuals()-- ui.refreshAll()-- },-- onLockLost: () => {-- // The lease went away mid-gesture: restore the exact starting state-- // rather than leaving half-applied local edits the server disagrees-- // with.-- if (xform.active) xform.cancel()-- selection.unfreeze()-- lockedForSelection = []-- ui.setMessage('⚠ edit lock lost — reselect to reacquire')-- },-- })++ const connection = endpoints.collaboration++ ? (await import('./collaboration/editorConnection.js')).createEditorConnection({++ scene,++ peersEl: document.getElementById('peers') as HTMLElement,++ camera,++ keyOf: editorToken,++ nameOf: defaultEditorName,++ onRemoteSaved: () => void saveController.pollRemote(),++ onLockDenied: (owner) => ui.setMessage(`🔒 held by ${owner} — you have read-only access`),++ onChange: () => {++ refreshVisuals()++ ui.refreshAll()++ },++ onLockLost: () => {++ // The lease went away mid-gesture: restore the exact starting state++ // rather than leaving half-applied local edits the server disagrees++ // with.++ if (xform.active) xform.cancel()++ selection.unfreeze()++ lockedForSelection = []++ ui.setMessage('⚠ edit lock lost — reselect to reacquire')++ },++ })++ : (await import('./collaboration/soloConnection.js')).createSoloConnection()// ── UI ──────────────────────────────────────────────────────────────const bindings = loadBindings(localStorage.getItem('openvibe.editor.bindings'))@@@ -765,6 +770,7 @@ export async function bootEditor(): Promise<void> {history,modelCache,editorToken,++ assetsUrl: endpoints.assets,onAssetsChanged: () => {registerCustomTextures(doc.textures() as MapTextureEntry[])ui.refreshAll()@@@ -996,6 +1002,8 @@ export async function bootEditor(): Promise<void> {// reloads the editor with the new token.if (!ssoToken()) {ui.setMessage('Sign in with OpenVibe (top right) before editing')++ } else if (endpoints.worldId) {++ ui.setMessage('Your world: saving makes it live. Publish it from /worlds.')}connection.connect()// Offer any unsaved work from a previous session. Never automatic: annew file mode 100644@@@ -0,0 +1,32 @@++import { describe, expect, it } from 'vitest'++import { mapEndpoints } from './mapEndpoints.js'++++describe('editor map endpoints', () => {++ it("edits the place's map by default, with uploads and collaboration", () => {++ expect(mapEndpoints('', 'https://openvibe.games')).toEqual({++ worldId: null,++ read: '/map.json',++ save: '/api/map',++ saveMethod: 'POST',++ assets: '/api/map-assets',++ collaboration: true,++ draftKey: 'https://openvibe.games/map.json',++ })++ })++++ it('edits a creator world alone, with its own draft and no uploads', () => {++ expect(mapEndpoints('?world=wabc123def0', 'https://openvibe.games')).toEqual({++ worldId: 'wabc123def0',++ read: '/api/worlds/wabc123def0/map',++ save: '/api/worlds/wabc123def0/map',++ saveMethod: 'PUT',++ assets: null,++ collaboration: false,++ draftKey: 'https://openvibe.games/api/worlds/wabc123def0/map',++ })++ })++++ it('ignores a world parameter that is not a world id', () => {++ expect(mapEndpoints('?world=../../api/map', 'https://x').worldId).toBeNull()++ })++})nnew file mode 100644@@@ -0,0 +1,59 @@++import { CREATOR_WORLD_ID } from '@openvibe/content'++import { editorToken } from './editorToken.js'++++/**++ * Where the editor reads and saves its map. Staff edit the place's map (/map.json, POST /api/map, uploads, live++ * collaboration). `/editor?world=<id>` edits a creator world instead: the owner's map at /api/worlds/<id>/map++ * (PUT, with the Network token), built-in materials only and no collaboration socket (that one is staff-only).++ */++export interface MapEndpoints {++ /** The creator world being edited, or null for the place's own map. */++ worldId: string | null++ /** GET: the v2 document, ETag = revision. */++ read: string++ save: string++ saveMethod: 'POST' | 'PUT'++ /** Content-addressed uploads (textures, models, paint masks), or null where uploads are off. */++ assets: string | null++ /** Live collaboration over /editor-ws. */++ collaboration: boolean++ /** The local draft store's key: one draft per map. */++ draftKey: string++}++++export function mapEndpoints(++ search: string = location.search,++ origin: string = location.origin,++): MapEndpoints {++ const requested = new URLSearchParams(search).get('world')++ if (requested && CREATOR_WORLD_ID.test(requested)) {++ const base = `/api/worlds/${requested}/map`++ return {++ worldId: requested,++ read: base,++ save: base,++ saveMethod: 'PUT',++ assets: null,++ collaboration: false,++ draftKey: `${origin}${base}`,++ }++ }++ return {++ worldId: null,++ read: '/map.json',++ save: '/api/map',++ saveMethod: 'POST',++ assets: '/api/map-assets',++ collaboration: true,++ draftKey: `${origin}/map.json`,++ }++}++++/** Reads the map being edited (a creator world's draft answers only its owner, so the token goes along). */++export function fetchMap(endpoints: MapEndpoints): Promise<Response> {++ const token = endpoints.worldId ? editorToken() : ''++ return fetch(endpoints.read, {++ cache: 'no-store',++ ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),++ })++}@@@ -21,6 +21,7 @@ import type { PaintSurfaceRegistry } from '../materials/paintSurfaceRegistry.js'import { surfaceDataFor, writeSurfaceData } from '../materials/paintController.js'import { DraftStore, indexedDbDraftStorage } from '../recovery/draftStore.js'import { editorToken } from './editorToken.js'++import { fetchMap, mapEndpoints, type MapEndpoints } from './mapEndpoints.js'export interface SaveControllerOptions {doc: EditorDocument@@@ -31,6 +32,8 @@ export interface SaveControllerOptions {/** Every live painted surface, keyed by (object, surface). */paintSurfaces: PaintSurfaceRegistrysetMessage: (m: string) => void++ /** Where the map is read and saved (default: the place's own map). */++ endpoints?: MapEndpoints}export interface SaveController {@@@ -50,6 +53,8 @@ export interface SaveController {export function createSaveController(opts: SaveControllerOptions): SaveController {const { doc, history, setMessage } = opts++ const endpoints = opts.endpoints ?? mapEndpoints('', location.origin)++ const who = endpoints.worldId ? 'you saved in another tab' : 'another admin saved'let baseRevision = opts.bootRevisionlet remoteDoc: MapFileV2 = doc.serialize()let nonHistoryDirt = false@@@ -60,7 +65,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlleconst drafts = new DraftStore({storage: indexedDbDraftStorage(),-- mapKey: `${location.origin}/map.json`,++ mapKey: endpoints.draftKey,})const isDirty = (): boolean => nonHistoryDirt || history.isDirty()@@@ -85,11 +90,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle*/let maskUploads = 0const uploadDirtyMasks = async (): Promise<void> => {++ const assets = endpoints.assets++ if (!assets) returnfor (const surface of opts.paintSurfaces.dirtySurfaces()) {const blob = await surface.mask.toBlob()if (!blob) continuetry {-- const resp = await fetch('/api/map-assets', {++ const resp = await fetch(assets, {method: 'POST',headers: { authorization: `Bearer ${tokenOf()}` },body: blob,@@@ -116,8 +123,8 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlleconst file = serialize()let resp: Responsetry {-- resp = await fetch('/api/map', {-- method: 'POST',++ resp = await fetch(endpoints.save, {++ method: endpoints.saveMethod,headers: {'content-type': 'application/json',authorization: `Bearer ${token}`,@@@ -138,13 +145,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControllenonHistoryDirt = falseremoteDoc = filevoid drafts.markSaved()-- setMessage('✅ saved — live in game')++ setMessage(endpoints.worldId ? '✅ saved — live in your world' : '✅ saved — live in game')notify()return}if (resp.status === 409) {await openConflict()-- setMessage('⚠ another admin saved first — your revision is stale')++ setMessage(`⚠ ${who} first — your revision is stale`)return}if (resp.status === 422) {@@@ -153,8 +160,10 @@ export function createSaveController(opts: SaveControllerOptions): SaveControllereturn}setMessage(-- resp.status === 403-- ? '⛔ not authorized (admin token/key required)'++ resp.status === 403 || resp.status === 401++ ? endpoints.worldId++ ? "⛔ sign in as the world's owner to save"++ : '⛔ not authorized (admin token/key required)': resp.status === 413? '⛔ map too large': `save failed (${resp.status})`,@@@ -168,7 +177,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle*/const pollRemote = async (): Promise<void> => {try {-- const resp = await fetch('/map.json')++ const resp = await fetchMap(endpoints)const served = resp.headers.get('etag')?.replace(/"/g, '')if (served && served === baseRevision) returnconst parsed = parseMapFile(await resp.json())@@@ -177,7 +186,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlleif (isDirty()) {remoteDoc = parsed.mapawait openConflict()-- setMessage('⚠ another admin saved while you have unsaved changes')++ setMessage(`⚠ ${who} while you have unsaved changes`)return}remoteDoc = parsed.map@@@ -186,7 +195,9 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle// NOT a merge: the local copy was clean, so it was REPLACED by the// remote one. Calling that "merged" would tell the user their edits// were combined with someone else's, which nothing here does.-- setMessage("🔄 loaded another admin's saved map")++ setMessage(++ endpoints.worldId ? '🔄 loaded the newer saved map' : "🔄 loaded another admin's saved map",++ )notify()} catch {// Offline poll; the next one will pick it up.@@@ -196,7 +207,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlleconst openConflict = async (): Promise<void> => {conflictOpen = truetry {-- const resp = await fetch('/map.json')++ const resp = await fetchMap(endpoints)const parsed = parseMapFile(await resp.json())conflictSummary = parsed.ok? describeDiff(diffMapFileV2(serialize(), parsed.map))@@@ -1,5 +1,8 @@import {++ CREATOR_WORLD_ID,createContent,++ creatorPlaceId,++ creatorWorldPack,setMapOverride,type MapLight,compileMapFileV2,@@@ -67,10 +70,26 @@ async function start(): Promise<void> {const uiRoot = document.getElementById('ui') as HTMLElementconst identity = getIdentity()-- const requestedGame = new URLSearchParams(location.search).get('game')-- const selectedPlace =-- requestedGame === 'salvage-rush' || requestedGame === 'scraplandia' ? requestedGame : undefined-- const placeQuery = selectedPlace ? `?game=${selectedPlace}` : ''++ const params = new URLSearchParams(location.search)++ const requestedGame = params.get('game')++ // A creator world (/worlds): its own place, the plain creator pack and the world's map.++ const requestedWorld = params.get('world')++ const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null++ const selectedPlace = worldId++ ? creatorPlaceId(worldId)++ : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'++ ? requestedGame++ : undefined++ const placeQuery = worldId ? `?world=${worldId}` : selectedPlace ? `?game=${selectedPlace}` : ''++ // A draft world's map answers only its owner, so the map read carries the Network token when there is one.++ const mapRequest = (): Promise<Response> => {++ if (!worldId) return fetch(`/map.json${placeQuery}`)++ const token = localStorage.getItem('ovg_sso')++ return fetch(`/api/worlds/${worldId}/map`, {++ cache: 'no-store',++ ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),++ })++ }// Fetch the active definition set before constructing game systems or sending hello.// A fresh client can join after a staff script install or reload changed the digest.let content = createContent()@@@ -86,7 +105,9 @@ async function start(): Promise<void> {const builtIn =data.game === 'salvage-rush'? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK-- : undefined++ : data.game === 'creator' && worldId++ ? creatorWorldPack(worldId)++ : undefinedconst active = createContent(data.packs, builtIn)if (active.digest === data.digest) content = active}@@@ -105,7 +126,7 @@ async function start(): Promise<void> {let mapLightsBoot: MapLight[] | undefinedtry {// /map.json is the v2 document, parsed and validated on the way in.-- const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())++ const parsed = parseMapFile(await (await mapRequest()).json())if (parsed.ok) {registerMapAssets({textures: parsed.map.textures as MapTextureEntry[],@@@ -381,7 +402,7 @@ async function start(): Promise<void> {if (msg.t === 'map_reload') {void (async () => {try {-- const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())++ const parsed = parseMapFile(await (await mapRequest()).json())if (!parsed.ok) returnregisterMapAssets({textures: parsed.map.textures as MapTextureEntry[],@@@ -487,8 +508,18 @@ async function start(): Promise<void> {.catch(() => {})}, 2000)}-- connection.onClose = () => {++ connection.onClose = (code) => {if (rejected) return++ if (code === 4005) {++ // A creator world that is not open: unpublished, taken down, or every world slot is busy. Reloading would++ // only ask again, so say so and leave the way back.++ rejected = true++ hud.setStatus(++ 'This world is not open right now. It may be busy or unpublished: try again in a minute.',++ )++ hud.toast('World not open — see /worlds', true)++ return++ }// Come back on our own instead of freezing on a dead socket.hud.setStatus('connection lost — reconnecting automatically…')hud.toast('Disconnected — reconnecting…', true)@@@ -93,7 +93,8 @@ export async function fetchWsTicket(args: {export class Connection {private ws: WebSocket | null = nullonMessage: ((msg: ServerMessage) => void) | null = null-- onClose: (() => void) | null = null++ /** The socket closed; `code` is the server's close code (4005: the creator world is not open). */++ onClose: ((code: number) => void) | null = nullasync connect(url: string,@@@ -146,7 +147,7 @@ export class Connection {}this.onMessage?.(msg)}-- ws.onclose = () => this.onClose?.()++ ws.onclose = (event) => this.onClose?.(event.code)// Identity was resolved at the upgrade; hello carries no identity field.this.send(helloForContent(name, appearance, slot, contentDigest, place))}nnew file mode 100644@@@ -0,0 +1,282 @@++export {}++++/** /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete). */++++interface World {++ id: string++ title: string++ summary: string++ owner: string++ plays: number++ online: number++ published_at: string | null++ updated_at: string++ play_url: string++ status?: 'draft' | 'published' | 'hidden'++ edit_url?: string++}++++interface Mine {++ worlds: World[]++ max: number++ staff: boolean++}++++const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T++const signedOut = $('signed-out')++const signedIn = $('signed-in')++const mineGrid = $('mine')++const mineStatus = $('mine-status')++const publishedGrid = $('published')++const publishedStatus = $('published-status')++const moreRow = $('more-row')++const moreButton = $<HTMLButtonElement>('more')++const createForm = $<HTMLFormElement>('create')++const createTitle = $<HTMLInputElement>('create-title')++const createSummary = $<HTMLTextAreaElement>('create-summary')++const createButton = $<HTMLButtonElement>('create-button')++const createStatus = $('create-status')++++/** The Network access token the site keeps after sign-in (the same one /play and /editor use). */++function token(): string {++ const stored = localStorage.getItem('ovg_sso')++ if (stored) return stored++ const cookie = document.cookie.split('; ').find((c) => c.startsWith('ovg_sso='))++ return cookie ? decodeURIComponent(cookie.slice('ovg_sso='.length)) : ''++}++++async function api<T>(path: string, init: RequestInit = {}): Promise<T> {++ const auth = token()++ const resp = await fetch(path, {++ ...init,++ headers: {++ ...(init.body ? { 'content-type': 'application/json' } : {}),++ ...(auth ? { authorization: `Bearer ${auth}` } : {}),++ ...(init.headers as Record<string, string> | undefined),++ },++ })++ if (resp.status === 204) return undefined as T++ const body = (await resp.json().catch(() => ({}))) as { error?: string; detail?: string }++ if (!resp.ok) throw new ApiError(resp.status, body.detail ?? body.error ?? `HTTP ${resp.status}`)++ return body as T++}++++class ApiError extends Error {++ constructor(++ readonly status: number,++ message: string,++ ) {++ super(message)++ }++}++++function el<K extends keyof HTMLElementTagNameMap>(++ tag: K,++ props: { className?: string; text?: string } = {},++ ...children: (Node | null)[]++): HTMLElementTagNameMap[K] {++ const node = document.createElement(tag)++ if (props.className) node.className = props.className++ if (props.text !== undefined) node.textContent = props.text++ for (const child of children) if (child) node.append(child)++ return node++}++++function link(text: string, href: string, primary = false): HTMLAnchorElement {++ const a = el('a', { className: primary ? 'button primary' : 'button', text })++ a.href = href++ return a++}++++function button(++ text: string,++ onClick: (b: HTMLButtonElement) => void,++ className = '',++): HTMLButtonElement {++ const b = el('button', { className, text })++ b.type = 'button'++ b.addEventListener('click', () => onClick(b))++ return b++}++++const count = (n: number, one: string, many: string): string =>++ `${n.toLocaleString()} ${n === 1 ? one : many}`++++function stats(world: World): string {++ const parts = [count(world.plays, 'visit', 'visits')]++ if (world.online > 0) parts.unshift(`${world.online} playing now`)++ return parts.join(' · ')++}++++function publishedCard(world: World, staff: boolean): HTMLElement {++ const card = el(++ 'article',++ { className: 'card' },++ el('h3', { text: world.title }),++ el('p', { className: 'by', text: `by ${world.owner}` }),++ world.summary ? el('p', { className: 'summary', text: world.summary }) : null,++ el('p', { className: 'stats', text: stats(world) }),++ )++ const actions = el('div', { className: 'actions' }, link('▶ Play', world.play_url, true))++ if (staff)++ actions.append(++ button(++ 'Take down',++ (b) => void change(b, world.id, { status: 'hidden' }, 'Take this world down for everyone?'),++ 'danger',++ ),++ )++ card.append(actions)++ return card++}++++function mineCard(world: World): HTMLElement {++ const status = world.status ?? 'draft'++ const label = status === 'published' ? 'Published' : status === 'hidden' ? 'Taken down' : 'Draft'++ const card = el(++ 'article',++ { className: 'card' },++ el('span', { className: `badge ${status}`, text: label }),++ el('h3', { text: world.title }),++ world.summary ? el('p', { className: 'summary', text: world.summary }) : null,++ el('p', { className: 'stats', text: stats(world) }),++ )++ const actions = el('div', { className: 'actions' })++ if (world.edit_url) actions.append(link('Edit', world.edit_url, true))++ if (status !== 'hidden')++ actions.append(link(status === 'published' ? 'Play' : 'Try it', world.play_url))++ if (status === 'draft')++ actions.append(button('Publish', (b) => void change(b, world.id, { status: 'published' })))++ if (status === 'published')++ actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))++ actions.append(++ button('Rename', (b) => {++ const title = prompt('New title (up to 60 characters)', world.title)++ if (title !== null && title.trim() && title !== world.title)++ void change(b, world.id, { title })++ }),++ button(++ 'Delete',++ (b) =>++ void remove(++ b,++ world.id,++ `Delete “${world.title}”? Its map and everything built in it go too.`,++ ),++ 'danger',++ ),++ )++ if (status === 'hidden')++ card.append(++ el('p', {++ className: 'summary',++ text: 'Staff took this world down. You can still edit it; ask on openvibe.help to bring it back.',++ }),++ )++ card.append(actions)++ return card++}++++let staff = false++let page = 1++++async function change(++ b: HTMLButtonElement,++ id: string,++ patch: Record<string, string>,++ confirmText?: string,++): Promise<void> {++ if (confirmText && !confirm(confirmText)) return++ b.disabled = true++ try {++ await api(`/api/worlds/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })++ await Promise.all([loadMine(), loadPublished(true)])++ } catch (err) {++ b.disabled = false++ alert(err instanceof Error ? err.message : 'That did not work. Try again.')++ }++}++++async function remove(b: HTMLButtonElement, id: string, confirmText: string): Promise<void> {++ if (!confirm(confirmText)) return++ b.disabled = true++ try {++ await api(`/api/worlds/${id}`, { method: 'DELETE' })++ await Promise.all([loadMine(), loadPublished(true)])++ } catch (err) {++ b.disabled = false++ alert(err instanceof Error ? err.message : 'That did not work. Try again.')++ }++}++++async function loadMine(): Promise<void> {++ if (!token()) {++ signedOut.hidden = false++ signedIn.hidden = true++ return++ }++ try {++ const mine = await api<Mine>('/api/worlds/mine')++ staff = mine.staff++ signedOut.hidden = true++ signedIn.hidden = false++ mineGrid.replaceChildren(...mine.worlds.map(mineCard))++ const full = mine.worlds.length >= mine.max++ createButton.disabled = full++ mineStatus.textContent = mine.worlds.length++ ? full++ ? `You have ${mine.max} worlds, the most one person keeps. Delete one to make another.`++ : ''++ : 'No worlds yet. Give your first one a title and open it in the editor.'++ } catch (err) {++ if (err instanceof ApiError && err.status === 401) {++ signedOut.hidden = false++ signedIn.hidden = true++ return++ }++ mineStatus.textContent = 'Your worlds could not be loaded. Refresh to try again.'++ }++}++++async function loadPublished(reset = false): Promise<void> {++ if (reset) page = 1++ try {++ const data = await api<{ worlds: World[]; more: boolean }>(`/api/worlds?page=${page}`)++ const cards = data.worlds.map((w) => publishedCard(w, staff))++ if (page === 1) publishedGrid.replaceChildren(...cards)++ else publishedGrid.append(...cards)++ moreRow.hidden = !data.more++ publishedStatus.textContent =++ page === 1 && data.worlds.length === 0++ ? 'No published worlds yet. Yours could be the first.'++ : ''++ } catch {++ publishedStatus.textContent = 'Worlds could not be loaded. Refresh to try again.'++ }++}++++createForm.addEventListener('submit', (event) => {++ event.preventDefault()++ void (async () => {++ createButton.disabled = true++ createStatus.textContent = 'Making your world…'++ try {++ const world = await api<World>('/api/worlds', {++ method: 'POST',++ body: JSON.stringify({ title: createTitle.value, summary: createSummary.value }),++ })++ createStatus.textContent = ''++ if (world.edit_url) location.href = world.edit_url++ } catch (err) {++ createStatus.textContent = err instanceof Error ? err.message : 'That did not work.'++ createButton.disabled = false++ }++ })()++})++++moreButton.addEventListener('click', () => {++ page++++ void loadPublished()++})++++void loadMine().then(() => loadPublished(true))@@@ -24,6 +24,7 @@ export default defineConfig({server.middlewares.use((req, _res, next) => {if (req.url === '/play') req.url = '/play.html'else if (req.url === '/editor') req.url = '/editor.html'++ else if (req.url === '/worlds') req.url = '/worlds.html'else if (/^\/play\/[a-z0-9-]+(\?|$)/.test(req.url ?? '')) req.url = '/casual.html'next()})@@@ -53,6 +54,7 @@ export default defineConfig({main: 'play.html',editor: 'editor.html',leaderboard: 'leaderboard.html',++ worlds: 'worlds.html',casual: 'casual.html',},// Babylon's core as a chain of layers no larger than 400 KB each, not one 2 MB chunk (scripts/vendorLayers.ts).nnew file mode 100644@@@ -0,0 +1,293 @@++<!doctype html>++<html lang="en">++ <head>++ <meta charset="UTF-8" />++ <meta name="viewport" content="width=device-width, initial-scale=1.0" />++ <title>Creator worlds · OpenVibe.Games</title>++ <meta++ name="description"++ content="Build your own 3D world in the OpenVibe.Games map editor and publish it for anyone to join in the browser. Browse the worlds players made."++ />++ <link rel="canonical" href="https://openvibe.games/worlds" />++ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />++ <style>++ :root {++ color-scheme: dark;++ --accent: #3b82f6;++ --accent-light: #60a5fa;++ --secondary: #22d3ee;++ --bg: #0a0f1c;++ --bg-raised: #101828;++ --bg-card: rgba(19, 28, 46, 0.88);++ --border: #1f2d47;++ --text: #e6edf7;++ --text-dim: #96a7c2;++ --text-faint: #7386a3;++ --good: #34d399;++ --warn: #fbbf24;++ --bad: #f87171;++ }++ * {++ box-sizing: border-box;++ }++ body {++ margin: 0;++ min-height: 100vh;++ color: var(--text);++ background:++ radial-gradient(900px 420px at 20% -10%, rgba(59, 130, 246, 0.18), transparent 70%),++ radial-gradient(700px 360px at 90% 0%, rgba(34, 211, 238, 0.1), transparent 70%),++ var(--bg);++ font:++ 16px/1.5 system-ui,++ -apple-system,++ sans-serif;++ }++ a {++ color: var(--accent-light);++ }++ main {++ max-width: 980px;++ margin: 0 auto;++ padding: 20px 16px 64px;++ }++ .crumbs {++ display: flex;++ flex-wrap: wrap;++ gap: 14px;++ font-size: 14px;++ }++ .eyebrow {++ margin: 28px 0 4px;++ font-size: 12px;++ font-weight: 800;++ letter-spacing: 0.14em;++ color: var(--secondary);++ }++ h1 {++ margin: 0 0 6px;++ font-size: clamp(28px, 5vw, 40px);++ font-weight: 900;++ letter-spacing: 0.01em;++ }++ h2 {++ margin: 34px 0 12px;++ font-size: 20px;++ font-weight: 800;++ }++ .lede {++ margin: 0 0 6px;++ max-width: 720px;++ color: var(--text-dim);++ }++ .panel {++ border: 1px solid var(--border);++ border-radius: 18px;++ background: var(--bg-card);++ padding: 16px;++ }++ .grid {++ display: grid;++ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));++ gap: 14px;++ }++ .card {++ display: flex;++ flex-direction: column;++ gap: 8px;++ min-width: 0;++ padding: 16px;++ border: 1px solid var(--border);++ border-radius: 16px;++ background: var(--bg-raised);++ }++ .card h3 {++ margin: 0;++ font-size: 18px;++ font-weight: 800;++ overflow-wrap: anywhere;++ }++ .card .by,++ .card .stats {++ margin: 0;++ font-size: 14px;++ color: var(--text-faint);++ }++ .card .summary {++ margin: 0;++ color: var(--text-dim);++ overflow-wrap: anywhere;++ }++ .card .actions {++ display: flex;++ flex-wrap: wrap;++ gap: 8px;++ margin-top: auto;++ padding-top: 6px;++ }++ .badge {++ align-self: flex-start;++ padding: 2px 10px;++ border-radius: 999px;++ border: 1px solid var(--border);++ font-size: 12px;++ font-weight: 750;++ letter-spacing: 0.04em;++ text-transform: uppercase;++ }++ .badge.published {++ color: var(--good);++ border-color: rgba(52, 211, 153, 0.45);++ }++ .badge.draft {++ color: var(--warn);++ border-color: rgba(251, 191, 36, 0.45);++ }++ .badge.hidden {++ color: var(--bad);++ border-color: rgba(248, 113, 113, 0.45);++ }++ .button,++ button {++ display: inline-flex;++ align-items: center;++ justify-content: center;++ min-height: 40px;++ padding: 8px 16px;++ border: 1px solid var(--border);++ border-radius: 10px;++ background: var(--bg);++ color: var(--text);++ font: inherit;++ font-weight: 650;++ text-decoration: none;++ cursor: pointer;++ }++ .button.primary,++ button.primary {++ border: 0;++ background: linear-gradient(120deg, var(--accent), #2563eb);++ color: #fff;++ font-weight: 800;++ }++ button.danger {++ color: var(--bad);++ }++ button:disabled {++ opacity: 0.5;++ cursor: default;++ }++ .button:focus-visible,++ button:focus-visible,++ input:focus-visible,++ textarea:focus-visible {++ outline: 2px solid var(--accent-light);++ outline-offset: 2px;++ }++ form {++ display: grid;++ gap: 10px;++ }++ label {++ display: grid;++ gap: 4px;++ font-size: 14px;++ font-weight: 650;++ color: var(--text-dim);++ }++ input,++ textarea {++ width: 100%;++ padding: 10px 12px;++ border: 1px solid var(--border);++ border-radius: 10px;++ background: var(--bg);++ color: var(--text);++ font: inherit;++ }++ textarea {++ min-height: 72px;++ resize: vertical;++ }++ .row {++ display: flex;++ flex-wrap: wrap;++ align-items: center;++ gap: 10px;++ }++ .status {++ margin: 0;++ color: var(--text-dim);++ }++ .status:empty {++ display: none;++ }++ .note {++ margin-top: 18px;++ font-size: 14px;++ color: var(--text-faint);++ }++ #more-row {++ margin-top: 14px;++ }++ </style>++ <script type="module" src="/src/pages/worlds.ts"></script>++ </head>++ <body>++ <main>++ <nav class="crumbs" aria-label="Breadcrumb">++ <a href="/">OpenVibe.Games</a><a href="/play">Play Scraplandia</a++ ><a href="/leaderboard.html">Leaderboard</a>++ </nav>++ <p class="eyebrow">CREATOR WORLDS</p>++ <h1>Build a world. Share it.</h1>++ <p class="lede">++ Shape terrain, place buildings, lights, zones, resource nodes and props in the map editor,++ then publish your world for anyone to join from their browser. A world is a sandbox: what++ players gather or build inside it stays in that world.++ </p>++++ <h2 id="mine-title">Your worlds</h2>++ <section class="panel" aria-labelledby="mine-title">++ <div id="signed-out" hidden>++ <p class="status">Sign in with your OpenVibe account to make a world.</p>++ <p class="row"><a class="button primary" href="/auth/login?next=%2Fworlds">Sign in</a></p>++ </div>++ <div id="signed-in" hidden>++ <form id="create">++ <label++ >Title++ <input id="create-title" name="title" maxlength="60" required autocomplete="off"++ /></label>++ <label++ >What is it? (optional)++ <textarea id="create-summary" name="summary" maxlength="280"></textarea>++ </label>++ <div class="row">++ <button class="primary" type="submit" id="create-button">Make a world</button>++ <span class="status" id="create-status" role="status"></span>++ </div>++ </form>++ <div class="grid" id="mine" style="margin-top: 16px"></div>++ </div>++ <p class="status" id="mine-status" role="status"></p>++ </section>++++ <h2 id="published-title">Published worlds</h2>++ <div class="grid" id="published" aria-labelledby="published-title"></div>++ <p class="status" id="published-status" role="status">Loading worlds…</p>++ <div class="row" id="more-row" hidden>++ <button type="button" id="more">More worlds</button>++ </div>++++ <p class="note">++ Creator worlds use the editor's built-in materials for now; uploaded textures and models++ stay with staff maps. Characters are not saved inside someone's world, so nothing earned++ there reaches Scraplandia, the leaderboards or your inventory.++ </p>++ <noscript>++ <p class="note">This page needs JavaScript to list worlds. Play Scraplandia at /play.</p>++ </noscript>++ </main>++ </body>++</html>@@@ -66,6 +66,19 @@ export interface ServerConfig {instancesPerPlace: number/** Internal runtime slot; never read from the public environment. */instanceSlot?: number++ /**++ * Internal: a creator world's room (never read from the environment). No NPC population and no world events++ * (both are Scraplandia's), and characters are not saved there (worlds/sandboxStore.ts).++ */++ sandbox?: boolean++ /** GAMES_CREATOR_WORLDS_MAX: creator worlds running at once in this process (0 turns joining them off). */++ creatorWorldsMax: number++ /** GAMES_CREATOR_WORLDS_PER_PERSON: worlds one person can keep. */++ creatorWorldsPerPerson: number++ /** GAMES_CREATOR_WORLD_IDLE_SECONDS: an empty creator world stops after this long. */++ creatorWorldIdleSeconds: number++ /** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */++ creatorWorldPlayers: numbermaxPlayers: numberpersistFlushSeconds: numbermetricsLogSeconds: number@@@ -205,6 +218,10 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',++ creatorWorldsMax: intEnv(env, 'GAMES_CREATOR_WORLDS_MAX', 2),++ 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),}}@@@ -164,7 +164,8 @@ export class GameServer {async load(): Promise<void> {await this.systems.environment.load()await this.systems.economy.load()-- await this.systems.npcs.load()++ // A creator world has no NPC population: the content spawns are Scraplandia's.++ if (!this.ctx.config.sandbox) await this.systems.npcs.load()}get currentTick(): number {@@@ -299,7 +300,7 @@ export class GameServer {// 5. NPC simulation (LOD-aware; abstract NPCs cost nothing here).// The sound buffer resets once a second inside the same step.-- s.npcs.tick(ctx, dt)++ if (!ctx.config.sandbox) s.npcs.tick(ctx, dt)// 6. Crafting queues (completions grant crafting/construction XP).s.production.tickCraftQueues()@@@ -309,7 +310,8 @@ export class GameServer {s.environment.tick(ctx, dt)// 8. Once a second: world events (supply drops, extraction).-- s.worldEvents.tick(ctx, dt)++ // Not in a creator world: the events' drop and extraction sites are Scraplandia's.++ if (!ctx.config.sandbox) s.worldEvents.tick(ctx, dt)// 9. Once a second: production sweep (generators, machines, tanks, crops).s.production.tickProduction()@@@ -2,11 +2,25 @@ import type { ClientMessage } from '@openvibe/protocol'import type { GameConnection } from './systems/context.js'import type { InstanceHub, InstanceRoom } from './instanceHub.js'++/**++ * Places started on demand (creator worlds, worlds/worldHost.ts). The router asks for a hub inside its admission++ * chain, so starting one is serialized with every other join.++ */++export interface DynamicPlaces {++ /** Whether `place` is one of these (a creator world id), whatever its state. */++ claims(place: string): boolean++ /** The running hub for `place`, started if needed; null refuses the join (unknown, not joinable, or full). */++ open(place: string, conn: GameConnection): Promise<InstanceHub | null>++ /** Every hub running now. */++ hubs(): Iterable<InstanceHub>++ shutdown(): Promise<boolean>++}++/** A single WebSocket endpoint over independent, place-fenced M4 room hubs. */export class PlaceRouter {private readonly connections = new Map<GameConnection,-- { hub: InstanceHub; key: string | null; admitted: boolean }++ { hub: InstanceHub | null; key: string | null; admitted: boolean }>()private readonly closed = new WeakSet<GameConnection>()private admission: Promise<void> = Promise.resolve()@@@ -14,10 +28,26 @@ export class PlaceRouter {constructor(private readonly hubs: ReadonlyMap<string, InstanceHub>,private readonly defaultPlace: string,++ private readonly dynamic: DynamicPlaces | null = null,) {if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)}++ /** Every hub: the built-in places, then the dynamic ones running now. */++ private allHubs(): InstanceHub[] {++ return [...this.hubs.values(), ...(this.dynamic ? this.dynamic.hubs() : [])]++ }++++ /** Runs `fn` in the admission chain (after every join already queued, before any queued later). */++ serialize<T>(fn: () => Promise<T>): Promise<T> {++ const run = this.admission.then(fn)++ this.admission = run.then(++ () => undefined,++ () => undefined,++ )++ return run++ }++resolve(place: string | undefined): string {return place && this.hubs.has(place) ? place : this.defaultPlace}@@@ -29,8 +59,10 @@ export class PlaceRouter {conn.close(4001, 'hello_first')return}-- const hub = this.hubs.get(this.resolve(msg.place))-- if (!hub) throw new Error('default hub missing')++ const dynamicPlace =++ msg.place && !this.hubs.has(msg.place) && this.dynamic?.claims(msg.place) ? msg.place : null++ const hub = dynamicPlace ? null : (this.hubs.get(this.resolve(msg.place)) ?? null)++ if (!dynamicPlace && !hub) throw new Error('default hub missing')const identity = conn.identityconst key = identity? `${'subject' in identity ? identity.subject : `guest:${identity.guestKeyHash}`}:${msg.slot}`@@@ -46,47 +78,61 @@ export class PlaceRouter {if (other === conn || prior.key !== key || !prior.admitted) continueother.close(4004, 'session_superseded')this.onDisconnect(other)-- await Promise.all(prior.hub.activeRooms.map((room) => room.game.drain(5000)))++ await Promise.all((prior.hub?.activeRooms ?? []).map((room) => room.game.drain(5000)))}}-- if (!this.closed.has(conn)) {-- const current = this.connections.get(conn)-- if (current) current.admitted = true-- hub.onMessage(conn, msg)-- await hub.waitForConnection(conn)++ if (this.closed.has(conn)) return++ const current = this.connections.get(conn)++ if (!current) return++ // A creator world starts here, after the superseded session's save above.++ const target =++ current.hub ?? (dynamicPlace ? await this.dynamic?.open(dynamicPlace, conn) : null)++ if (this.closed.has(conn)) return++ if (!target) {++ conn.close(4005, 'world_unavailable')++ this.onDisconnect(conn)++ return}++ current.hub = target++ current.admitted = true++ target.onMessage(conn, msg)++ await target.waitForConnection(conn)}).catch(() => conn.close(1011, 'instance_unavailable'))return}-- active.hub.onMessage(conn, msg)++ // A creator world's hub is set once it has started; nothing before the welcome needs it.++ active.hub?.onMessage(conn, msg)}onDisconnect(conn: GameConnection): void {this.closed.add(conn)-- this.connections.get(conn)?.hub.onDisconnect(conn)++ this.connections.get(conn)?.hub?.onDisconnect(conn)this.connections.delete(conn)}get activeRooms(): readonly InstanceRoom[] {-- return [...this.hubs.values()].flatMap((hub) => hub.activeRooms)++ return this.allHubs().flatMap((hub) => hub.activeRooms)}get onlineCount(): number {-- return [...this.hubs.values()].reduce((total, hub) => total + hub.onlineCount, 0)++ return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)}step(): void {-- for (const hub of this.hubs.values()) hub.step()++ for (const hub of this.allHubs()) hub.step()}async heartbeat(): Promise<void> {-- await Promise.all([...this.hubs.values()].map((hub) => hub.heartbeat()))++ await Promise.all(this.allHubs().map((hub) => hub.heartbeat()))}async shutdown(): Promise<boolean> {await this.admission-- const drained = await Promise.all([...this.hubs.values()].map((hub) => hub.shutdown()))++ const drained = await Promise.all([++ ...[...this.hubs.values()].map((hub) => hub.shutdown()),++ this.dynamic ? this.dynamic.shutdown() : Promise.resolve(true),++ ])this.connections.clear()return drained.every(Boolean)}@@@ -4,14 +4,22 @@ import { dirname, join } from 'node:path'import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'import { createValkey } from 'openvibe-sdk/valkey'import {++ CREATOR_WORLD_ID,compileMapFileV2,createContent,++ creatorWorldPack,parseMapFile,SCRAPLANDIA_PACK,setMapOverride,++ type ContentRegistry,} from '@openvibe/content'import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'--import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'++import {++ InstanceRegistry,++ createCreatorWorldStore,++ openPgStore,++ type InstanceLease,++} from '@openvibe/persistence'import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'import { createConsoleLogger } from '@openvibe/shared'import { loadConfig } from './config.js'@@@ -76,6 +84,9 @@ import { createPlayBoards } from './platform/playBoards.js'import { createCasualRoutes } from './casual/routes.js'import { pruneCasualActions } from './casual/store.js'import { CASUAL_GAMES, validatePack } from '@openvibe/casual'++import { CreatorWorldHost } from './worlds/worldHost.js'++import { createWorldHubStarter } from './worlds/startWorldHub.js'++import { createWorldRoutes } from './worlds/worldRoutes.js'/*** Dedicated authoritative server entry point.@@@ -573,7 +584,43 @@ async function main(): Promise<void> {[config.placeId, hub],[secondaryPlace, secondaryHub],])-- const router = new PlaceRouter(hubs, config.placeId)++ // ── Creator worlds (worlds/): players' own places, started on the first join, stopped when empty ──++ // 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 => {++ let built = creatorContent.get(worldId)++ if (!built) {++ if (creatorContent.size >= 256) creatorContent.clear()++ built = createContent([], creatorWorldPack(worldId))++ creatorContent.set(worldId, built)++ }++ return built++ }++ const startWorldHub = createWorldHubStarter({++ db,++ rapier,++ config,++ metrics,++ log,++ contentFor: contentForWorld,++ })++ const worldHost = new CreatorWorldHost({++ store: creatorWorlds,++ start: startWorldHub,++ log: log.child({ system: 'creator-worlds' }),++ maxRunning: config.creatorWorldsMax,++ idleMs: config.creatorWorldIdleSeconds * 1000,++ })++ const router = new PlaceRouter(hubs, config.placeId, worldHost)++ // The idle sweep runs in the admission chain, so a world never stops under a join that is starting it.++ const worldSweepTimer = setInterval(() => {++ void router++ .serialize(() => worldHost.sweep())++ .catch((err: unknown) => log.warn('creator world sweep failed', { error: String(err) }))++ }, 30_000)++ worldSweepTimer.unref()const contentByPlace = new Map([[config.placeId, content],[secondaryPlace, secondaryContent],@@@ -711,6 +758,30 @@ async function main(): Promise<void> {// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).const clientAddress = (req: IncomingMessage): string =>resolveClientAddress(req, config.trustProxy)++ // Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.++ const serveWorlds = createWorldRoutes({++ store: creatorWorlds,++ host: {++ online: () => worldHost.online(),++ applyMap: (id, map) => {++ void router.serialize(async () => worldHost.applyMap(id, map))++ },++ changed: (world, id) => router.serialize(() => worldHost.changed(world, id)),++ remove: (id) =>++ router.serialize(async () => {++ await worldHost.changed(null, id)++ await creatorWorlds.remove(id)++ }),++ },++ resolveUser: (token) =>++ config.networkAuthUrl++ ? resolveNetworkUser(config.networkAuthUrl, token)++ : Promise.resolve(null),++ limits: actorLimits,++ address: clientAddress,++ log: log.child({ system: 'worlds' }),++ perPerson: config.creatorWorldsPerPerson,++ })// The casual games' JSON API (plan T22).const serveCasual = createCasualRoutes({db,@@@ -771,6 +842,30 @@ async function main(): Promise<void> {{handle: (req, res) => {if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {++ const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get(++ 'world',++ )++ if (requestedWorld !== null) {++ // A creator world: the plain creator pack and no installed packs (the client builds the same set).++ if (!CREATOR_WORLD_ID.test(requestedWorld)) {++ res.writeHead(404, {++ 'content-type': 'application/json',++ 'cache-control': 'no-store',++ })++ 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: [],++ }),++ )++ return true++ }const requested = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')const placeId = router.resolve(requested ?? undefined)const selectedContent = contentByPlace.get(placeId)@@@ -792,6 +887,7 @@ async function main(): Promise<void> {return true}if (serveCasual(req, res)) return true++ if (serveWorlds(req, res)) return trueif (handleGameRead(req, res, {db,@@@ -1020,6 +1116,7 @@ async function main(): Promise<void> {running = falseclearInterval(metricsTimer)clearInterval(heartbeatTimer)++ clearInterval(worldSweepTimer)clearInterval(pruneTimer)editors.stop()const mirrorDone = mirror?.stop()@@@ -49,6 +49,8 @@ export function createGamesActorLimits(// flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })++ // Creator worlds (worlds/worldRoutes.ts): making, renaming, publishing and deleting a world, per person.++ const worldWrite = limiter('games.world.write', { minute: 20, hour: 200 })// Casual games (plan T22): a session open per address, and each player's actions (a tap is one request).const casualSession = limiter('games.casual.session', { minute: 30, hour: 600 })const casualAction = limiter('games.casual.action', { minute: 120, hour: 3000 })@@@ -71,6 +73,7 @@ export function createGamesActorLimits(ticket: run(ticket),upgrade: run(upgrade),publicRead: run(publicRead),++ worldWrite: run(worldWrite),casualSession: run(casualSession),casualAction: run(casualAction),}@@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {text: pack.tagline,priority: 0.8,})),++ {++ title: 'Creator worlds',++ url: `${SITE}/worlds`,++ note: 'worlds players built in the map editor and published, each playable in the browser',++ text: 'Creator worlds: players shape terrain and place buildings, lights, zones, resource nodes and props in the OpenVibe.Games map editor, then publish the world for anyone to join from the browser. A world is a sandbox: what players gather or build inside it stays in that world.',++ priority: 0.7,++ },{title: 'What shipped',url: `${SITE}/updates`,@@@ -192,6 +192,7 @@ export function createHttpServer({ href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },{ href: '/leaderboard.html', label: 'Leaderboard' },++ { href: '/worlds', label: 'Creator worlds' },{ href: '/editor', label: 'Map editor' },]const playLinks: NotFoundLink[] = [@@@ -791,7 +792,9 @@ export function createHttpServer(? 'play.html': url === '/editor'? 'editor.html'-- : null++ : url === '/worlds'++ ? 'worlds.html'++ : null// Static files with path traversal guard.const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')@@@ -65,6 +65,7 @@ function openLimits() {modWrite: () => Promise.resolve(true),ticket: () => Promise.resolve(true),publicRead: () => Promise.resolve(true),++ worldWrite: () => Promise.resolve(true),casualSession: () => Promise.resolve(true),casualAction: () => Promise.resolve(true),upgrade: (req: unknown, _res: unknown, actor: string) =>@@@ -114,7 +114,12 @@ describe('account export and deletion (ADR-033)', () => {'/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8/confirmations',)expect(validate('network.account-deletion-confirmation@1', conf.body).valid).toBe(true)-- expect(conf.body.erased).toEqual({ characters: 2, identity_rows: 0, casual_saves: 0 })++ expect(conf.body.erased).toEqual({++ characters: 2,++ identity_rows: 0,++ casual_saves: 0,++ creator_worlds: 0,++ })expect(conf.body.retained).toEqual({ structures_unowned: 0 })expect(await data.apply(del)).toBe('unchanged')expect(sent.length).toBe(2)nnew file mode 100644@@@ -0,0 +1,221 @@++import {++ createContent,++ creatorWorldPack,++ emptyMapV2,++ SCRAPLANDIA_PACK,++ type ContentRegistry,++} from '@openvibe/content'++import {++ InstanceRegistry,++ createCreatorWorldStore,++ openPgStore,++ type CreatorWorld,++ type InstanceLease,++} from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'++import { PROTOCOL_VERSION, defaultAppearance, type ServerMessage } from '@openvibe/protocol'++import { createConsoleLogger } from '@openvibe/shared'++import { sql } from 'openvibe-sdk/db'++import { expect, it } from 'vitest'++import { loadConfig } from '../config.js'++import { GameServer, type GameConnection } from '../game/gameServer.js'++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 { CreatorWorldHost, mayJoin } from './worldHost.js'++++const log = createConsoleLogger({ app: 'creator-worlds-test' }, 'error')++const KIM = 'usr_01J0000000000000000000000K'++const LEE = 'usr_01J0000000000000000000000L'++++async function until(check: () => boolean): Promise<void> {++ for (let i = 0; i < 300; i++) {++ if (check()) return++ await new Promise((resolve) => setTimeout(resolve, 10))++ }++ throw new Error('timed out')++}++++it('decides who joins a world that is not published', () => {++ const world = { ownerSubject: KIM, status: 'draft' } as CreatorWorld++ const as = (identity: NonNullable<GameConnection['identity']>): GameConnection => ({++ identity,++ send: () => {},++ close: () => {},++ })++ const kim = as({ subject: KIM, rank: null, authIat: null })++ const lee = as({ subject: LEE, rank: null, authIat: null })++ const admin = as({ subject: LEE, rank: 'admin', authIat: null })++ const guest = as({ guestKeyHash: 'a'.repeat(64) })++ expect([kim, lee, admin, guest].map((c) => mayJoin(world, c))).toEqual([true, false, true, false])++ const hidden = { ...world, status: 'hidden' } as CreatorWorld++ expect([kim, admin].map((c) => mayJoin(hidden, c))).toEqual([false, true])++ const published = { ...world, status: 'published' } as CreatorWorld++ expect(mayJoin(published, guest)).toBe(true)++})++++it('starts a published creator world on the first join, sandboxes characters, refuses beyond the cap, stops when idle', async () => {++ const db = await openTestDb()++ const rapier = await loadRapier()++ const config = loadConfig({ GAMES_PLACE_ID: 'scraplandia' })++ // The built-in default place the router always hosts.++ const registry = new InstanceRegistry(db, 'scraplandia')++ await openPgStore(db, { placeId: 'scraplandia' }).ensurePlace()++ const claimed = await registry.claim(0, 4, 'holder', Date.now(), 30_000)++ if (!claimed) throw new Error('claim failed')++ let lease: InstanceLease = claimed++ const store = openPgStore(db, {++ placeId: 'scraplandia',++ instanceSlot: 0,++ leaseGuard: () => registry.assertHeld(lease, Date.now()),++ })++ const physics = createRapierWorld(rapier)++ const world = new GameWorld(++ createContent([], SCRAPLANDIA_PACK),++ physics,++ log,++ config.interestCell,++ )++ await world.seedOrRestore(store)++ const game = new GameServer(++ { ...config, placeId: 'scraplandia' },++ world,++ store,++ new ServerMetrics(),++ log,++ )++ await game.load()++ lease = (await registry.renew(lease, Date.now(), 30_000, 'running')) ?? lease++ const hub = new InstanceHub(++ registry,++ { lease, game, world, dispose: () => physics.dispose() },++ 1,++ 4,++ async () => {++ throw new Error('unexpected room creation')++ },++ log,++ )++++ const worlds = createCreatorWorldStore(db)++ const contents = new Map<string, ContentRegistry>()++ const contentFor = (id: string): ContentRegistry => {++ const built = contents.get(id) ?? createContent([], creatorWorldPack(id))++ contents.set(id, built)++ return built++ }++ const map = { ...emptyMapV2(), spawn: [3, 1, 3] as [number, number, number] }++ const open = await worlds.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Open',++ summary: '',++ map,++ now: 1,++ })++ await worlds.update(open.id, { status: 'published' }, 2)++ const draft = await worlds.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Draft',++ summary: '',++ map,++ now: 3,++ })++ const second = await worlds.create({++ ownerSubject: LEE,++ ownerName: 'lee',++ title: 'Second',++ summary: '',++ map,++ now: 4,++ })++ await worlds.update(second.id, { status: 'published' }, 5)++++ let now = 1_000_000++ const host = new CreatorWorldHost({++ store: worlds,++ start: createWorldHubStarter({++ db,++ rapier,++ config,++ metrics: new ServerMetrics(),++ log,++ contentFor,++ }),++ log,++ maxRunning: 1,++ idleMs: 60_000,++ now: () => now,++ })++ const router = new PlaceRouter(new Map([['scraplandia', hub]]), 'scraplandia', host)++ const join = (place: string, key: string, digest: string) => {++ const inbox: ServerMessage[] = []++ const closes: string[] = []++ const conn: GameConnection = {++ identity: { guestKeyHash: key.repeat(64) },++ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),++ close: (_code, reason) => {++ closes.push(reason)++ router.onDisconnect(conn)++ },++ }++ router.onMessage(conn, {++ t: 'hello',++ v: PROTOCOL_VERSION,++ place,++ contentDigest: digest,++ slot: 0,++ name: key,++ appearance: defaultAppearance(),++ })++ return { conn, inbox, closes }++ }++ try {++ const guest = join(`world:${open.id}`, 'a', contentFor(open.id).digest)++ await until(() => guest.inbox.some((m) => m.t === 'welcome') || guest.closes.length > 0)++ expect(guest.closes).toEqual([])++ // A one-room place sends no room fields; the welcome carries the world's own def set.++ expect(guest.inbox.find((m) => m.t === 'welcome')).toMatchObject({++ contentDigest: contentFor(open.id).digest,++ })++ expect([...host.online()]).toEqual([[open.id, 1]])++ expect(router.activeRooms.map((r) => r.world.content.world.id)).toContain(`creator_${open.id}`)++ expect((await worlds.get(open.id))?.plays).toBe(1)++++ // A draft is its owner's: a guest is refused. The cap of one running world refuses the second world.++ const refused = join(`world:${draft.id}`, 'b', contentFor(draft.id).digest)++ const busy = join(`world:${second.id}`, 'c', contentFor(second.id).digest)++ await until(() => refused.closes.length > 0 && busy.closes.length > 0)++ expect(refused.closes).toEqual(['world_unavailable'])++ expect(busy.closes).toEqual(['world_unavailable'])++++ // Leaving saves nothing about the character; the world stops once it has been empty long enough.++ guest.conn.close(1000, 'bye')++ await Promise.all(router.activeRooms.map((room) => room.game.drain(5000)))++ expect(await db.one<{ n: number }>(sql`SELECT count(*)::int AS n FROM characters`)).toEqual({++ n: 0,++ })++ await router.serialize(() => host.sweep())++ expect(host.online().size).toBe(1)++ now += 61_000++ await router.serialize(() => host.sweep())++ expect(host.online().size).toBe(0)++ const instance = await db.one<{ state: string }>(++ sql`SELECT state FROM game_instances WHERE place_id = ${`world:${open.id}`}`,++ )++ expect(instance.state).toBe('stopped')++++ // With the slot free the second world starts; unpublishing it closes it.++ const lee = join(`world:${second.id}`, 'd', contentFor(second.id).digest)++ await until(() => lee.inbox.some((m) => m.t === 'welcome'))++ const unpublished = await worlds.update(second.id, { status: 'draft' }, 6)++ await router.serialize(() => host.changed(unpublished, second.id))++ expect(host.online().size).toBe(0)++ } finally {++ await router.shutdown()++ }++})nnew file mode 100644@@@ -0,0 +1,23 @@++import type { PersistenceStore, PlayerRepository } from '@openvibe/persistence'++++/**++ * A creator world's store: the world's own rows (its props, constraints, clock) are written as in any place, but a++ * character is never saved there. A player brings their character in and leaves with it exactly as it was: nothing++ * gathered, crafted, traded or dropped in somebody's world reaches Scraplandia, the boards or the Inventory.++ * Characters are still read (appearance, name and the items a player carries in), and a new character made in a++ * creator world lives only for that session.++ */++export function sandboxStore(store: PersistenceStore): PersistenceStore {++ const players: PlayerRepository = {++ findByToken: (token) => store.players.findByToken(token),++ listByToken: (token) => store.players.listByToken(token),++ findByTokenSlot: (token, slot) => store.players.findByTokenSlot(token, slot),++ findById: (id) => store.players.findById(id),++ upsert: async () => {},++ upsertMany: async () => {},++ resetAllPositions: async () => {},++ }++ const wrapped = Object.create(store) as PersistenceStore++ Object.defineProperty(wrapped, 'players', { value: players, enumerable: true })++ return wrapped++}nnew file mode 100644@@@ -0,0 +1,106 @@++import type { ContentRegistry } from '@openvibe/content'++import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'++import { createRapierWorld, type RapierModule } from '@openvibe/physics/rapier'++import type { Logger } from '@openvibe/shared'++import type { Db } from 'openvibe-sdk/db'++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 type { ServerMetrics } from '../observability/metrics.js'++import { sandboxStore } from './sandboxStore.js'++import type { StartWorldHub } from './worldHost.js'++++export interface WorldHubDeps {++ db: Db++ rapier: RapierModule++ 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++}++++/**++ * Starts one creator world as a single-room hub: its place row, a lease on slot 0, then a sandboxed room (the++ * world's rows are kept, characters are not; no NPCs, world events, rewards or events). The caller has already set++ * the world's map override. Null when the slot is held elsewhere or the room fails to start.++ */++export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {++ const { db, rapier, config, metrics, log } = deps++ return async (world, placeId) => {++ const worldContent = 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) => {++ const roomStore = sandboxStore(++ openPgStore(db, {++ placeId,++ instanceSlot: slot,++ leaseGuard: () => worldRegistry.assertHeld(lease, Date.now()),++ }),++ )++ await roomStore.ensurePlace(world.title)++ const roomPhysics = createRapierWorld(rapier)++ const roomWorld = new GameWorld(worldContent, roomPhysics, worldLog, config.interestCell)++ await roomWorld.seedOrRestore(roomStore)++ roomWorld.reconcileMapNodes()++ roomWorld.reconcileMapProps()++ const roomGame = new GameServer(++ {++ ...config,++ placeId,++ instanceSlot: slot,++ maxPlayers: config.creatorWorldPlayers,++ sandbox: true,++ },++ roomWorld,++ roomStore,++ metrics,++ log.child({ system: 'game', place: placeId }),++ {},++ )++ await roomGame.load()++ return {++ lease,++ game: roomGame,++ world: roomWorld,++ dispose: async () => {++ roomPhysics.dispose()++ },++ }++ }++ // The places row first: game_instances references it.++ await openPgStore(db, { placeId }).ensurePlace(world.title)++ const claim = await worldRegistry.claim(++ 0,++ config.creatorWorldPlayers,++ crypto.randomUUID(),++ Date.now(),++ 60_000,++ )++ if (!claim) return null++ let room: Awaited<ReturnType<typeof createWorldRoom>>++ try {++ room = await createWorldRoom(0, claim)++ } catch (err) {++ worldLog.warn('creator world failed to start', { error: String(err) })++ await worldRegistry.stop(claim).catch(() => undefined)++ return null++ }++ const running = await worldRegistry.renew(claim, Date.now(), 15_000, 'running')++ if (!running) {++ await room.dispose()++ return null++ }++ room.lease = running++ return new InstanceHub(++ worldRegistry,++ room,++ 1,++ config.creatorWorldPlayers,++ createWorldRoom,++ log.child({ system: 'instances', place: placeId }),++ )++ }++}nnew file mode 100644@@@ -0,0 +1,184 @@++import {++ compileMapFileV2,++ creatorPlaceId,++ creatorWorldOf,++ parseMapFile,++ setMapOverride,++ type MapFileV2,++} from '@openvibe/content'++import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { InstanceHub } from '../game/instanceHub.js'++import type { DynamicPlaces } from '../game/placeRouter.js'++import type { GameConnection } from '../game/systems/context.js'++++/** Starts a creator world's hub (main.ts builds the room: physics, world, a sandboxed GameServer). */++export type StartWorldHub = (world: CreatorWorld, placeId: string) => Promise<InstanceHub | null>++++export interface WorldHostOptions {++ store: CreatorWorldStore++ start: StartWorldHub++ log: Logger++ /** Creator worlds running at once in this process (GAMES_CREATOR_WORLDS_MAX). */++ maxRunning: number++ /** A world with nobody in it stops after this long. */++ idleMs: number++ now?: () => number++}++++interface Running {++ hub: InstanceHub++ world: CreatorWorld++ /** The last join or the last time someone was seen inside. */++ activeAt: number++}++++/** Who may join a world that is not published: its owner, and staff. */++export function mayJoin(world: CreatorWorld, conn: GameConnection): boolean {++ if (world.status === 'published') return true++ const identity = conn.identity++ if (!identity || !('subject' in identity)) return false++ if (identity.subject === world.ownerSubject) return world.status === 'draft'++ return identity.rank === 'owner' || identity.rank === 'admin'++}++++/**++ * The creator worlds running in this process. A world starts when someone joins it (inside the router's admission++ * chain), runs as the place `world:<id>` with its own map override, and stops once it has been empty for `idleMs`.++ * At most `maxRunning` run at once: a join beyond that is refused (the client says the worlds are busy) rather than++ * slowing Scraplandia's tick.++ */++export class CreatorWorldHost implements DynamicPlaces {++ private readonly running = new Map<string, Running>()++ /** Worlds being stopped; a join waits for the stop, then starts the world again. */++ private readonly stopping = new Map<string, Promise<void>>()++ private readonly now: () => number++++ constructor(private readonly opts: WorldHostOptions) {++ this.now = opts.now ?? Date.now++ }++++ claims(place: string): boolean {++ return creatorWorldOf(place) !== null++ }++++ hubs(): Iterable<InstanceHub> {++ return [...this.running.values()].map((r) => r.hub)++ }++++ /** Worlds running now, for /api/worlds (players inside each). */++ online(): Map<string, number> {++ return new Map([...this.running].map(([id, r]) => [id, r.hub.onlineCount]))++ }++++ async open(place: string, conn: GameConnection): Promise<InstanceHub | null> {++ const id = creatorWorldOf(place)++ if (!id) return null++ await this.stopping.get(id)++ const world = await this.opts.store.get(id)++ if (!world || !mayJoin(world, conn)) return null++ const current = this.running.get(id)++ if (current) {++ current.activeAt = this.now()++ current.world = world++ void this.opts.store.countPlay(id).catch(() => undefined)++ return current.hub++ }++ if (this.running.size >= this.opts.maxRunning) {++ this.opts.log.info('creator world refused: all slots busy', { world: id })++ return null++ }++ const stored = await this.opts.store.map(id)++ const parsed = parseMapFile(stored?.map ?? null)++ if (!parsed.ok) {++ this.opts.log.warn('creator world map unreadable', { world: id })++ return null++ }++ this.applyOverride(id, parsed.map)++ const hub = await this.opts.start(world, creatorPlaceId(id))++ if (!hub) {++ setMapOverride(null, contentWorldId(id))++ return null++ }++ this.running.set(id, { hub, world, activeAt: this.now() })++ void this.opts.store.countPlay(id).catch(() => undefined)++ this.opts.log.info('creator world started', { world: id, running: this.running.size })++ return hub++ }++++ /** A saved map reaches a running world at once (the editor's "save applies live"). */++ applyMap(id: string, map: MapFileV2): void {++ const current = this.running.get(id)++ if (!current) return++ this.applyOverride(id, map)++ for (const room of current.hub.activeRooms) {++ room.world.reconcileMapTerrain()++ room.world.reconcileMapStatics()++ room.world.reconcileMapZones()++ room.world.reconcileMapNodes()++ room.world.reconcileMapProps()++ room.game.broadcastMapReload()++ }++ }++++ /** The world changed state (unpublished, hidden, deleted): a world nobody may join any more stops now. */++ async changed(world: CreatorWorld | null, id: string): Promise<void> {++ const current = this.running.get(id)++ if (!current) return++ if (world?.status === 'published') {++ current.world = world++ return++ }++ for (const room of current.hub.activeRooms)++ room.game.announce('This world was closed by its owner or by staff.')++ 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()++ for (const [id, current] of [...this.running]) {++ if (current.hub.onlineCount > 0) {++ current.activeAt = now++ continue++ }++ if (now - current.activeAt >= this.opts.idleMs) await this.stop(id)++ }++ }++++ async stop(id: string): Promise<void> {++ const current = this.running.get(id)++ if (!current) return++ this.running.delete(id)++ const done = (async () => {++ try {++ await current.hub.shutdown()++ } catch (err) {++ this.opts.log.warn('creator world stop failed', { world: id, error: String(err) })++ } finally {++ setMapOverride(null, contentWorldId(id))++ this.stopping.delete(id)++ this.opts.log.info('creator world stopped', { world: id, running: this.running.size })++ }++ })()++ this.stopping.set(id, done)++ await done++ }++++ async shutdown(): Promise<boolean> {++ const results = await Promise.all(++ [...this.running.values()].map((r) => r.hub.shutdown().catch(() => false)),++ )++ this.running.clear()++ return results.every(Boolean)++ }++++ private applyOverride(id: string, map: MapFileV2): void {++ setMapOverride(compileMapFileV2(map), contentWorldId(id))++ }++}++++/** The content world id of a creator world (creatorWorldPack's map id). */++export function contentWorldId(worldId: string): string {++ return `creator_${worldId}`++}nnew file mode 100644@@@ -0,0 +1,187 @@++import { createServer, type Server } from 'node:http'++import type { AddressInfo } from 'node:net'++import { emptyMapV2, type MapFileV2 } from '@openvibe/content'++import { createCreatorWorldStore, type CreatorWorld } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import { createConsoleLogger } from '@openvibe/shared'++import { afterEach, describe, expect, it } from 'vitest'++import { createGamesActorLimits } from '../net/actorLimits.js'++import type { NetworkUser } from '../net/networkAuth.js'++import { cleanText, createWorldRoutes } from './worldRoutes.js'++++const KIM = 'usr_01J0000000000000000000000K'++const LEE = 'usr_01J0000000000000000000000L'++const STAFF = 'usr_01J0000000000000000000000S'++const USERS: Record<string, NetworkUser> = {++ kim: { id: '1', name: 'kim', rank: null, subjectId: KIM },++ lee: { id: '2', name: 'lee', rank: null, subjectId: LEE },++ staff: { id: '3', name: 'mod', rank: 'admin', subjectId: STAFF },++ guest: { id: '4', name: 'guest', rank: null, subjectId: null },++}++++let server: Server | null = null++afterEach(async () => {++ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()))++ server = null++})++++async function setup(perPerson = 2) {++ const db = await openTestDb()++ const store = createCreatorWorldStore(db)++ const calls = {++ applied: [] as string[],++ changed: [] as (string | null)[],++ removed: [] as string[],++ }++ const handler = createWorldRoutes({++ store,++ host: {++ online: () => new Map(),++ applyMap: (id) => calls.applied.push(id),++ changed: async (world: CreatorWorld | null, id: string) => {++ calls.changed.push(world ? `${id}:${world.status}` : null)++ },++ remove: async (id) => {++ calls.removed.push(id)++ await store.remove(id)++ },++ },++ resolveUser: async (token) => USERS[token] ?? null,++ limits: createGamesActorLimits({ env: {} }),++ address: () => '127.0.0.1',++ log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),++ perPerson,++ })++ server = createServer((req, res) => {++ if (!handler(req, res)) {++ res.writeHead(404)++ res.end()++ }++ })++ await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))++ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`++ const call = async (++ method: string,++ path: string,++ as?: string,++ body?: unknown,++ headers: Record<string, string> = {},++ ) => {++ const resp = await fetch(`${base}${path}`, {++ method,++ headers: {++ ...(as ? { authorization: `Bearer ${as}` } : {}),++ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),++ ...headers,++ },++ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),++ })++ const text = await resp.text()++ return {++ status: resp.status,++ etag: resp.headers.get('etag'),++ body: (text ? JSON.parse(text) : null) as Record<string, unknown> & {++ worlds?: Record<string, unknown>[]++ issues?: string[]++ },++ }++ }++ return { call, calls, store }++}++++describe('/api/worlds', () => {++ it('cleans titles: trimmed, control characters out, length capped', () => {++ expect(cleanText(' Moon\u0007 base ', 60)).toBe('Moon base')++ expect(cleanText('x'.repeat(61), 60)).toBeNull()++ expect(cleanText(42, 60)).toBeNull()++ })++++ it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {++ const { call, calls } = await setup()++ expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)++ expect((await call('POST', '/api/worlds', 'guest', { title: 'x' })).status).toBe(401)++ expect((await call('POST', '/api/worlds', 'kim', { title: ' ' })).status).toBe(422)++++ const made = await call('POST', '/api/worlds', 'kim', {++ title: 'Moon base',++ summary: 'Low gravity vibes',++ })++ expect(made.status).toBe(201)++ const id = String(made.body.id)++ expect(made.body).toMatchObject({++ status: 'draft',++ edit_url: `/editor?world=${id}`,++ play_url: `/play?world=${id}`,++ })++++ // A draft is the owner's (and staff's) only.++ expect((await call('GET', '/api/worlds')).body.worlds).toEqual([])++ expect((await call('GET', `/api/worlds/${id}`)).status).toBe(404)++ expect((await call('GET', `/api/worlds/${id}`, 'lee')).status).toBe(404)++ expect((await call('GET', `/api/worlds/${id}`, 'staff')).status).toBe(200)++ const map = await call('GET', `/api/worlds/${id}/map`, 'kim')++ expect(map.status).toBe(200)++ expect(map.etag).toMatch(/^"[0-9a-f]{24}"$/)++++ // Saves: creator limits, If-Match, owner only.++ const uploads: MapFileV2 = {++ ...emptyMapV2(),++ textures: [{ name: 'photo', url: '/map-assets/a.png' }],++ }++ const refused = await call('PUT', `/api/worlds/${id}/map`, 'kim', uploads)++ expect(refused.status).toBe(422)++ expect(refused.body.issues?.some((i) => i.startsWith('textures:'))).toBe(true)++ const next: MapFileV2 = { ...emptyMapV2(), spawn: [2, 1, 2] }++ const saved = await call('PUT', `/api/worlds/${id}/map`, 'kim', next, {++ 'if-match': map.etag ?? '',++ })++ expect(saved.status).toBe(200)++ expect(calls.applied).toEqual([id])++ expect(++ (await call('PUT', `/api/worlds/${id}/map`, 'kim', next, { 'if-match': map.etag ?? '' }))++ .status,++ ).toBe(409)++ expect((await call('PUT', `/api/worlds/${id}/map`, 'lee', next)).status).toBe(404)++ expect((await call('PUT', `/api/worlds/${id}/map`, 'staff', next)).status).toBe(404)++++ // Publishing lists it for everyone, without the owner's fields.++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'hidden' })).status).toBe(403)++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(++ 200,++ )++ expect(calls.changed).toEqual([`${id}:published`])++ const listed = (await call('GET', '/api/worlds')).body.worlds ?? []++ expect(listed.map((w) => w.id)).toEqual([id])++ expect(listed[0]).toMatchObject({++ title: 'Moon base',++ owner: 'kim',++ summary: 'Low gravity vibes',++ })++ expect(listed[0]).not.toHaveProperty('status')++ expect((await call('GET', `/api/worlds/${id}/map`)).status).toBe(200)++++ // Staff take it down; the owner cannot put it back, nor can staff edit its title.++ expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { title: 'Mine now' })).status).toBe(++ 403,++ )++ expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { status: 'hidden' })).status).toBe(++ 200,++ )++ expect((await call('GET', '/api/worlds')).body.worlds).toEqual([])++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(++ 403,++ )++ expect((await call('GET', '/api/worlds/mine', 'staff')).body).toMatchObject({ staff: true })++++ // Limits: two per person here.++ expect((await call('POST', '/api/worlds', 'kim', { title: 'Two' })).status).toBe(201)++ expect((await call('POST', '/api/worlds', 'kim', { title: 'Three' })).status).toBe(409)++ expect(((await call('GET', '/api/worlds/mine', 'kim')).body.worlds ?? []).length).toBe(2)++++ expect((await call('DELETE', `/api/worlds/${id}`, 'lee')).status).toBe(404)++ expect((await call('DELETE', `/api/worlds/${id}`, 'kim')).status).toBe(204)++ expect(calls.removed).toEqual([id])++ expect((await call('GET', `/api/worlds/${id}`, 'kim')).status).toBe(404)++ expect((await call('GET', '/api/worlds/not-an-id')).status).toBe(404)++ })++})nnew file mode 100644@@@ -0,0 +1,420 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import {++ CREATOR_MAP_LIMITS,++ CREATOR_WORLD_ID,++ creatorMapIssues,++ emptyMapV2,++ parseMapFile,++ type MapFileV2,++} from '@openvibe/content'++import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { GamesActorLimits } from '../net/actorLimits.js'++import type { NetworkUser } from '../net/networkAuth.js'++++/**++ * /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a++ * signed-in person (a Network account, not a guest) makes up to `perPerson` worlds, edits their maps in /editor and++ * publishes them; staff hide a world. Every write is counted per person (games.world.write, games.map.save).++ *++ * GET /api/worlds[?page=] published worlds, newest first++ * GET /api/worlds/mine the caller's worlds++ * POST /api/worlds { title, summary? } → a new draft with an empty map++ * GET /api/worlds/:id one world (published, or the caller's, or staff)++ * GET /api/worlds/:id/map its map document, ETag = revision++ * PUT /api/worlds/:id/map the owner saves (If-Match: revision); a running world reloads at once++ * PATCH /api/worlds/:id { title?, summary?, status? } owner: draft|published; staff: hidden|published++ * DELETE /api/worlds/:id the owner or games staff++ */++++/** The running worlds, as the routes see them (main.ts runs the changes in the router's admission chain). */++export interface WorldHostView {++ online(): Map<string, number>++ applyMap(id: string, map: MapFileV2): void++ changed(world: CreatorWorld | null, id: string): Promise<void>++ /** Stops the world (its last save lands) and removes it with its place's rows. */++ remove(id: string): Promise<void>++}++++export interface WorldRouteOptions {++ store: CreatorWorldStore++ host: WorldHostView++ /** The Network account behind a bearer token (networkAuth.resolveNetworkUser), or null. */++ resolveUser: (token: string) => Promise<NetworkUser | null>++ limits: GamesActorLimits++ address: (req: IncomingMessage) => string++ log: Logger++ perPerson: number++ now?: () => number++}++++const PAGE_SIZE = 24++const META_BYTES = 16 * 1024++const TITLE_MAX = 60++const SUMMARY_MAX = 280++++type Problem = {++ status: number++ error: string++ detail?: string++ issues?: string[]++ revision?: string++}++++class RouteError extends Error {++ constructor(readonly problem: Problem) {++ super(problem.error)++ }++}++++const fail = (++ status: number,++ error: string,++ extra: Omit<Problem, 'status' | 'error'> = {},++): never => {++ throw new RouteError({ status, error, ...extra })++}++++/** Plain text of at most `max` characters: trimmed, control characters out, inner runs of space kept to one. */++export function cleanText(value: unknown, max: number): string | null {++ if (typeof value !== 'string') return null++ const visible = Array.from(value, (ch) => {++ const code = ch.charCodeAt(0)++ return code < 32 || code === 127 ? ' ' : ch++ }).join('')++ const text = visible.replace(/\s+/g, ' ').trim()++ return text.length > max ? null : text++}++++function isStaff(user: NetworkUser | null): boolean {++ return user?.rank === 'owner' || user?.rank === 'admin' || user?.rank === 'moderator'++}++++function isGamesStaff(user: NetworkUser | null): boolean {++ return user?.rank === 'owner' || user?.rank === 'admin'++}++++function publicView(world: CreatorWorld, online: number) {++ return {++ id: world.id,++ title: world.title,++ summary: world.summary,++ owner: world.ownerName,++ plays: world.plays,++ online,++ published_at: world.publishedAt === null ? null : new Date(world.publishedAt).toISOString(),++ updated_at: new Date(world.updatedAt).toISOString(),++ play_url: `/play?world=${world.id}`,++ }++}++++function ownerView(world: CreatorWorld, online: number) {++ return {++ ...publicView(world, online),++ status: world.status,++ revision: world.revision,++ created_at: new Date(world.createdAt).toISOString(),++ edit_url: `/editor?world=${world.id}`,++ }++}++++function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {++ return new Promise((resolve, reject) => {++ const chunks: Buffer[] = []++ let size = 0++ let over = false++ req.on('data', (chunk: Buffer) => {++ size += chunk.length++ if (size > max) {++ over = true++ req.destroy()++ } else chunks.push(chunk)++ })++ req.on('end', () => resolve({ text: Buffer.concat(chunks).toString('utf8'), bytes: size }))++ req.on('close', () => {++ if (over) reject(new RouteError({ status: 413, error: 'too_large' }))++ })++ req.on('error', reject)++ })++}++++function parseJson(text: string): unknown {++ try {++ return JSON.parse(text) as unknown++ } catch {++ return fail(400, 'invalid_json')++ }++}++++export function createWorldRoutes(opts: WorldRouteOptions) {++ const now = opts.now ?? Date.now++ const online = (id: string): number => opts.host.online().get(id) ?? 0++++ const send = (++ res: ServerResponse,++ status: number,++ body: unknown,++ headers: Record<string, string> = {},++ ) => {++ res.writeHead(status, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': 'no-store',++ ...headers,++ })++ res.end(JSON.stringify(body))++ }++++ const bearer = (req: IncomingMessage): string | null =>++ /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null++++ /** The caller's Network account, or null for a guest / no token. */++ const caller = async (req: IncomingMessage): Promise<NetworkUser | null> => {++ const token = bearer(req)++ if (!token) return null++ const user = await opts.resolveUser(token)++ return user?.subjectId ? user : null++ }++++ const signedIn = async (req: IncomingMessage): Promise<NetworkUser & { subjectId: string }> => {++ const user = await caller(req)++ if (!user?.subjectId)++ fail(401, 'sign_in_required', { detail: 'Sign in with your OpenVibe account.' })++ return user as NetworkUser & { subjectId: string }++ }++++ /** A world the caller may see: published, or theirs, or staff. Anything else is not found. */++ const visible = async (id: string, user: NetworkUser | null): Promise<CreatorWorld> => {++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null++ if (!world) return fail(404, 'world_not_found')++ if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)++ return world++ return fail(404, 'world_not_found')++ }++++ const owned = async (++ id: string,++ user: NetworkUser & { subjectId: string },++ ): Promise<CreatorWorld> => {++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null++ if (!world || world.ownerSubject !== user.subjectId) return fail(404, 'world_not_found')++ return world++ }++++ const limited = (++ kind: 'publicRead' | 'mapSave',++ req: IncomingMessage,++ res: ServerResponse,++ actor: string,++ ): Promise<boolean> => opts.limits[kind](req, res, actor)++++ async function route(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {++ const path = url.pathname++ const method = req.method ?? 'GET'++++ if (path === '/api/worlds' && method === 'GET') {++ if (!(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return++ const page = Number(url.searchParams.get('page') ?? '1')++ if (!Number.isSafeInteger(page) || page < 1 || page > 1000) fail(400, 'invalid_query')++ const rows = await opts.store.listPublished(PAGE_SIZE + 1, (page - 1) * PAGE_SIZE)++ send(res, 200, {++ worlds: rows.slice(0, PAGE_SIZE).map((w) => publicView(w, online(w.id))),++ page,++ more: rows.length > PAGE_SIZE,++ })++ return++ }++++ if (path === '/api/worlds/mine' && method === 'GET') {++ const user = await signedIn(req)++ const worlds = await opts.store.listByOwner(user.subjectId)++ send(res, 200, {++ worlds: worlds.map((w) => ownerView(w, online(w.id))),++ max: opts.perPerson,++ limits: CREATOR_MAP_LIMITS,++ staff: isStaff(user),++ })++ return++ }++++ if (path === '/api/worlds' && method === 'POST') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const body = parseJson((await readBody(req, META_BYTES)).text) as Record<++ string,++ unknown++ > | null++ const title = cleanText(body?.title, TITLE_MAX)++ const summary = body?.summary === undefined ? '' : cleanText(body.summary, SUMMARY_MAX)++ if (!title) fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })++ if (summary === null)++ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })++ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)++ fail(409, 'world_limit', {++ detail: `You can keep ${opts.perPerson} worlds. Delete one to make another.`,++ })++ const world = await opts.store.create({++ ownerSubject: user.subjectId,++ ownerName: user.name,++ title: title as string,++ summary: summary as string,++ map: emptyMapV2(),++ now: now(),++ })++ opts.log.info('creator world created', { world: world.id })++ send(res, 201, ownerView(world, 0))++ return++ }++++ const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)++ if (!match) fail(404, 'not_found')++ const id = match?.[1] ?? ''++ const isMap = match?.[2] === '/map'++++ if (isMap && method === 'GET') {++ const user = await caller(req)++ if (!user && !(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return++ await visible(id, user)++ const stored = await opts.store.map(id)++ if (!stored) fail(404, 'world_not_found')++ send(res, 200, stored?.map, { etag: `"${stored?.revision}"`, 'cache-control': 'no-cache' })++ return++ }++++ if (isMap && (method === 'PUT' || method === 'POST')) {++ const user = await signedIn(req)++ await owned(id, user)++ if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return++ const { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)++ const parsed = parseMapFile(parseJson(text))++ if (!parsed.ok) fail(422, 'map_invalid', { issues: parsed.issues.slice(0, 40) })++ const map = (parsed as { map: MapFileV2 }).map++ const issues = creatorMapIssues(map, bytes)++ if (issues.length) fail(422, 'map_invalid', { issues })++ const { revision: _client, ...doc } = map++ const ifMatch = (req.headers['if-match'] as string | undefined)?.replace(/"/g, '') || null++ const saved = await opts.store.saveMap(id, doc, ifMatch, now())++ if (saved === null) fail(404, 'world_not_found')++ if (saved === 'conflict') {++ const current = await opts.store.get(id)++ fail(409, 'stale_revision', current ? { revision: current.revision } : {})++ }++ const world = saved as CreatorWorld++ void opts.store.rename(user.subjectId, user.name).catch(() => undefined)++ opts.host.applyMap(id, doc as MapFileV2)++ send(++ res,++ 200,++ { ok: true, live: true, revision: world.revision },++ { etag: `"${world.revision}"` },++ )++ return++ }++++ if (!isMap && method === 'GET') {++ const user = await caller(req)++ if (!user && !(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return++ const world = await visible(id, user)++ const mine = user?.subjectId === world.ownerSubject++ send(++ res,++ 200,++ mine || isStaff(user) ? ownerView(world, online(id)) : publicView(world, online(id)),++ )++ return++ }++++ if (!isMap && method === 'PATCH') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const body = parseJson((await readBody(req, META_BYTES)).text) as Record<++ string,++ unknown++ > | null++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null++ const mine = !!world && world.ownerSubject === user.subjectId++ if (!world || (!mine && !isStaff(user))) return fail(404, 'world_not_found')++ const patch: { title?: string; summary?: string; status?: CreatorWorldStatus } = {}++ if (body?.title !== undefined) {++ if (!mine) fail(403, 'owner_only')++ const title = cleanText(body.title, TITLE_MAX)++ if (!title)++ fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })++ patch.title = title as string++ }++ if (body?.summary !== undefined) {++ if (!mine) fail(403, 'owner_only')++ const summary = cleanText(body.summary, SUMMARY_MAX)++ if (summary === null)++ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })++ patch.summary = summary as string++ }++ if (body?.status !== undefined) {++ const status = body.status++ if (status !== 'draft' && status !== 'published' && status !== 'hidden')++ fail(422, 'invalid_status')++ if (isStaff(user) && !mine) {++ // Staff take a world down or put it back; they never edit it.++ if (status === 'draft') fail(422, 'invalid_status')++ } else {++ if (status === 'hidden') fail(403, 'staff_only')++ if (world.status === 'hidden')++ fail(403, 'world_hidden', {++ detail: 'Staff took this world down. Ask on openvibe.help to bring it back.',++ })++ }++ patch.status = status as CreatorWorldStatus++ }++ const updated = await opts.store.update(id, patch, now())++ if (!updated) fail(404, 'world_not_found')++ if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)++ if (patch.status && patch.status !== world.status) {++ opts.log.info('creator world status', {++ world: id,++ from: world.status,++ to: patch.status,++ staff: !mine,++ })++ await opts.host.changed(updated, id)++ }++ send(res, 200, ownerView(updated as CreatorWorld, online(id)))++ return++ }++++ if (!isMap && method === 'DELETE') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null++ if (!world || (world.ownerSubject !== user.subjectId && !isGamesStaff(user)))++ return fail(404, 'world_not_found')++ await opts.host.remove(id)++ opts.log.info('creator world deleted', {++ world: id,++ staff: world.ownerSubject !== user.subjectId,++ })++ res.writeHead(204, { 'cache-control': 'no-store' })++ res.end()++ return++ }++++ fail(405, 'method_not_allowed')++ }++++ /** true when the request was /api/worlds… (answered, or being answered). */++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const url = new URL(req.url ?? '/', 'http://games.local')++ if (url.pathname !== '/api/worlds' && !url.pathname.startsWith('/api/worlds/')) return false++ route(req, res, url).catch((err: unknown) => {++ if (res.headersSent) return++ if (err instanceof RouteError) {++ const { status, ...body } = err.problem++ send(res, status, body)++ return++ }++ opts.log.warn('worlds request failed', {++ error: String((err as Error | undefined)?.message ?? err),++ })++ send(res, 500, { error: 'internal' })++ })++ return true++ }++}@@@ -38,6 +38,31 @@ The read-only `/leaderboard.html` page shows Salvage Rush's best round scores anNetwork account exports include results, best scores, unlocks and hauls; account erasure removes them.Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.++## Creator worlds++++`/worlds` lists the worlds players built and published; a signed-in OpenVibe account (not a guest) can keep up to++five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in the map editor at++`/editor?world=<id>` and played at `/play?world=<id>`.++++- **Storage:** `creator_worlds` (migration 0010) holds the title, summary, status (`draft`, `published`, `hidden`)++ and the editor's v2 map document; `revision` is its hash and the editor's `If-Match`.++- **Editing:** the owner alone, over `PUT /api/worlds/:id/map`, with no collaboration socket (`/editor-ws` stays++ staff-only). Creator maps use the built-in materials: no uploaded textures, models or paint masks (they wait for++ the Pics safety pipeline), and smaller limits than staff maps (`CREATOR_MAP_LIMITS`: 2 MB, 4,000 statics, 16++ terrains, 400 nodes and props, 64 lights and zones). A save reaches a running world at once.++- **Running:** a world is the place `world:<id>` with the plain creator pack (`creatorWorldPack`, the same def set++ on server and client) and its map as the override. It starts on the first join (inside the router's admission++ chain), runs one room of `GAMES_CREATOR_WORLD_PLAYERS` (16), and stops after `GAMES_CREATOR_WORLD_IDLE_SECONDS`++ (180) empty. At most `GAMES_CREATOR_WORLDS_MAX` (2) run at once, so Scraplandia's tick keeps its budget; a join++ beyond that closes with 4005 `world_unavailable` and the client says the world is not open right now.++- **Sandbox:** characters are read but never saved in a creator world (`worlds/sandboxStore.ts`), and the room has++ no NPCs, world events, rewards, boards or events. Nothing gathered, crafted, traded or dropped in somebody's world++ reaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.++- **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. Worlds are in the account export, follow an++ account merge and go with an account deletion.++## Adding another game1. Create a `games-content@2` built-in pack with a validated map document and declarative@@@ -22,6 +22,7 @@ export * from './defs/recipes.js'export * from './defs/skills.js'export * from './defs/resources.js'export * from './packs/scraplandia.js'++export * from './packs/creatorWorld.js'import { ContentRegistry, type ContentDefs } from './registry.js'import { mergePackDefs, type ContentPackV2 } from './pack.js'nnew file mode 100644@@@ -0,0 +1,68 @@++import { describe, expect, it } from 'vitest'++import { createContent } from '../index.js'++import { emptyMapV2, type MapFileV2 } from '../mapFileV2.js'++import {++ CREATOR_MAP_LIMITS,++ creatorMapIssues,++ creatorPlaceId,++ creatorWorldOf,++ creatorWorldPack,++} from './creatorWorld.js'++++describe('creator worlds', () => {++ it('maps world ids to places and back, refusing anything else', () => {++ expect(creatorPlaceId('wabc123def0')).toBe('world:wabc123def0')++ expect(creatorWorldOf('world:wabc123def0')).toBe('wabc123def0')++ expect(creatorWorldOf('world:WABC123DEF0')).toBeNull()++ expect(creatorWorldOf('world:wabc')).toBeNull()++ expect(creatorWorldOf('scraplandia')).toBeNull()++ expect(creatorWorldOf(undefined)).toBeNull()++ expect(() => creatorWorldPack('../etc')).toThrow(/creator world id/)++ })++++ it('builds the same def set on server and client, one per world, never Scraplandia’s', () => {++ const a = createContent([], creatorWorldPack('w0000000001'))++ expect(createContent([], creatorWorldPack('w0000000001')).digest).toBe(a.digest)++ expect(createContent([], creatorWorldPack('w0000000002')).digest).not.toBe(a.digest)++ expect(createContent().digest).not.toBe(a.digest)++ expect(a.world.id).toBe('creator_w0000000001')++ expect(a.world.zones).toEqual([])++ })++++ it('takes a plain map and refuses uploads, paint and oversized maps', () => {++ const plain: MapFileV2 = emptyMapV2()++ expect(creatorMapIssues(plain, 200)).toEqual([])++ const uploads: MapFileV2 = {++ ...plain,++ textures: [{ name: 'photo', url: '/map-assets/abc.png' }],++ models: [{ id: 'm', name: 'm', glb: '/map-assets/m.glb', bounds: [1, 1, 1] }],++ }++ const issues = creatorMapIssues(uploads, CREATOR_MAP_LIMITS.bytes + 1)++ expect(issues.some((i) => i.startsWith('bytes:'))).toBe(true)++ expect(issues.some((i) => i.startsWith('textures:'))).toBe(true)++ expect(issues.some((i) => i.startsWith('models:'))).toBe(true)++ const lights = Array.from({ length: CREATOR_MAP_LIMITS.lights + 1 }, (_, n) => ({++ ...(plain.lights[0] ?? {}),++ id: `l${n}`,++ })) as MapFileV2['lights']++ expect(creatorMapIssues({ ...plain, lights }, 10).some((i) => i.startsWith('lights:'))).toBe(++ true,++ )++ const painted = {++ ...plain,++ statics: [++ {++ id: 's',++ surface: {++ base: {},++ paint: {++ layers: [{ id: 'l', tex: 'none', color: '#ffffff', channel: 'r' }],++ mask: 'm',++ },++ },++ },++ ],++ } as unknown as MapFileV2++ expect(creatorMapIssues(painted, 10).some((i) => i.startsWith('statics:'))).toBe(true)++ })++})nnew file mode 100644@@@ -0,0 +1,91 @@++import type { ContentPackV2 } from '../pack.js'++import type { MapFileV2 } from '../mapFileV2.js'++++/**++ * Creator worlds: a place a signed-in player builds in /editor and publishes at /worlds. The server and++ * the client build the same pack from the world id, so the def-set digest agrees without shipping a pack.++ * The pack is deliberately plain (flat ground, no zones, no achievements): everything a creator adds lives in++ * the world's map document, and the title stays out of the pack so renaming a world never changes its digest.++ */++++/** A creator world id: `w` and ten lower-case base-36 characters. */++export const CREATOR_WORLD_ID = /^w[0-9a-z]{10}$/++++/** The place a creator world runs as; `hello.place` carries it. */++export function creatorPlaceId(worldId: string): string {++ return `world:${worldId}`++}++++/** The world id inside a creator place id, or null for any other place. */++export function creatorWorldOf(placeId: string | undefined | null): string | null {++ const id = placeId?.startsWith('world:') ? placeId.slice('world:'.length) : null++ return id && CREATOR_WORLD_ID.test(id) ? id : null++}++++export function creatorWorldPack(worldId: string): ContentPackV2 {++ if (!CREATOR_WORLD_ID.test(worldId)) throw new Error(`not a creator world id: ${worldId}`)++ return {++ map: {++ id: `creator_${worldId}`,++ name: 'Creator world',++ flatTerrain: true,++ groundHalfExtent: 80,++ spawnPoint: [0, 1.2, 4],++ spawnYaw: 0,++ statics: [],++ resourceNodes: [],++ initialProps: [],++ zones: [],++ },++ }++}++++/** What a creator world's map may hold. Staff maps keep the schema's own (much larger) limits. */++export const CREATOR_MAP_LIMITS = {++ bytes: 2 * 1024 * 1024,++ terrains: 16,++ statics: 4000,++ nodes: 400,++ props: 400,++ lights: 64,++ zones: 64,++} as const++++/**++ * Checks a parsed map against what creator worlds take: the limits above, and only the built-in materials.++ * Uploads (custom textures, models and paint masks) are staff-only for now: an uploaded image is shown to++ * everyone who joins, and creator uploads wait for the Pics safety pipeline.++ */++export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {++ const issues: string[] = []++ const limit = (what: keyof typeof CREATOR_MAP_LIMITS, count: number): void => {++ if (count > CREATOR_MAP_LIMITS[what])++ issues.push(++ `${what}: ${count} is more than the ${CREATOR_MAP_LIMITS[what]} a creator world takes`,++ )++ }++ limit('bytes', bytes)++ limit('terrains', map.terrains.length)++ limit('statics', map.statics.length)++ limit('nodes', map.nodes.length)++ limit('props', map.props.length)++ limit('lights', map.lights.length)++ limit('zones', map.zones.length)++ if (map.models.length > 0)++ issues.push('models: uploaded models are not available in creator worlds yet')++ if (map.textures.length > 0)++ issues.push('textures: uploaded textures are not available in creator worlds yet')++ const painted = (++ surface: { paint?: { layers: unknown[]; mask?: string | undefined } | undefined } | undefined,++ ): boolean => !!surface?.paint && (surface.paint.layers.length > 0 || !!surface.paint.mask)++ if (map.terrains.some((t) => painted(t.surface)))++ issues.push('terrains: painted surfaces are not available in creator worlds yet')++ if (++ map.statics.some(++ (s) =>++ painted(s.surface) || Object.values(s.surfaces ?? {}).some((surface) => painted(surface)),++ )++ )++ issues.push('statics: painted surfaces are not available in creator worlds yet')++ return issues++}nnew file mode 100644@@@ -0,0 +1,22 @@++-- phase: expand++-- Creator worlds: a signed-in player's own place, built in /editor and published at /worlds. The map document is++-- the editor's v2 file (validated before it is stored); `revision` is its content hash, the editor's If-Match.++-- A world runs as the place 'world:<id>' (its rows in places/world_entities/... are removed with the world).++-- `status`: draft (only the owner sees it), published (listed and joinable), hidden (staff took it down; the++-- owner can still open it in the editor, nobody can join it).++CREATE TABLE creator_worlds (++ id text COLLATE "C" PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL,++ owner_name text NOT NULL,++ title text NOT NULL,++ summary text NOT NULL DEFAULT '',++ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'hidden')),++ map jsonb NOT NULL,++ revision text COLLATE "C" NOT NULL,++ plays integer NOT NULL DEFAULT 0,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ published_at bigint++);++CREATE INDEX creator_worlds_owner ON creator_worlds (owner_subject, updated_at DESC);++CREATE INDEX creator_worlds_listed ON creator_worlds (published_at DESC) WHERE status = 'published';@@@ -5,3 +5,14 @@ export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/export { InstanceRegistry, type InstanceLease, type InstanceState } from './pg/instanceRegistry.js'export { MIGRATIONS_DIR } from './migrations.js'export { moveCasualSaves, exportCasual, eraseCasual } from './pg/casualData.js'++export {++ createCreatorWorldStore,++ mapRevision,++ removeWorldPlaces,++ exportCreatorWorlds,++ eraseCreatorWorlds,++ moveCreatorWorlds,++ type CreatorWorld,++ type CreatorWorldStatus,++ type CreatorWorldStore,++} from './pg/creatorWorlds.js'nnew file mode 100644@@@ -0,0 +1,97 @@++import { describe, expect, it } from 'vitest'++import { sql } from 'openvibe-sdk/db'++import { openTestDb } from '../testing.js'++import { openPgStore } from './pgStore.js'++import { createIdentityRepository } from './platformRepositories.js'++import { createCreatorWorldStore, mapRevision } from './creatorWorlds.js'++++const KIM = 'usr_01J0000000000000000000000K'++const LEE = 'usr_01J0000000000000000000000L'++const MAP = {++ v: 2,++ terrains: [],++ statics: [],++ nodes: [],++ props: [],++ lights: [],++ zones: [],++ models: [],++ textures: [],++}++++describe('creator worlds store', () => {++ it('creates, lists, saves with If-Match, publishes once and removes the world with its place', async () => {++ const db = await openTestDb()++ const store = createCreatorWorldStore(db)++ const world = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Moon base',++ summary: '',++ map: MAP,++ now: 1000,++ })++ expect(world.id).toMatch(/^w[0-9a-z]{10}$/)++ expect(world).toMatchObject({++ status: 'draft',++ revision: mapRevision(MAP),++ publishedAt: null,++ plays: 0,++ })++ expect(await store.countByOwner(KIM)).toBe(1)++ expect(await store.listPublished(10, 0)).toEqual([])++++ const next = { ...MAP, lights: [] as unknown[], spawn: [1, 2, 3] }++ expect(await store.saveMap(world.id, next, 'stale', 2000)).toBe('conflict')++ const saved = await store.saveMap(world.id, next, world.revision, 2000)++ expect(saved).not.toBe('conflict')++ expect((await store.map(world.id))?.map).toEqual(next)++ expect(await store.saveMap('wnope000000', next, null, 2000)).toBeNull()++++ const published = await store.update(world.id, { status: 'published' }, 3000)++ expect(published?.publishedAt).toBe(3000)++ await store.update(world.id, { status: 'draft' }, 4000)++ const again = await store.update(world.id, { status: 'published', title: 'Moon base 2' }, 5000)++ expect(again).toMatchObject({ publishedAt: 3000, title: 'Moon base 2' })++ expect((await store.listPublished(10, 0)).map((w) => w.id)).toEqual([world.id])++ await store.countPlay(world.id)++ expect((await store.get(world.id))?.plays).toBe(1)++ await store.rename(KIM, 'Kim')++ expect((await store.get(world.id))?.ownerName).toBe('Kim')++++ // The place's rows go with the world.++ const place = `world:${world.id}`++ const placeStore = openPgStore(db, { placeId: place })++ await placeStore.ensurePlace('Moon base')++ await placeStore.meta.set('world_seeded', true)++ expect(await store.remove(world.id)).toBe(true)++ expect(await store.get(world.id)).toBeNull()++ expect(await db.maybe(sql`SELECT 1 FROM places WHERE id = ${place}`)).toBeNull()++ expect(await db.maybe(sql`SELECT 1 FROM world_meta WHERE place_id = ${place}`)).toBeNull()++ })++++ it('follows the account: export, merge and erasure (ADR-033, ADR-029)', async () => {++ const db = await openTestDb()++ const store = createCreatorWorldStore(db)++ const identity = createIdentityRepository(db)++ const kims = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'A',++ summary: 's',++ map: MAP,++ now: 1,++ })++ const exported = await identity.exportSubject(KIM)++ expect(exported.creatorWorlds).toHaveLength(1)++ expect(exported.creatorWorlds[0]).toMatchObject({ id: kims.id, title: 'A' })++ expect(exported.creatorWorlds[0]).not.toHaveProperty('owner_subject')++++ await identity.mergeSubject(KIM, LEE, 'merge-1', 10)++ expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)++++ const erased = await identity.eraseSubjects([LEE])++ expect(erased.creator_worlds).toBe(1)++ expect(await store.get(kims.id)).toBeNull()++ })++})nnew file mode 100644@@@ -0,0 +1,254 @@++import { createHash, randomBytes } from 'node:crypto'++import { sql, type Db } from 'openvibe-sdk/db'++import { idList } from './sqlUtils.js'++++/**++ * Creator worlds (migration 0010): a signed-in player's place, built in /editor and published at /worlds. The map++ * is stored as the validated v2 document; callers validate it first (the content package's parseMapFile and++ * creatorMapIssues), this module only stores, lists and removes. A world runs as the place `world:<id>`, so removing++ * one also removes that place's rows.++ */++++export type CreatorWorldStatus = 'draft' | 'published' | 'hidden'++++export interface CreatorWorld {++ id: string++ ownerSubject: string++ ownerName: string++ title: string++ summary: string++ status: CreatorWorldStatus++ revision: string++ plays: number++ createdAt: number++ updatedAt: number++ publishedAt: number | null++}++++interface Row {++ id: string++ owner_subject: string++ owner_name: string++ title: string++ summary: string++ status: CreatorWorldStatus++ revision: string++ plays: number++ created_at: number | string++ updated_at: number | string++ published_at: number | string | null++}++++const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at`++++function fromRow(row: Row): CreatorWorld {++ return {++ id: row.id,++ ownerSubject: row.owner_subject,++ ownerName: row.owner_name,++ title: row.title,++ summary: row.summary,++ status: row.status,++ revision: row.revision,++ plays: Number(row.plays),++ createdAt: Number(row.created_at),++ updatedAt: Number(row.updated_at),++ publishedAt: row.published_at === null ? null : Number(row.published_at),++ }++}++++/** The revision of a stored map: a hash of the document as it is stored. */++export function mapRevision(map: unknown): string {++ return createHash('sha256').update(JSON.stringify(map)).digest('hex').slice(0, 24)++}++++/** `w` + ten base-36 characters (content's CREATOR_WORLD_ID). */++export function newCreatorWorldId(): string {++ const bytes = randomBytes(10)++ let id = 'w'++ for (const b of bytes) id += (b % 36).toString(36)++ return id++}++++/** The place ids a world's state lives under: its own and any extra room's (`<place>:instance:<n>`). */++function placeMatch(worldId: string) {++ const place = `world:${worldId}`++ return sql`(place_id = ${place} OR place_id LIKE ${`${place}:instance:%`})`++}++++export interface CreatorWorldStore {++ create(input: {++ ownerSubject: string++ ownerName: string++ title: string++ summary: string++ map: unknown++ now: number++ }): Promise<CreatorWorld>++ get(id: string): Promise<CreatorWorld | null>++ /** The stored map document and its revision. */++ map(id: string): Promise<{ map: unknown; revision: string } | null>++ countByOwner(ownerSubject: string): Promise<number>++ listByOwner(ownerSubject: string): Promise<CreatorWorld[]>++ /** Published worlds, newest first. */++ listPublished(limit: number, offset: number): Promise<CreatorWorld[]>++ /**++ * Replaces the map when `ifMatch` is the stored revision (or absent). Answers the new world, 'conflict' when the++ * revision moved, or null when the world is gone.++ */++ saveMap(++ id: string,++ map: unknown,++ ifMatch: string | null,++ now: number,++ ): Promise<CreatorWorld | 'conflict' | null>++ update(++ id: string,++ patch: { title?: string; summary?: string; status?: CreatorWorldStatus },++ now: number,++ ): Promise<CreatorWorld | null>++ /** The owner's display name follows the Network's (set at each signed-in write). */++ rename(ownerSubject: string, ownerName: string): Promise<void>++ countPlay(id: string): Promise<void>++ /** Removes the world and its place's rows. */++ remove(id: string): Promise<boolean>++}++++export function createCreatorWorldStore(db: Db): CreatorWorldStore {++ return {++ async create(input) {++ const id = newCreatorWorldId()++ const row = await db.one<Row>(sql`++ INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at)++ VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},++ ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now})++ RETURNING ${COLUMNS}++ `)++ return fromRow(row)++ },++ async get(id) {++ const row = await db.maybe<Row>(sql`SELECT ${COLUMNS} FROM creator_worlds WHERE id = ${id}`)++ return row ? fromRow(row) : null++ },++ async map(id) {++ const row = await db.maybe<{ map: unknown; revision: string }>(++ sql`SELECT map, revision FROM creator_worlds WHERE id = ${id}`,++ )++ return row ? { map: row.map, revision: row.revision } : 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}`,++ )++ return Number(row.n)++ },++ async listByOwner(ownerSubject) {++ const rows = await db.many<Row>(++ sql`SELECT ${COLUMNS} FROM creator_worlds WHERE owner_subject = ${ownerSubject} ORDER BY updated_at DESC`,++ )++ return rows.map(fromRow)++ },++ async listPublished(limit, offset) {++ const rows = await db.many<Row>(sql`++ SELECT ${COLUMNS} FROM creator_worlds WHERE status = 'published'++ ORDER BY published_at DESC, id LIMIT ${limit} OFFSET ${offset}++ `)++ return rows.map(fromRow)++ },++ async saveMap(id, map, ifMatch, now) {++ return db.tx(async () => {++ const current = await db.maybe<{ revision: string }>(++ sql`SELECT revision FROM creator_worlds WHERE id = ${id} FOR UPDATE`,++ )++ if (!current) return null++ if (ifMatch && ifMatch !== current.revision) return 'conflict' as const++ const row = await db.one<Row>(sql`++ UPDATE creator_worlds SET map = ${sql.json(map)}, revision = ${mapRevision(map)}, updated_at = ${now}++ WHERE id = ${id} RETURNING ${COLUMNS}++ `)++ return fromRow(row)++ })++ },++ async update(id, patch, now) {++ const sets = [sql`updated_at = ${now}`]++ if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`)++ if (patch.summary !== undefined) sets.push(sql`summary = ${patch.summary}`)++ if (patch.status !== undefined) {++ sets.push(sql`status = ${patch.status}`)++ // The first publication dates the listing; unpublishing and publishing again keeps the date.++ if (patch.status === 'published')++ sets.push(sql`published_at = COALESCE(published_at, ${now})`)++ }++ const row = await db.maybe<Row>(++ sql`UPDATE creator_worlds SET ${sql.join(sets)} WHERE id = ${id} RETURNING ${COLUMNS}`,++ )++ return row ? fromRow(row) : null++ },++ async rename(ownerSubject, ownerName) {++ await db.exec(++ sql`UPDATE creator_worlds SET owner_name = ${ownerName} WHERE owner_subject = ${ownerSubject} AND owner_name <> ${ownerName}`,++ )++ },++ async countPlay(id) {++ await db.exec(sql`UPDATE creator_worlds SET plays = plays + 1 WHERE id = ${id}`)++ },++ async remove(id) {++ return db.tx(async () => {++ const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)++ await removeWorldPlaces(db, [id])++ return gone > 0++ })++ },++ }++}++++/** Every row a creator world's place left behind (call inside the transaction that removes the worlds). */++export async function removeWorldPlaces(db: Db, worldIds: readonly string[]): Promise<void> {++ for (const id of worldIds) {++ const where = placeMatch(id)++ await db.exec(sql`++ DELETE FROM character_instance_positions++ WHERE instance_id IN (SELECT id FROM game_instances WHERE ${where})++ `)++ await db.exec(sql`DELETE FROM game_instances WHERE ${where}`)++ await db.exec(sql`DELETE FROM world_constraints WHERE ${where}`)++ await db.exec(sql`DELETE FROM world_entities WHERE ${where}`)++ await db.exec(sql`DELETE FROM world_meta WHERE ${where}`)++ await db.exec(sql`DELETE FROM mod_storage WHERE ${where}`)++ const place = `world:${id}`++ await db.exec(sql`DELETE FROM places WHERE id = ${place} OR id LIKE ${`${place}:instance:%`}`)++ }++}++++/** A person's worlds, for their data export (ADR-033): everything but the account key. */++export async function exportCreatorWorlds(++ db: Db,++ subject: string,++): Promise<Record<string, unknown>[]> {++ return db.many<Record<string, unknown>>(sql`++ SELECT id, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, map++ FROM creator_worlds WHERE owner_subject = ${subject} ORDER BY created_at++ `)++}++++/** Erasure (ADR-033): the worlds go with their places. Answers how many. */++export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): Promise<number> {++ if (!subjects.length) return 0++ const ids = (++ await db.many<{ id: string }>(++ sql`SELECT id FROM creator_worlds WHERE owner_subject IN (${idList(subjects)})`,++ )++ ).map((r) => r.id)++ if (!ids.length) return 0++ await db.exec(sql`DELETE FROM creator_worlds WHERE id IN (${idList(ids)})`)++ await removeWorldPlaces(db, ids)++ return ids.length++}++++/** Account merge (ADR-029): the folded-in account's worlds belong to the survivor. */++export async function moveCreatorWorlds(db: Db, from: string, into: string): Promise<number> {++ if (from === into) return 0++ return db.exec(++ sql`UPDATE creator_worlds SET owner_subject = ${into} WHERE owner_subject = ${from}`,++ )++}@@@ -165,6 +165,7 @@ describe('account export and deletion (ADR-033)', () => {structures_unowned: 1,identity_rows: 1,casual_saves: 2,++ creator_worlds: 0,})expect(await store.db.many(sql`SELECT account_key FROM casual_ledger ORDER BY account_key`),@@@ -185,6 +186,7 @@ describe('account export and deletion (ADR-033)', () => {structures_unowned: 0,identity_rows: 0,casual_saves: 0,++ creator_worlds: 0,})})})@@@ -12,6 +12,7 @@ import type {import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'import { idList } from './sqlUtils.js'import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'++import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'/*** PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry@@@ -163,6 +164,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {moved++}await moveProgress(from, into)++ await moveCreatorWorlds(db, from, into)await recordAdoption(key, into, 'merge', moved, kept, now)return { moved, kept, already: false }})@@@ -191,6 +193,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,)const casual = await exportCasual(db, subject)++ const creatorWorlds = await exportCreatorWorlds(db, subject)return {characters: characters.map(withoutAccountKey),structures,@@@ -200,12 +203,19 @@ export function createIdentityRepository(db: Db): IdentityRepository {gameHauls,casualSaves: casual.saves,casualLedger: casual.ledger,++ creatorWorlds,}},eraseSubjects(subjects) {return db.tx(async () => {-- const out = { characters: 0, structures_unowned: 0, identity_rows: 0, casual_saves: 0 }++ const out = {++ characters: 0,++ structures_unowned: 0,++ identity_rows: 0,++ casual_saves: 0,++ creator_worlds: 0,++ }if (!subjects.length) return outconst ids = (await db.many<{ id: string }>(@@@ -229,6 +239,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {)await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)out.casual_saves = await eraseCasual(db, subjects)++ out.creator_worlds = await eraseCreatorWorlds(db, subjects)return out})},@@@ -91,6 +91,8 @@ export interface IdentityRepository {/** Casual-kit saves (plan T22) and their ledger. */casualSaves: Record<string, unknown>[]casualLedger: Record<string, unknown>[]++ /** The person's creator worlds, maps included. */++ creatorWorlds: Record<string, unknown>[]}>/*** Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their@@@ -102,6 +104,7 @@ export interface IdentityRepository {structures_unowned: numberidentity_rows: numbercasual_saves: number++ creator_worlds: number}>}@@@ -21,6 +21,7 @@ const TABLES = ['casual_ledger','casual_actions','play_sync',++ 'creator_worlds','game_instances','character_instance_positions','world_meta',nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/editor/net/mapEndpoints.test.ts'++// covers: 'apps/client/src/editor/net'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/worlds/creatorWorlds.test.ts'++// covers: 'apps/server/src/worlds'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/worlds/worldRoutes.test.ts'++// covers: 'apps/server/src/worlds'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/content/src/packs/creatorWorld.test.ts'++// covers: 'packages/content/src/packs'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/persistence/src/pg/creatorWorlds.test.ts'++// covers: 'packages/persistence/src/pg'++// Generated by node test/run.js --index; do not edit.
@@ -17,6 +17,11 @@ GAMES_INSTANCES_PER_PLACE=1
@@ -17,6 +17,11 @@ GAMES_INSTANCES_PER_PLACE=1
MAX_PLAYERS=64
MAX_PLAYERS=64
GAMES_INTEREST_CELL=16
GAMES_INTEREST_CELL=16
GAMES_INTEREST_RADIUS=80
GAMES_INTEREST_RADIUS=80
+# Creator worlds (/worlds): running at once, kept per account, idle stop (seconds), players per world.
+# GAMES_CREATOR_WORLDS_MAX=2
+# GAMES_CREATOR_WORLDS_PER_PERSON=5
+# GAMES_CREATOR_WORLD_IDLE_SECONDS=180
+# GAMES_CREATOR_WORLD_PLAYERS=16
# ── Valkey (optional): shared per-actor rate-limit counters across instances ──
# ── Valkey (optional): shared per-actor rate-limit counters across instances ──
VALKEY_URL=
VALKEY_URL=
@@ -8,6 +8,8 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard and
@@ -8,6 +8,8 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard and
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
See [docs/games.md](docs/games.md) for both games and local progression.
See [docs/games.md](docs/games.md) for both games and local progression.
+Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join
+(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).
It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
**Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md).
**Vibe Heroes**, an idle hero collector at `/play/vibe-heroes`. See [docs/casual.md](docs/casual.md).
A persistent multiplayer physics sandbox for the browser: Source-style movement,
A persistent multiplayer physics sandbox for the browser: Source-style movement,
@@ -231,18 +233,22 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -231,18 +233,22 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`):
Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`):
-| Variable | Purpose |
-| --------------------------- | ------------------------------------------------------------------------------------------ |
-| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |
-| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |
-| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |
-| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |
-| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |
-| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |
-| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |
-| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local |
-| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
-| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
+| Variable | Purpose |
+| ---------------------------------- | ------------------------------------------------------------------------------------------ |
+| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |
+| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |
+| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |
+| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |
+| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |
+| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |
+| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |
+| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local |
+| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
+| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
+| `GAMES_CREATOR_WORLDS_MAX` | Creator worlds running at once (default 2; 0 stops new ones starting) |
+| `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) |
## Capabilities
## Capabilities
@@ -706,6 +706,84 @@
@@ -706,6 +706,84 @@
</div>
</div>
</div>
</div>
</div>
</div>
+ <div class="game-card wide">
+ <div
+ class="shot"
+ role="img"
+ aria-label="Creator worlds: a block of raised terrain with a tower, a lamp and a crate being placed"
+ >
+ <svg
+ class="shot-art"
+ viewBox="0 0 400 260"
+ preserveAspectRatio="xMidYMid meet"
+ aria-hidden="true"
+ focusable="false"
+ >
+ <!-- an island of terrain, seen at an angle -->
+ <path
+ d="M200 70 340 140 200 210 60 140z"
+ fill="#14233f"
+ stroke="#3b82f6"
+ stroke-width="2"
+ />
+ <path d="M60 140v26l140 70v-26z" fill="#0d1830" />
+ <path d="M340 140v26l-140 70v-26z" fill="#0a1426" />
+ <path
+ d="M150 128q30-30 62-10t58 4"
+ fill="none"
+ stroke="#22d3ee"
+ stroke-width="2"
+ stroke-opacity=".6"
+ />
+ <!-- a tower -->
+ <path
+ d="M118 120l26-13 26 13v-46l-26-13-26 13z"
+ fill="#1e3a66"
+ stroke="#60a5fa"
+ stroke-width="1.5"
+ />
+ <path d="M118 74l26 13 26-13" fill="none" stroke="#60a5fa" stroke-width="1.5" />
+ <path d="M144 87v46" stroke="#60a5fa" stroke-width="1.5" />
+ <!-- a lamp and its light -->
+ <circle cx="262" cy="96" r="26" fill="#fbbf24" fill-opacity=".12" />
+ <path d="M262 132v-34" stroke="#96a7c2" stroke-width="3" stroke-linecap="round" />
+ <circle cx="262" cy="96" r="6" fill="#fde68a" />
+ <!-- a crate being placed, with the editor's move gizmo -->
+ <path
+ d="M210 150l18-9 18 9-18 9z"
+ fill="#3b82f6"
+ fill-opacity=".35"
+ stroke="#60a5fa"
+ stroke-dasharray="4 3"
+ />
+ <path
+ d="M210 130l18-9 18 9v20l-18 9-18-9z"
+ fill="#2563eb"
+ fill-opacity=".55"
+ stroke="#93c5fd"
+ stroke-width="1.5"
+ />
+ <path d="M228 120V96" stroke="#34d399" stroke-width="2.5" />
+ <path d="M223 100l5-8 5 8z" fill="#34d399" />
+ <path d="M246 130l20-10" stroke="#f87171" stroke-width="2.5" />
+ <path d="M262 117l8-1-4 7z" fill="#f87171" />
+ </svg>
+ </div>
+ <div class="game-body">
+ <span class="badge live">● NEW</span>
+ <h3>CREATOR WORLDS</h3>
+ <p>
+ Build your own world in the map editor: terrain, buildings, lights, zones and props.
+ Publish it and anyone can join from their browser, or browse what other players made.
+ </p>
+ <div class="tags">
+ <span>map editor</span><span>publish</span><span>multiplayer</span>
+ </div>
+ <div class="cta-row">
+ <a class="play-cta" href="/worlds">▶ BROWSE & BUILD</a>
+ </div>
+ </div>
+ </div>
</section>
</section>
<section class="account">
<section class="account">
@@ -30,6 +30,8 @@ export interface AssetControllerOptions {
@@ -30,6 +30,8 @@ export interface AssetControllerOptions {
history: CommandHistory<EditorDocument>
history: CommandHistory<EditorDocument>
modelCache: ModelCache
modelCache: ModelCache
editorToken: () => string
editorToken: () => string
+ /** The upload endpoint; null where uploads are off (a creator world). Default /api/map-assets. */
+ assetsUrl?: string | null
/** Re-register custom textures with the renderer after a change. */
/** Re-register custom textures with the renderer after a change. */
onAssetsChanged: () => void
onAssetsChanged: () => void
setMessage: (m: string) => void
setMessage: (m: string) => void
@@ -119,8 +121,10 @@ export class AssetController {
@@ -119,8 +121,10 @@ export class AssetController {
/** POST raw bytes to the content-addressed store. */
/** POST raw bytes to the content-addressed store. */
private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {
private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {
+ const url = this.opts.assetsUrl === undefined ? '/api/map-assets' : this.opts.assetsUrl
+ if (url === null) return 'forbidden'
try {
try {
- const resp = await fetch('/api/map-assets', {
+ const resp = await fetch(url, {
method: 'POST',
method: 'POST',
headers: { authorization: `Bearer ${this.opts.editorToken()}` },
headers: { authorization: `Bearer ${this.opts.editorToken()}` },
body: blob,
body: blob,
@@ -133,6 +137,12 @@ export class AssetController {
@@ -133,6 +137,12 @@ export class AssetController {
}
}
}
}
+ private forbiddenMessage(): string {
+ return this.opts.assetsUrl === null
+ ? '⛔ creator worlds use the built-in materials for now (uploads are staff-only)'
+ : '⛔ import needs your admin token (top right)'
+ }
+
textureUsage(): Map<string, TextureUsage> {
textureUsage(): Map<string, TextureUsage> {
return collectTextureUsage(this.opts.doc)
return collectTextureUsage(this.opts.doc)
}
}
@@ -148,7 +158,7 @@ export class AssetController {
@@ -148,7 +158,7 @@ export class AssetController {
setMessage('🖼 uploading texture…')
setMessage('🖼 uploading texture…')
const uploaded = await this.upload(file)
const uploaded = await this.upload(file)
if (uploaded === 'forbidden') {
if (uploaded === 'forbidden') {
- setMessage('⛔ import needs your admin token (top right)')
+ setMessage(this.forbiddenMessage())
return null
return null
}
}
if (!uploaded) {
if (!uploaded) {
@@ -177,7 +187,7 @@ export class AssetController {
@@ -177,7 +187,7 @@ export class AssetController {
setMessage('🗿 uploading model…')
setMessage('🗿 uploading model…')
const uploaded = await this.upload(file)
const uploaded = await this.upload(file)
if (uploaded === 'forbidden') {
if (uploaded === 'forbidden') {
- setMessage('⛔ import needs your admin token (top right)')
+ setMessage(this.forbiddenMessage())
return null
return null
}
}
if (!uploaded) {
if (!uploaded) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+import type { EditorConnection } from './editorConnection.js'
+
+/**
+ * The editor's connection when nobody else can be editing: a creator world has one editor, its owner, and no
+ * collaboration socket (/editor-ws is staff-only). Mutations still go through the same lock gate; every request is
+ * granted at once, so `acquire` answers true (proceed now) and never calls `then`.
+ */
+export function createSoloConnection(): EditorConnection {
+ const held = new Set<string>()
+ return {
+ connect: () => {},
+ disconnect: () => {},
+ sendSelection: () => {},
+ canInspect: () => true,
+ isLockedByOther: () => false,
+ owns: (id) => held.has(id),
+ ownsAll: (ids) => ids.every((id) => held.has(id)),
+ acquire: (ids) => {
+ for (const id of ids) held.add(id)
+ return true
+ },
+ release: (ids) => {
+ for (const id of ids) held.delete(id)
+ },
+ releaseAll: () => held.clear(),
+ lockOwner: () => null,
+ lockOwners: () => new Map(),
+ lockColors: () => new Map(),
+ remoteSelectionColors: () => new Map(),
+ peers: () => [],
+ peerCount: () => 0,
+ myPeerId: () => 0,
+ connected: () => true,
+ }
+}
@@ -72,6 +72,7 @@ import { PaintSurfaceRegistry } from './materials/paintSurfaceRegistry.js'
@@ -72,6 +72,7 @@ import { PaintSurfaceRegistry } from './materials/paintSurfaceRegistry.js'
import { buildShell } from './ui/editorShell.js'
import { buildShell } from './ui/editorShell.js'
import { createEditorUi } from './ui/editorUi.js'
import { createEditorUi } from './ui/editorUi.js'
import { createSaveController } from './net/saveController.js'
import { createSaveController } from './net/saveController.js'
+import { fetchMap, mapEndpoints } from './net/mapEndpoints.js'
import { editorToken, ssoToken } from './net/editorToken.js'
import { editorToken, ssoToken } from './net/editorToken.js'
import { ActionRouter } from './input/actionRouter.js'
import { ActionRouter } from './input/actionRouter.js'
import { ACTIONS, loadBindings, type Binding } from './bindings.js'
import { ACTIONS, loadBindings, type Binding } from './bindings.js'
@@ -113,7 +114,9 @@ export async function bootEditor(): Promise<void> {
@@ -113,7 +114,9 @@ export async function bootEditor(): Promise<void> {
// /map.json is the v2 document, so the editor only ever holds v2 — and a
// /map.json is the v2 document, so the editor only ever holds v2 — and a
// missing or invalid map is emptyMapV2(), never a fabricated starter
// missing or invalid map is emptyMapV2(), never a fabricated starter
// heightfield.
// heightfield.
- const bootResp = await fetch('/map.json')
+ // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor).
+ const endpoints = mapEndpoints()
+ const bootResp = await fetchMap(endpoints)
const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''
const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''
const bootParsed = parseMapFile(await bootResp.json().catch(() => null))
const bootParsed = parseMapFile(await bootResp.json().catch(() => null))
if (!bootParsed.ok) console.warn('[editor] map rejected, starting empty:', bootParsed.issues)
if (!bootParsed.ok) console.warn('[editor] map rejected, starting empty:', bootParsed.issues)
@@ -730,31 +733,33 @@ export async function bootEditor(): Promise<void> {
@@ -730,31 +733,33 @@ export async function bootEditor(): Promise<void> {
},
},
paintSurfaces,
paintSurfaces,
setMessage: (m) => ui.setMessage(m),
setMessage: (m) => ui.setMessage(m),
+ endpoints,
})
})
- const { createEditorConnection } = await import('./collaboration/editorConnection.js')
- const connection = createEditorConnection({
- scene,
- peersEl: document.getElementById('peers') as HTMLElement,
- camera,
- keyOf: editorToken,
- nameOf: defaultEditorName,
- onRemoteSaved: () => void saveController.pollRemote(),
- onLockDenied: (owner) => ui.setMessage(`🔒 held by ${owner} — you have read-only access`),
- onChange: () => {
- refreshVisuals()
- ui.refreshAll()
- },
- onLockLost: () => {
- // The lease went away mid-gesture: restore the exact starting state
- // rather than leaving half-applied local edits the server disagrees
- // with.
- if (xform.active) xform.cancel()
- selection.unfreeze()
- lockedForSelection = []
- ui.setMessage('⚠ edit lock lost — reselect to reacquire')
- },
- })
+ const connection = endpoints.collaboration
+ ? (await import('./collaboration/editorConnection.js')).createEditorConnection({
+ scene,
+ peersEl: document.getElementById('peers') as HTMLElement,
+ camera,
+ keyOf: editorToken,
+ nameOf: defaultEditorName,
+ onRemoteSaved: () => void saveController.pollRemote(),
+ onLockDenied: (owner) => ui.setMessage(`🔒 held by ${owner} — you have read-only access`),
+ onChange: () => {
+ refreshVisuals()
+ ui.refreshAll()
+ },
+ onLockLost: () => {
+ // The lease went away mid-gesture: restore the exact starting state
+ // rather than leaving half-applied local edits the server disagrees
+ // with.
+ if (xform.active) xform.cancel()
+ selection.unfreeze()
+ lockedForSelection = []
+ ui.setMessage('⚠ edit lock lost — reselect to reacquire')
+ },
+ })
+ : (await import('./collaboration/soloConnection.js')).createSoloConnection()
// ── UI ──────────────────────────────────────────────────────────────
// ── UI ──────────────────────────────────────────────────────────────
const bindings = loadBindings(localStorage.getItem('openvibe.editor.bindings'))
const bindings = loadBindings(localStorage.getItem('openvibe.editor.bindings'))
@@ -765,6 +770,7 @@ export async function bootEditor(): Promise<void> {
@@ -765,6 +770,7 @@ export async function bootEditor(): Promise<void> {
history,
history,
modelCache,
modelCache,
editorToken,
editorToken,
+ assetsUrl: endpoints.assets,
onAssetsChanged: () => {
onAssetsChanged: () => {
registerCustomTextures(doc.textures() as MapTextureEntry[])
registerCustomTextures(doc.textures() as MapTextureEntry[])
ui.refreshAll()
ui.refreshAll()
@@ -996,6 +1002,8 @@ export async function bootEditor(): Promise<void> {
@@ -996,6 +1002,8 @@ export async function bootEditor(): Promise<void> {
// reloads the editor with the new token.
// reloads the editor with the new token.
if (!ssoToken()) {
if (!ssoToken()) {
ui.setMessage('Sign in with OpenVibe (top right) before editing')
ui.setMessage('Sign in with OpenVibe (top right) before editing')
+ } else if (endpoints.worldId) {
+ ui.setMessage('Your world: saving makes it live. Publish it from /worlds.')
}
}
connection.connect()
connection.connect()
// Offer any unsaved work from a previous session. Never automatic: a
// Offer any unsaved work from a previous session. Never automatic: a
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { mapEndpoints } from './mapEndpoints.js'
+
+describe('editor map endpoints', () => {
+ it("edits the place's map by default, with uploads and collaboration", () => {
+ expect(mapEndpoints('', 'https://openvibe.games')).toEqual({
+ worldId: null,
+ read: '/map.json',
+ save: '/api/map',
+ saveMethod: 'POST',
+ assets: '/api/map-assets',
+ collaboration: true,
+ draftKey: 'https://openvibe.games/map.json',
+ })
+ })
+
+ it('edits a creator world alone, with its own draft and no uploads', () => {
+ expect(mapEndpoints('?world=wabc123def0', 'https://openvibe.games')).toEqual({
+ worldId: 'wabc123def0',
+ read: '/api/worlds/wabc123def0/map',
+ save: '/api/worlds/wabc123def0/map',
+ saveMethod: 'PUT',
+ assets: null,
+ collaboration: false,
+ draftKey: 'https://openvibe.games/api/worlds/wabc123def0/map',
+ })
+ })
+
+ it('ignores a world parameter that is not a world id', () => {
+ expect(mapEndpoints('?world=../../api/map', 'https://x').worldId).toBeNull()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+import { CREATOR_WORLD_ID } from '@openvibe/content'
+import { editorToken } from './editorToken.js'
+
+/**
+ * Where the editor reads and saves its map. Staff edit the place's map (/map.json, POST /api/map, uploads, live
+ * collaboration). `/editor?world=<id>` edits a creator world instead: the owner's map at /api/worlds/<id>/map
+ * (PUT, with the Network token), built-in materials only and no collaboration socket (that one is staff-only).
+ */
+export interface MapEndpoints {
+ /** The creator world being edited, or null for the place's own map. */
+ worldId: string | null
+ /** GET: the v2 document, ETag = revision. */
+ read: string
+ save: string
+ saveMethod: 'POST' | 'PUT'
+ /** Content-addressed uploads (textures, models, paint masks), or null where uploads are off. */
+ assets: string | null
+ /** Live collaboration over /editor-ws. */
+ collaboration: boolean
+ /** The local draft store's key: one draft per map. */
+ draftKey: string
+}
+
+export function mapEndpoints(
+ search: string = location.search,
+ origin: string = location.origin,
+): MapEndpoints {
+ const requested = new URLSearchParams(search).get('world')
+ if (requested && CREATOR_WORLD_ID.test(requested)) {
+ const base = `/api/worlds/${requested}/map`
+ return {
+ worldId: requested,
+ read: base,
+ save: base,
+ saveMethod: 'PUT',
+ assets: null,
+ collaboration: false,
+ draftKey: `${origin}${base}`,
+ }
+ }
+ return {
+ worldId: null,
+ read: '/map.json',
+ save: '/api/map',
+ saveMethod: 'POST',
+ assets: '/api/map-assets',
+ collaboration: true,
+ draftKey: `${origin}/map.json`,
+ }
+}
+
+/** Reads the map being edited (a creator world's draft answers only its owner, so the token goes along). */
+export function fetchMap(endpoints: MapEndpoints): Promise<Response> {
+ const token = endpoints.worldId ? editorToken() : ''
+ return fetch(endpoints.read, {
+ cache: 'no-store',
+ ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
+ })
+}
@@ -21,6 +21,7 @@ import type { PaintSurfaceRegistry } from '../materials/paintSurfaceRegistry.js'
@@ -21,6 +21,7 @@ import type { PaintSurfaceRegistry } from '../materials/paintSurfaceRegistry.js'
import { surfaceDataFor, writeSurfaceData } from '../materials/paintController.js'
import { surfaceDataFor, writeSurfaceData } from '../materials/paintController.js'
import { DraftStore, indexedDbDraftStorage } from '../recovery/draftStore.js'
import { DraftStore, indexedDbDraftStorage } from '../recovery/draftStore.js'
import { editorToken } from './editorToken.js'
import { editorToken } from './editorToken.js'
+import { fetchMap, mapEndpoints, type MapEndpoints } from './mapEndpoints.js'
export interface SaveControllerOptions {
export interface SaveControllerOptions {
doc: EditorDocument
doc: EditorDocument
@@ -31,6 +32,8 @@ export interface SaveControllerOptions {
@@ -31,6 +32,8 @@ export interface SaveControllerOptions {
/** Every live painted surface, keyed by (object, surface). */
/** Every live painted surface, keyed by (object, surface). */
paintSurfaces: PaintSurfaceRegistry
paintSurfaces: PaintSurfaceRegistry
setMessage: (m: string) => void
setMessage: (m: string) => void
+ /** Where the map is read and saved (default: the place's own map). */
+ endpoints?: MapEndpoints
}
}
export interface SaveController {
export interface SaveController {
@@ -50,6 +53,8 @@ export interface SaveController {
@@ -50,6 +53,8 @@ export interface SaveController {
export function createSaveController(opts: SaveControllerOptions): SaveController {
export function createSaveController(opts: SaveControllerOptions): SaveController {
const { doc, history, setMessage } = opts
const { doc, history, setMessage } = opts
+ const endpoints = opts.endpoints ?? mapEndpoints('', location.origin)
+ const who = endpoints.worldId ? 'you saved in another tab' : 'another admin saved'
let baseRevision = opts.bootRevision
let baseRevision = opts.bootRevision
let remoteDoc: MapFileV2 = doc.serialize()
let remoteDoc: MapFileV2 = doc.serialize()
let nonHistoryDirt = false
let nonHistoryDirt = false
@@ -60,7 +65,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -60,7 +65,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
const drafts = new DraftStore({
const drafts = new DraftStore({
storage: indexedDbDraftStorage(),
storage: indexedDbDraftStorage(),
- mapKey: `${location.origin}/map.json`,
+ mapKey: endpoints.draftKey,
})
})
const isDirty = (): boolean => nonHistoryDirt || history.isDirty()
const isDirty = (): boolean => nonHistoryDirt || history.isDirty()
@@ -85,11 +90,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -85,11 +90,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
*/
*/
let maskUploads = 0
let maskUploads = 0
const uploadDirtyMasks = async (): Promise<void> => {
const uploadDirtyMasks = async (): Promise<void> => {
+ const assets = endpoints.assets
+ if (!assets) return
for (const surface of opts.paintSurfaces.dirtySurfaces()) {
for (const surface of opts.paintSurfaces.dirtySurfaces()) {
const blob = await surface.mask.toBlob()
const blob = await surface.mask.toBlob()
if (!blob) continue
if (!blob) continue
try {
try {
- const resp = await fetch('/api/map-assets', {
+ const resp = await fetch(assets, {
method: 'POST',
method: 'POST',
headers: { authorization: `Bearer ${tokenOf()}` },
headers: { authorization: `Bearer ${tokenOf()}` },
body: blob,
body: blob,
@@ -116,8 +123,8 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -116,8 +123,8 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
const file = serialize()
const file = serialize()
let resp: Response
let resp: Response
try {
try {
- resp = await fetch('/api/map', {
- method: 'POST',
+ resp = await fetch(endpoints.save, {
+ method: endpoints.saveMethod,
headers: {
headers: {
'content-type': 'application/json',
'content-type': 'application/json',
authorization: `Bearer ${token}`,
authorization: `Bearer ${token}`,
@@ -138,13 +145,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -138,13 +145,13 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
nonHistoryDirt = false
nonHistoryDirt = false
remoteDoc = file
remoteDoc = file
void drafts.markSaved()
void drafts.markSaved()
- setMessage('✅ saved — live in game')
+ setMessage(endpoints.worldId ? '✅ saved — live in your world' : '✅ saved — live in game')
notify()
notify()
return
return
}
}
if (resp.status === 409) {
if (resp.status === 409) {
await openConflict()
await openConflict()
- setMessage('⚠ another admin saved first — your revision is stale')
+ setMessage(`⚠ ${who} first — your revision is stale`)
return
return
}
}
if (resp.status === 422) {
if (resp.status === 422) {
@@ -153,8 +160,10 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -153,8 +160,10 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
return
return
}
}
setMessage(
setMessage(
- resp.status === 403
- ? '⛔ not authorized (admin token/key required)'
+ resp.status === 403 || resp.status === 401
+ ? endpoints.worldId
+ ? "⛔ sign in as the world's owner to save"
+ : '⛔ not authorized (admin token/key required)'
: resp.status === 413
: resp.status === 413
? '⛔ map too large'
? '⛔ map too large'
: `save failed (${resp.status})`,
: `save failed (${resp.status})`,
@@ -168,7 +177,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -168,7 +177,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
*/
*/
const pollRemote = async (): Promise<void> => {
const pollRemote = async (): Promise<void> => {
try {
try {
- const resp = await fetch('/map.json')
+ const resp = await fetchMap(endpoints)
const served = resp.headers.get('etag')?.replace(/"/g, '')
const served = resp.headers.get('etag')?.replace(/"/g, '')
if (served && served === baseRevision) return
if (served && served === baseRevision) return
const parsed = parseMapFile(await resp.json())
const parsed = parseMapFile(await resp.json())
@@ -177,7 +186,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -177,7 +186,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
if (isDirty()) {
if (isDirty()) {
remoteDoc = parsed.map
remoteDoc = parsed.map
await openConflict()
await openConflict()
- setMessage('⚠ another admin saved while you have unsaved changes')
+ setMessage(`⚠ ${who} while you have unsaved changes`)
return
return
}
}
remoteDoc = parsed.map
remoteDoc = parsed.map
@@ -186,7 +195,9 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -186,7 +195,9 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
// NOT a merge: the local copy was clean, so it was REPLACED by the
// NOT a merge: the local copy was clean, so it was REPLACED by the
// remote one. Calling that "merged" would tell the user their edits
// remote one. Calling that "merged" would tell the user their edits
// were combined with someone else's, which nothing here does.
// were combined with someone else's, which nothing here does.
- setMessage("🔄 loaded another admin's saved map")
+ setMessage(
+ endpoints.worldId ? '🔄 loaded the newer saved map' : "🔄 loaded another admin's saved map",
+ )
notify()
notify()
} catch {
} catch {
// Offline poll; the next one will pick it up.
// Offline poll; the next one will pick it up.
@@ -196,7 +207,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -196,7 +207,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
const openConflict = async (): Promise<void> => {
const openConflict = async (): Promise<void> => {
conflictOpen = true
conflictOpen = true
try {
try {
- const resp = await fetch('/map.json')
+ const resp = await fetchMap(endpoints)
const parsed = parseMapFile(await resp.json())
const parsed = parseMapFile(await resp.json())
conflictSummary = parsed.ok
conflictSummary = parsed.ok
? describeDiff(diffMapFileV2(serialize(), parsed.map))
? describeDiff(diffMapFileV2(serialize(), parsed.map))
@@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
import {
import {
+ CREATOR_WORLD_ID,
createContent,
createContent,
+ creatorPlaceId,
+ creatorWorldPack,
setMapOverride,
setMapOverride,
type MapLight,
type MapLight,
compileMapFileV2,
compileMapFileV2,
@@ -67,10 +70,26 @@ async function start(): Promise<void> {
@@ -67,10 +70,26 @@ async function start(): Promise<void> {
const uiRoot = document.getElementById('ui') as HTMLElement
const uiRoot = document.getElementById('ui') as HTMLElement
const identity = getIdentity()
const identity = getIdentity()
- const requestedGame = new URLSearchParams(location.search).get('game')
- const selectedPlace =
- requestedGame === 'salvage-rush' || requestedGame === 'scraplandia' ? requestedGame : undefined
- const placeQuery = selectedPlace ? `?game=${selectedPlace}` : ''
+ const params = new URLSearchParams(location.search)
+ const requestedGame = params.get('game')
+ // A creator world (/worlds): its own place, the plain creator pack and the world's map.
+ const requestedWorld = params.get('world')
+ const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null
+ const selectedPlace = worldId
+ ? creatorPlaceId(worldId)
+ : requestedGame === 'salvage-rush' || requestedGame === 'scraplandia'
+ ? requestedGame
+ : undefined
+ const placeQuery = worldId ? `?world=${worldId}` : selectedPlace ? `?game=${selectedPlace}` : ''
+ // A draft world's map answers only its owner, so the map read carries the Network token when there is one.
+ const mapRequest = (): Promise<Response> => {
+ if (!worldId) return fetch(`/map.json${placeQuery}`)
+ const token = localStorage.getItem('ovg_sso')
+ return fetch(`/api/worlds/${worldId}/map`, {
+ cache: 'no-store',
+ ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
+ })
+ }
// Fetch the active definition set before constructing game systems or sending hello.
// Fetch the active definition set before constructing game systems or sending hello.
// A fresh client can join after a staff script install or reload changed the digest.
// A fresh client can join after a staff script install or reload changed the digest.
let content = createContent()
let content = createContent()
@@ -86,7 +105,9 @@ async function start(): Promise<void> {
@@ -86,7 +105,9 @@ async function start(): Promise<void> {
const builtIn =
const builtIn =
data.game === 'salvage-rush'
data.game === 'salvage-rush'
? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
- : undefined
+ : data.game === 'creator' && worldId
+ ? creatorWorldPack(worldId)
+ : undefined
const active = createContent(data.packs, builtIn)
const active = createContent(data.packs, builtIn)
if (active.digest === data.digest) content = active
if (active.digest === data.digest) content = active
}
}
@@ -105,7 +126,7 @@ async function start(): Promise<void> {
@@ -105,7 +126,7 @@ async function start(): Promise<void> {
let mapLightsBoot: MapLight[] | undefined
let mapLightsBoot: MapLight[] | undefined
try {
try {
// /map.json is the v2 document, parsed and validated on the way in.
// /map.json is the v2 document, parsed and validated on the way in.
- const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())
+ const parsed = parseMapFile(await (await mapRequest()).json())
if (parsed.ok) {
if (parsed.ok) {
registerMapAssets({
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
textures: parsed.map.textures as MapTextureEntry[],
@@ -381,7 +402,7 @@ async function start(): Promise<void> {
@@ -381,7 +402,7 @@ async function start(): Promise<void> {
if (msg.t === 'map_reload') {
if (msg.t === 'map_reload') {
void (async () => {
void (async () => {
try {
try {
- const parsed = parseMapFile(await (await fetch(`/map.json${placeQuery}`)).json())
+ const parsed = parseMapFile(await (await mapRequest()).json())
if (!parsed.ok) return
if (!parsed.ok) return
registerMapAssets({
registerMapAssets({
textures: parsed.map.textures as MapTextureEntry[],
textures: parsed.map.textures as MapTextureEntry[],
@@ -487,8 +508,18 @@ async function start(): Promise<void> {
@@ -487,8 +508,18 @@ async function start(): Promise<void> {
.catch(() => {})
.catch(() => {})
}, 2000)
}, 2000)
}
}
- connection.onClose = () => {
+ connection.onClose = (code) => {
if (rejected) return
if (rejected) return
+ if (code === 4005) {
+ // A creator world that is not open: unpublished, taken down, or every world slot is busy. Reloading would
+ // only ask again, so say so and leave the way back.
+ rejected = true
+ hud.setStatus(
+ 'This world is not open right now. It may be busy or unpublished: try again in a minute.',
+ )
+ hud.toast('World not open — see /worlds', true)
+ return
+ }
// Come back on our own instead of freezing on a dead socket.
// Come back on our own instead of freezing on a dead socket.
hud.setStatus('connection lost — reconnecting automatically…')
hud.setStatus('connection lost — reconnecting automatically…')
hud.toast('Disconnected — reconnecting…', true)
hud.toast('Disconnected — reconnecting…', true)
@@ -93,7 +93,8 @@ export async function fetchWsTicket(args: {
@@ -93,7 +93,8 @@ export async function fetchWsTicket(args: {
export class Connection {
export class Connection {
private ws: WebSocket | null = null
private ws: WebSocket | null = null
onMessage: ((msg: ServerMessage) => void) | null = null
onMessage: ((msg: ServerMessage) => void) | null = null
- onClose: (() => void) | null = null
+ /** The socket closed; `code` is the server's close code (4005: the creator world is not open). */
+ onClose: ((code: number) => void) | null = null
async connect(
async connect(
url: string,
url: string,
@@ -146,7 +147,7 @@ export class Connection {
@@ -146,7 +147,7 @@ export class Connection {
}
}
this.onMessage?.(msg)
this.onMessage?.(msg)
}
}
- ws.onclose = () => this.onClose?.()
+ ws.onclose = (event) => this.onClose?.(event.code)
// Identity was resolved at the upgrade; hello carries no identity field.
// Identity was resolved at the upgrade; hello carries no identity field.
this.send(helloForContent(name, appearance, slot, contentDigest, place))
this.send(helloForContent(name, appearance, slot, contentDigest, place))
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,282 @@
@@ -0,0 +1,282 @@
+export {}
+
+/** /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete). */
+
+interface World {
+ id: string
+ title: string
+ summary: string
+ owner: string
+ plays: number
+ online: number
+ published_at: string | null
+ updated_at: string
+ play_url: string
+ status?: 'draft' | 'published' | 'hidden'
+ edit_url?: string
+}
+
+interface Mine {
+ worlds: World[]
+ max: number
+ staff: boolean
+}
+
+const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T
+const signedOut = $('signed-out')
+const signedIn = $('signed-in')
+const mineGrid = $('mine')
+const mineStatus = $('mine-status')
+const publishedGrid = $('published')
+const publishedStatus = $('published-status')
+const moreRow = $('more-row')
+const moreButton = $<HTMLButtonElement>('more')
+const createForm = $<HTMLFormElement>('create')
+const createTitle = $<HTMLInputElement>('create-title')
+const createSummary = $<HTMLTextAreaElement>('create-summary')
+const createButton = $<HTMLButtonElement>('create-button')
+const createStatus = $('create-status')
+
+/** The Network access token the site keeps after sign-in (the same one /play and /editor use). */
+function token(): string {
+ const stored = localStorage.getItem('ovg_sso')
+ if (stored) return stored
+ const cookie = document.cookie.split('; ').find((c) => c.startsWith('ovg_sso='))
+ return cookie ? decodeURIComponent(cookie.slice('ovg_sso='.length)) : ''
+}
+
+async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
+ const auth = token()
+ const resp = await fetch(path, {
+ ...init,
+ headers: {
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
+ ...(auth ? { authorization: `Bearer ${auth}` } : {}),
+ ...(init.headers as Record<string, string> | undefined),
+ },
+ })
+ if (resp.status === 204) return undefined as T
+ const body = (await resp.json().catch(() => ({}))) as { error?: string; detail?: string }
+ if (!resp.ok) throw new ApiError(resp.status, body.detail ?? body.error ?? `HTTP ${resp.status}`)
+ return body as T
+}
+
+class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message)
+ }
+}
+
+function el<K extends keyof HTMLElementTagNameMap>(
+ tag: K,
+ props: { className?: string; text?: string } = {},
+ ...children: (Node | null)[]
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag)
+ if (props.className) node.className = props.className
+ if (props.text !== undefined) node.textContent = props.text
+ for (const child of children) if (child) node.append(child)
+ return node
+}
+
+function link(text: string, href: string, primary = false): HTMLAnchorElement {
+ const a = el('a', { className: primary ? 'button primary' : 'button', text })
+ a.href = href
+ return a
+}
+
+function button(
+ text: string,
+ onClick: (b: HTMLButtonElement) => void,
+ className = '',
+): HTMLButtonElement {
+ const b = el('button', { className, text })
+ b.type = 'button'
+ b.addEventListener('click', () => onClick(b))
+ return b
+}
+
+const count = (n: number, one: string, many: string): string =>
+ `${n.toLocaleString()} ${n === 1 ? one : many}`
+
+function stats(world: World): string {
+ const parts = [count(world.plays, 'visit', 'visits')]
+ if (world.online > 0) parts.unshift(`${world.online} playing now`)
+ return parts.join(' · ')
+}
+
+function publishedCard(world: World, staff: boolean): HTMLElement {
+ const card = el(
+ 'article',
+ { className: 'card' },
+ el('h3', { text: world.title }),
+ el('p', { className: 'by', text: `by ${world.owner}` }),
+ world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
+ el('p', { className: 'stats', text: stats(world) }),
+ )
+ const actions = el('div', { className: 'actions' }, link('▶ Play', world.play_url, true))
+ if (staff)
+ actions.append(
+ button(
+ 'Take down',
+ (b) => void change(b, world.id, { status: 'hidden' }, 'Take this world down for everyone?'),
+ 'danger',
+ ),
+ )
+ card.append(actions)
+ return card
+}
+
+function mineCard(world: World): HTMLElement {
+ const status = world.status ?? 'draft'
+ const label = status === 'published' ? 'Published' : status === 'hidden' ? 'Taken down' : 'Draft'
+ const card = el(
+ 'article',
+ { className: 'card' },
+ el('span', { className: `badge ${status}`, text: label }),
+ el('h3', { text: world.title }),
+ world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
+ el('p', { className: 'stats', text: stats(world) }),
+ )
+ const actions = el('div', { className: 'actions' })
+ if (world.edit_url) actions.append(link('Edit', world.edit_url, true))
+ if (status !== 'hidden')
+ actions.append(link(status === 'published' ? 'Play' : 'Try it', world.play_url))
+ if (status === 'draft')
+ actions.append(button('Publish', (b) => void change(b, world.id, { status: 'published' })))
+ if (status === 'published')
+ actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
+ actions.append(
+ button('Rename', (b) => {
+ const title = prompt('New title (up to 60 characters)', world.title)
+ if (title !== null && title.trim() && title !== world.title)
+ void change(b, world.id, { title })
+ }),
+ button(
+ 'Delete',
+ (b) =>
+ void remove(
+ b,
+ world.id,
+ `Delete “${world.title}”? Its map and everything built in it go too.`,
+ ),
+ 'danger',
+ ),
+ )
+ if (status === 'hidden')
+ card.append(
+ el('p', {
+ className: 'summary',
+ text: 'Staff took this world down. You can still edit it; ask on openvibe.help to bring it back.',
+ }),
+ )
+ card.append(actions)
+ return card
+}
+
+let staff = false
+let page = 1
+
+async function change(
+ b: HTMLButtonElement,
+ id: string,
+ patch: Record<string, string>,
+ confirmText?: string,
+): Promise<void> {
+ if (confirmText && !confirm(confirmText)) return
+ b.disabled = true
+ try {
+ await api(`/api/worlds/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
+ await Promise.all([loadMine(), loadPublished(true)])
+ } catch (err) {
+ b.disabled = false
+ alert(err instanceof Error ? err.message : 'That did not work. Try again.')
+ }
+}
+
+async function remove(b: HTMLButtonElement, id: string, confirmText: string): Promise<void> {
+ if (!confirm(confirmText)) return
+ b.disabled = true
+ try {
+ await api(`/api/worlds/${id}`, { method: 'DELETE' })
+ await Promise.all([loadMine(), loadPublished(true)])
+ } catch (err) {
+ b.disabled = false
+ alert(err instanceof Error ? err.message : 'That did not work. Try again.')
+ }
+}
+
+async function loadMine(): Promise<void> {
+ if (!token()) {
+ signedOut.hidden = false
+ signedIn.hidden = true
+ return
+ }
+ try {
+ const mine = await api<Mine>('/api/worlds/mine')
+ staff = mine.staff
+ signedOut.hidden = true
+ signedIn.hidden = false
+ mineGrid.replaceChildren(...mine.worlds.map(mineCard))
+ const full = mine.worlds.length >= mine.max
+ createButton.disabled = full
+ mineStatus.textContent = mine.worlds.length
+ ? full
+ ? `You have ${mine.max} worlds, the most one person keeps. Delete one to make another.`
+ : ''
+ : 'No worlds yet. Give your first one a title and open it in the editor.'
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ signedOut.hidden = false
+ signedIn.hidden = true
+ return
+ }
+ mineStatus.textContent = 'Your worlds could not be loaded. Refresh to try again.'
+ }
+}
+
+async function loadPublished(reset = false): Promise<void> {
+ if (reset) page = 1
+ try {
+ const data = await api<{ worlds: World[]; more: boolean }>(`/api/worlds?page=${page}`)
+ const cards = data.worlds.map((w) => publishedCard(w, staff))
+ if (page === 1) publishedGrid.replaceChildren(...cards)
+ else publishedGrid.append(...cards)
+ moreRow.hidden = !data.more
+ publishedStatus.textContent =
+ page === 1 && data.worlds.length === 0
+ ? 'No published worlds yet. Yours could be the first.'
+ : ''
+ } catch {
+ publishedStatus.textContent = 'Worlds could not be loaded. Refresh to try again.'
+ }
+}
+
+createForm.addEventListener('submit', (event) => {
+ event.preventDefault()
+ void (async () => {
+ createButton.disabled = true
+ createStatus.textContent = 'Making your world…'
+ try {
+ const world = await api<World>('/api/worlds', {
+ method: 'POST',
+ body: JSON.stringify({ title: createTitle.value, summary: createSummary.value }),
+ })
+ createStatus.textContent = ''
+ if (world.edit_url) location.href = world.edit_url
+ } catch (err) {
+ createStatus.textContent = err instanceof Error ? err.message : 'That did not work.'
+ createButton.disabled = false
+ }
+ })()
+})
+
+moreButton.addEventListener('click', () => {
+ page++
+ void loadPublished()
+})
+
+void loadMine().then(() => loadPublished(true))
@@ -24,6 +24,7 @@ export default defineConfig({
@@ -24,6 +24,7 @@ export default defineConfig({
server.middlewares.use((req, _res, next) => {
server.middlewares.use((req, _res, next) => {
if (req.url === '/play') req.url = '/play.html'
if (req.url === '/play') req.url = '/play.html'
else if (req.url === '/editor') req.url = '/editor.html'
else if (req.url === '/editor') req.url = '/editor.html'
+ else if (req.url === '/worlds') req.url = '/worlds.html'
else if (/^\/play\/[a-z0-9-]+(\?|$)/.test(req.url ?? '')) req.url = '/casual.html'
else if (/^\/play\/[a-z0-9-]+(\?|$)/.test(req.url ?? '')) req.url = '/casual.html'
next()
next()
})
})
@@ -53,6 +54,7 @@ export default defineConfig({
@@ -53,6 +54,7 @@ export default defineConfig({
main: 'play.html',
main: 'play.html',
editor: 'editor.html',
editor: 'editor.html',
leaderboard: 'leaderboard.html',
leaderboard: 'leaderboard.html',
+ worlds: 'worlds.html',
casual: 'casual.html',
casual: 'casual.html',
},
},
// Babylon's core as a chain of layers no larger than 400 KB each, not one 2 MB chunk (scripts/vendorLayers.ts).
// Babylon's core as a chain of layers no larger than 400 KB each, not one 2 MB chunk (scripts/vendorLayers.ts).
new file mode 100644
new file mode 100644
@@ -0,0 +1,293 @@
@@ -0,0 +1,293 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Creator worlds · OpenVibe.Games</title>
+ <meta
+ name="description"
+ content="Build your own 3D world in the OpenVibe.Games map editor and publish it for anyone to join in the browser. Browse the worlds players made."
+ />
+ <link rel="canonical" href="https://openvibe.games/worlds" />
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+ <style>
+ :root {
+ color-scheme: dark;
+ --accent: #3b82f6;
+ --accent-light: #60a5fa;
+ --secondary: #22d3ee;
+ --bg: #0a0f1c;
+ --bg-raised: #101828;
+ --bg-card: rgba(19, 28, 46, 0.88);
+ --border: #1f2d47;
+ --text: #e6edf7;
+ --text-dim: #96a7c2;
+ --text-faint: #7386a3;
+ --good: #34d399;
+ --warn: #fbbf24;
+ --bad: #f87171;
+ }
+ * {
+ box-sizing: border-box;
+ }
+ body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(900px 420px at 20% -10%, rgba(59, 130, 246, 0.18), transparent 70%),
+ radial-gradient(700px 360px at 90% 0%, rgba(34, 211, 238, 0.1), transparent 70%),
+ var(--bg);
+ font:
+ 16px/1.5 system-ui,
+ -apple-system,
+ sans-serif;
+ }
+ a {
+ color: var(--accent-light);
+ }
+ main {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 20px 16px 64px;
+ }
+ .crumbs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ font-size: 14px;
+ }
+ .eyebrow {
+ margin: 28px 0 4px;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ color: var(--secondary);
+ }
+ h1 {
+ margin: 0 0 6px;
+ font-size: clamp(28px, 5vw, 40px);
+ font-weight: 900;
+ letter-spacing: 0.01em;
+ }
+ h2 {
+ margin: 34px 0 12px;
+ font-size: 20px;
+ font-weight: 800;
+ }
+ .lede {
+ margin: 0 0 6px;
+ max-width: 720px;
+ color: var(--text-dim);
+ }
+ .panel {
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--bg-card);
+ padding: 16px;
+ }
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 14px;
+ }
+ .card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--bg-raised);
+ }
+ .card h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 800;
+ overflow-wrap: anywhere;
+ }
+ .card .by,
+ .card .stats {
+ margin: 0;
+ font-size: 14px;
+ color: var(--text-faint);
+ }
+ .card .summary {
+ margin: 0;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+ }
+ .card .actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: auto;
+ padding-top: 6px;
+ }
+ .badge {
+ align-self: flex-start;
+ padding: 2px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ font-size: 12px;
+ font-weight: 750;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+ .badge.published {
+ color: var(--good);
+ border-color: rgba(52, 211, 153, 0.45);
+ }
+ .badge.draft {
+ color: var(--warn);
+ border-color: rgba(251, 191, 36, 0.45);
+ }
+ .badge.hidden {
+ color: var(--bad);
+ border-color: rgba(248, 113, 113, 0.45);
+ }
+ .button,
+ button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ padding: 8px 16px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-weight: 650;
+ text-decoration: none;
+ cursor: pointer;
+ }
+ .button.primary,
+ button.primary {
+ border: 0;
+ background: linear-gradient(120deg, var(--accent), #2563eb);
+ color: #fff;
+ font-weight: 800;
+ }
+ button.danger {
+ color: var(--bad);
+ }
+ button:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+ .button:focus-visible,
+ button:focus-visible,
+ input:focus-visible,
+ textarea:focus-visible {
+ outline: 2px solid var(--accent-light);
+ outline-offset: 2px;
+ }
+ form {
+ display: grid;
+ gap: 10px;
+ }
+ label {
+ display: grid;
+ gap: 4px;
+ font-size: 14px;
+ font-weight: 650;
+ color: var(--text-dim);
+ }
+ input,
+ textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ }
+ textarea {
+ min-height: 72px;
+ resize: vertical;
+ }
+ .row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ }
+ .status {
+ margin: 0;
+ color: var(--text-dim);
+ }
+ .status:empty {
+ display: none;
+ }
+ .note {
+ margin-top: 18px;
+ font-size: 14px;
+ color: var(--text-faint);
+ }
+ #more-row {
+ margin-top: 14px;
+ }
+ </style>
+ <script type="module" src="/src/pages/worlds.ts"></script>
+ </head>
+ <body>
+ <main>
+ <nav class="crumbs" aria-label="Breadcrumb">
+ <a href="/">OpenVibe.Games</a><a href="/play">Play Scraplandia</a
+ ><a href="/leaderboard.html">Leaderboard</a>
+ </nav>
+ <p class="eyebrow">CREATOR WORLDS</p>
+ <h1>Build a world. Share it.</h1>
+ <p class="lede">
+ Shape terrain, place buildings, lights, zones, resource nodes and props in the map editor,
+ then publish your world for anyone to join from their browser. A world is a sandbox: what
+ players gather or build inside it stays in that world.
+ </p>
+
+ <h2 id="mine-title">Your worlds</h2>
+ <section class="panel" aria-labelledby="mine-title">
+ <div id="signed-out" hidden>
+ <p class="status">Sign in with your OpenVibe account to make a world.</p>
+ <p class="row"><a class="button primary" href="/auth/login?next=%2Fworlds">Sign in</a></p>
+ </div>
+ <div id="signed-in" hidden>
+ <form id="create">
+ <label
+ >Title
+ <input id="create-title" name="title" maxlength="60" required autocomplete="off"
+ /></label>
+ <label
+ >What is it? (optional)
+ <textarea id="create-summary" name="summary" maxlength="280"></textarea>
+ </label>
+ <div class="row">
+ <button class="primary" type="submit" id="create-button">Make a world</button>
+ <span class="status" id="create-status" role="status"></span>
+ </div>
+ </form>
+ <div class="grid" id="mine" style="margin-top: 16px"></div>
+ </div>
+ <p class="status" id="mine-status" role="status"></p>
+ </section>
+
+ <h2 id="published-title">Published worlds</h2>
+ <div class="grid" id="published" aria-labelledby="published-title"></div>
+ <p class="status" id="published-status" role="status">Loading worlds…</p>
+ <div class="row" id="more-row" hidden>
+ <button type="button" id="more">More worlds</button>
+ </div>
+
+ <p class="note">
+ Creator worlds use the editor's built-in materials for now; uploaded textures and models
+ stay with staff maps. Characters are not saved inside someone's world, so nothing earned
+ there reaches Scraplandia, the leaderboards or your inventory.
+ </p>
+ <noscript>
+ <p class="note">This page needs JavaScript to list worlds. Play Scraplandia at /play.</p>
+ </noscript>
+ </main>
+ </body>
+</html>
@@ -66,6 +66,19 @@ export interface ServerConfig {
@@ -66,6 +66,19 @@ export interface ServerConfig {
instancesPerPlace: number
instancesPerPlace: number
/** Internal runtime slot; never read from the public environment. */
/** Internal runtime slot; never read from the public environment. */
instanceSlot?: number
instanceSlot?: number
+ /**
+ * Internal: a creator world's room (never read from the environment). No NPC population and no world events
+ * (both are Scraplandia's), and characters are not saved there (worlds/sandboxStore.ts).
+ */
+ sandbox?: boolean
+ /** GAMES_CREATOR_WORLDS_MAX: creator worlds running at once in this process (0 turns joining them off). */
+ creatorWorldsMax: number
+ /** GAMES_CREATOR_WORLDS_PER_PERSON: worlds one person can keep. */
+ creatorWorldsPerPerson: number
+ /** GAMES_CREATOR_WORLD_IDLE_SECONDS: an empty creator world stops after this long. */
+ creatorWorldIdleSeconds: number
+ /** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */
+ creatorWorldPlayers: number
maxPlayers: number
maxPlayers: number
persistFlushSeconds: number
persistFlushSeconds: number
metricsLogSeconds: number
metricsLogSeconds: number
@@ -205,6 +218,10 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -205,6 +218,10 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,
eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,
scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',
scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',
+ creatorWorldsMax: intEnv(env, 'GAMES_CREATOR_WORLDS_MAX', 2),
+ 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),
}
}
}
}
@@ -164,7 +164,8 @@ export class GameServer {
@@ -164,7 +164,8 @@ export class GameServer {
async load(): Promise<void> {
async load(): Promise<void> {
await this.systems.environment.load()
await this.systems.environment.load()
await this.systems.economy.load()
await this.systems.economy.load()
- await this.systems.npcs.load()
+ // A creator world has no NPC population: the content spawns are Scraplandia's.
+ if (!this.ctx.config.sandbox) await this.systems.npcs.load()
}
}
get currentTick(): number {
get currentTick(): number {
@@ -299,7 +300,7 @@ export class GameServer {
@@ -299,7 +300,7 @@ export class GameServer {
// 5. NPC simulation (LOD-aware; abstract NPCs cost nothing here).
// 5. NPC simulation (LOD-aware; abstract NPCs cost nothing here).
// The sound buffer resets once a second inside the same step.
// The sound buffer resets once a second inside the same step.
- s.npcs.tick(ctx, dt)
+ if (!ctx.config.sandbox) s.npcs.tick(ctx, dt)
// 6. Crafting queues (completions grant crafting/construction XP).
// 6. Crafting queues (completions grant crafting/construction XP).
s.production.tickCraftQueues()
s.production.tickCraftQueues()
@@ -309,7 +310,8 @@ export class GameServer {
@@ -309,7 +310,8 @@ export class GameServer {
s.environment.tick(ctx, dt)
s.environment.tick(ctx, dt)
// 8. Once a second: world events (supply drops, extraction).
// 8. Once a second: world events (supply drops, extraction).
- s.worldEvents.tick(ctx, dt)
+ // Not in a creator world: the events' drop and extraction sites are Scraplandia's.
+ if (!ctx.config.sandbox) s.worldEvents.tick(ctx, dt)
// 9. Once a second: production sweep (generators, machines, tanks, crops).
// 9. Once a second: production sweep (generators, machines, tanks, crops).
s.production.tickProduction()
s.production.tickProduction()
@@ -2,11 +2,25 @@ import type { ClientMessage } from '@openvibe/protocol'
@@ -2,11 +2,25 @@ import type { ClientMessage } from '@openvibe/protocol'
import type { GameConnection } from './systems/context.js'
import type { GameConnection } from './systems/context.js'
import type { InstanceHub, InstanceRoom } from './instanceHub.js'
import type { InstanceHub, InstanceRoom } from './instanceHub.js'
+/**
+ * Places started on demand (creator worlds, worlds/worldHost.ts). The router asks for a hub inside its admission
+ * chain, so starting one is serialized with every other join.
+ */
+export interface DynamicPlaces {
+ /** Whether `place` is one of these (a creator world id), whatever its state. */
+ claims(place: string): boolean
+ /** The running hub for `place`, started if needed; null refuses the join (unknown, not joinable, or full). */
+ open(place: string, conn: GameConnection): Promise<InstanceHub | null>
+ /** Every hub running now. */
+ hubs(): Iterable<InstanceHub>
+ shutdown(): Promise<boolean>
+}
+
/** A single WebSocket endpoint over independent, place-fenced M4 room hubs. */
/** A single WebSocket endpoint over independent, place-fenced M4 room hubs. */
export class PlaceRouter {
export class PlaceRouter {
private readonly connections = new Map<
private readonly connections = new Map<
GameConnection,
GameConnection,
- { hub: InstanceHub; key: string | null; admitted: boolean }
+ { hub: InstanceHub | null; key: string | null; admitted: boolean }
>()
>()
private readonly closed = new WeakSet<GameConnection>()
private readonly closed = new WeakSet<GameConnection>()
private admission: Promise<void> = Promise.resolve()
private admission: Promise<void> = Promise.resolve()
@@ -14,10 +28,26 @@ export class PlaceRouter {
@@ -14,10 +28,26 @@ export class PlaceRouter {
constructor(
constructor(
private readonly hubs: ReadonlyMap<string, InstanceHub>,
private readonly hubs: ReadonlyMap<string, InstanceHub>,
private readonly defaultPlace: string,
private readonly defaultPlace: string,
+ private readonly dynamic: DynamicPlaces | null = null,
) {
) {
if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)
if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)
}
}
+ /** Every hub: the built-in places, then the dynamic ones running now. */
+ private allHubs(): InstanceHub[] {
+ return [...this.hubs.values(), ...(this.dynamic ? this.dynamic.hubs() : [])]
+ }
+
+ /** Runs `fn` in the admission chain (after every join already queued, before any queued later). */
+ serialize<T>(fn: () => Promise<T>): Promise<T> {
+ const run = this.admission.then(fn)
+ this.admission = run.then(
+ () => undefined,
+ () => undefined,
+ )
+ return run
+ }
+
resolve(place: string | undefined): string {
resolve(place: string | undefined): string {
return place && this.hubs.has(place) ? place : this.defaultPlace
return place && this.hubs.has(place) ? place : this.defaultPlace
}
}
@@ -29,8 +59,10 @@ export class PlaceRouter {
@@ -29,8 +59,10 @@ export class PlaceRouter {
conn.close(4001, 'hello_first')
conn.close(4001, 'hello_first')
return
return
}
}
- const hub = this.hubs.get(this.resolve(msg.place))
- if (!hub) throw new Error('default hub missing')
+ const dynamicPlace =
+ msg.place && !this.hubs.has(msg.place) && this.dynamic?.claims(msg.place) ? msg.place : null
+ const hub = dynamicPlace ? null : (this.hubs.get(this.resolve(msg.place)) ?? null)
+ if (!dynamicPlace && !hub) throw new Error('default hub missing')
const identity = conn.identity
const identity = conn.identity
const key = identity
const key = identity
? `${'subject' in identity ? identity.subject : `guest:${identity.guestKeyHash}`}:${msg.slot}`
? `${'subject' in identity ? identity.subject : `guest:${identity.guestKeyHash}`}:${msg.slot}`
@@ -46,47 +78,61 @@ export class PlaceRouter {
@@ -46,47 +78,61 @@ export class PlaceRouter {
if (other === conn || prior.key !== key || !prior.admitted) continue
if (other === conn || prior.key !== key || !prior.admitted) continue
other.close(4004, 'session_superseded')
other.close(4004, 'session_superseded')
this.onDisconnect(other)
this.onDisconnect(other)
- await Promise.all(prior.hub.activeRooms.map((room) => room.game.drain(5000)))
+ await Promise.all((prior.hub?.activeRooms ?? []).map((room) => room.game.drain(5000)))
}
}
}
}
- if (!this.closed.has(conn)) {
- const current = this.connections.get(conn)
- if (current) current.admitted = true
- hub.onMessage(conn, msg)
- await hub.waitForConnection(conn)
+ if (this.closed.has(conn)) return
+ const current = this.connections.get(conn)
+ if (!current) return
+ // A creator world starts here, after the superseded session's save above.
+ const target =
+ current.hub ?? (dynamicPlace ? await this.dynamic?.open(dynamicPlace, conn) : null)
+ if (this.closed.has(conn)) return
+ if (!target) {
+ conn.close(4005, 'world_unavailable')
+ this.onDisconnect(conn)
+ return
}
}
+ current.hub = target
+ current.admitted = true
+ target.onMessage(conn, msg)
+ await target.waitForConnection(conn)
})
})
.catch(() => conn.close(1011, 'instance_unavailable'))
.catch(() => conn.close(1011, 'instance_unavailable'))
return
return
}
}
- active.hub.onMessage(conn, msg)
+ // A creator world's hub is set once it has started; nothing before the welcome needs it.
+ active.hub?.onMessage(conn, msg)
}
}
onDisconnect(conn: GameConnection): void {
onDisconnect(conn: GameConnection): void {
this.closed.add(conn)
this.closed.add(conn)
- this.connections.get(conn)?.hub.onDisconnect(conn)
+ this.connections.get(conn)?.hub?.onDisconnect(conn)
this.connections.delete(conn)
this.connections.delete(conn)
}
}
get activeRooms(): readonly InstanceRoom[] {
get activeRooms(): readonly InstanceRoom[] {
- return [...this.hubs.values()].flatMap((hub) => hub.activeRooms)
+ return this.allHubs().flatMap((hub) => hub.activeRooms)
}
}
get onlineCount(): number {
get onlineCount(): number {
- return [...this.hubs.values()].reduce((total, hub) => total + hub.onlineCount, 0)
+ return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)
}
}
step(): void {
step(): void {
- for (const hub of this.hubs.values()) hub.step()
+ for (const hub of this.allHubs()) hub.step()
}
}
async heartbeat(): Promise<void> {
async heartbeat(): Promise<void> {
- await Promise.all([...this.hubs.values()].map((hub) => hub.heartbeat()))
+ await Promise.all(this.allHubs().map((hub) => hub.heartbeat()))
}
}
async shutdown(): Promise<boolean> {
async shutdown(): Promise<boolean> {
await this.admission
await this.admission
- const drained = await Promise.all([...this.hubs.values()].map((hub) => hub.shutdown()))
+ const drained = await Promise.all([
+ ...[...this.hubs.values()].map((hub) => hub.shutdown()),
+ this.dynamic ? this.dynamic.shutdown() : Promise.resolve(true),
+ ])
this.connections.clear()
this.connections.clear()
return drained.every(Boolean)
return drained.every(Boolean)
}
}
@@ -4,14 +4,22 @@ import { dirname, join } from 'node:path'
@@ -4,14 +4,22 @@ import { dirname, join } from 'node:path'
import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createValkey } from 'openvibe-sdk/valkey'
import { createValkey } from 'openvibe-sdk/valkey'
import {
import {
+ CREATOR_WORLD_ID,
compileMapFileV2,
compileMapFileV2,
createContent,
createContent,
+ creatorWorldPack,
parseMapFile,
parseMapFile,
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
setMapOverride,
setMapOverride,
+ type ContentRegistry,
} from '@openvibe/content'
} from '@openvibe/content'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
-import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
+import {
+ InstanceRegistry,
+ createCreatorWorldStore,
+ openPgStore,
+ type InstanceLease,
+} from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { loadConfig } from './config.js'
@@ -76,6 +84,9 @@ import { createPlayBoards } from './platform/playBoards.js'
@@ -76,6 +84,9 @@ import { createPlayBoards } from './platform/playBoards.js'
import { createCasualRoutes } from './casual/routes.js'
import { createCasualRoutes } from './casual/routes.js'
import { pruneCasualActions } from './casual/store.js'
import { pruneCasualActions } from './casual/store.js'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
+import { CreatorWorldHost } from './worlds/worldHost.js'
+import { createWorldHubStarter } from './worlds/startWorldHub.js'
+import { createWorldRoutes } from './worlds/worldRoutes.js'
/**
/**
* Dedicated authoritative server entry point.
* Dedicated authoritative server entry point.
@@ -573,7 +584,43 @@ async function main(): Promise<void> {
@@ -573,7 +584,43 @@ async function main(): Promise<void> {
[config.placeId, hub],
[config.placeId, hub],
[secondaryPlace, secondaryHub],
[secondaryPlace, secondaryHub],
])
])
- const router = new PlaceRouter(hubs, config.placeId)
+ // ── Creator worlds (worlds/): players' own places, started on the first join, stopped when empty ──
+ // 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 => {
+ let built = creatorContent.get(worldId)
+ if (!built) {
+ if (creatorContent.size >= 256) creatorContent.clear()
+ built = createContent([], creatorWorldPack(worldId))
+ creatorContent.set(worldId, built)
+ }
+ return built
+ }
+ const startWorldHub = createWorldHubStarter({
+ db,
+ rapier,
+ config,
+ metrics,
+ log,
+ contentFor: contentForWorld,
+ })
+ const worldHost = new CreatorWorldHost({
+ store: creatorWorlds,
+ start: startWorldHub,
+ log: log.child({ system: 'creator-worlds' }),
+ maxRunning: config.creatorWorldsMax,
+ idleMs: config.creatorWorldIdleSeconds * 1000,
+ })
+ const router = new PlaceRouter(hubs, config.placeId, worldHost)
+ // The idle sweep runs in the admission chain, so a world never stops under a join that is starting it.
+ const worldSweepTimer = setInterval(() => {
+ void router
+ .serialize(() => worldHost.sweep())
+ .catch((err: unknown) => log.warn('creator world sweep failed', { error: String(err) }))
+ }, 30_000)
+ worldSweepTimer.unref()
const contentByPlace = new Map([
const contentByPlace = new Map([
[config.placeId, content],
[config.placeId, content],
[secondaryPlace, secondaryContent],
[secondaryPlace, secondaryContent],
@@ -711,6 +758,30 @@ async function main(): Promise<void> {
@@ -711,6 +758,30 @@ async function main(): Promise<void> {
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
const clientAddress = (req: IncomingMessage): string =>
const clientAddress = (req: IncomingMessage): string =>
resolveClientAddress(req, config.trustProxy)
resolveClientAddress(req, config.trustProxy)
+ // Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
+ const serveWorlds = createWorldRoutes({
+ store: creatorWorlds,
+ host: {
+ online: () => worldHost.online(),
+ applyMap: (id, map) => {
+ void router.serialize(async () => worldHost.applyMap(id, map))
+ },
+ changed: (world, id) => router.serialize(() => worldHost.changed(world, id)),
+ remove: (id) =>
+ router.serialize(async () => {
+ await worldHost.changed(null, id)
+ await creatorWorlds.remove(id)
+ }),
+ },
+ resolveUser: (token) =>
+ config.networkAuthUrl
+ ? resolveNetworkUser(config.networkAuthUrl, token)
+ : Promise.resolve(null),
+ limits: actorLimits,
+ address: clientAddress,
+ log: log.child({ system: 'worlds' }),
+ perPerson: config.creatorWorldsPerPerson,
+ })
// The casual games' JSON API (plan T22).
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
const serveCasual = createCasualRoutes({
db,
db,
@@ -771,6 +842,30 @@ async function main(): Promise<void> {
@@ -771,6 +842,30 @@ async function main(): Promise<void> {
{
{
handle: (req, res) => {
handle: (req, res) => {
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
+ const requestedWorld = new URL(req.url ?? '/', 'http://games.local').searchParams.get(
+ 'world',
+ )
+ if (requestedWorld !== null) {
+ // A creator world: the plain creator pack and no installed packs (the client builds the same set).
+ if (!CREATOR_WORLD_ID.test(requestedWorld)) {
+ res.writeHead(404, {
+ 'content-type': 'application/json',
+ 'cache-control': 'no-store',
+ })
+ 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: [],
+ }),
+ )
+ return true
+ }
const requested = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
const requested = new URL(req.url ?? '/', 'http://games.local').searchParams.get('game')
const placeId = router.resolve(requested ?? undefined)
const placeId = router.resolve(requested ?? undefined)
const selectedContent = contentByPlace.get(placeId)
const selectedContent = contentByPlace.get(placeId)
@@ -792,6 +887,7 @@ async function main(): Promise<void> {
@@ -792,6 +887,7 @@ async function main(): Promise<void> {
return true
return true
}
}
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
+ if (serveWorlds(req, res)) return true
if (
if (
handleGameRead(req, res, {
handleGameRead(req, res, {
db,
db,
@@ -1020,6 +1116,7 @@ async function main(): Promise<void> {
@@ -1020,6 +1116,7 @@ async function main(): Promise<void> {
running = false
running = false
clearInterval(metricsTimer)
clearInterval(metricsTimer)
clearInterval(heartbeatTimer)
clearInterval(heartbeatTimer)
+ clearInterval(worldSweepTimer)
clearInterval(pruneTimer)
clearInterval(pruneTimer)
editors.stop()
editors.stop()
const mirrorDone = mirror?.stop()
const mirrorDone = mirror?.stop()
@@ -49,6 +49,8 @@ export function createGamesActorLimits(
@@ -49,6 +49,8 @@ export function createGamesActorLimits(
// flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).
// flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).
const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })
const publicRead = limiter('games.public.read', { minute: 60, hour: 1200 })
+ // Creator worlds (worlds/worldRoutes.ts): making, renaming, publishing and deleting a world, per person.
+ const worldWrite = limiter('games.world.write', { minute: 20, hour: 200 })
// Casual games (plan T22): a session open per address, and each player's actions (a tap is one request).
// Casual games (plan T22): a session open per address, and each player's actions (a tap is one request).
const casualSession = limiter('games.casual.session', { minute: 30, hour: 600 })
const casualSession = limiter('games.casual.session', { minute: 30, hour: 600 })
const casualAction = limiter('games.casual.action', { minute: 120, hour: 3000 })
const casualAction = limiter('games.casual.action', { minute: 120, hour: 3000 })
@@ -71,6 +73,7 @@ export function createGamesActorLimits(
@@ -71,6 +73,7 @@ export function createGamesActorLimits(
ticket: run(ticket),
ticket: run(ticket),
upgrade: run(upgrade),
upgrade: run(upgrade),
publicRead: run(publicRead),
publicRead: run(publicRead),
+ worldWrite: run(worldWrite),
casualSession: run(casualSession),
casualSession: run(casualSession),
casualAction: run(casualAction),
casualAction: run(casualAction),
}
}
@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {
@@ -174,6 +174,13 @@ function publicPageDocs(): PublicPageDoc[] {
text: pack.tagline,
text: pack.tagline,
priority: 0.8,
priority: 0.8,
})),
})),
+ {
+ title: 'Creator worlds',
+ url: `${SITE}/worlds`,
+ note: 'worlds players built in the map editor and published, each playable in the browser',
+ text: 'Creator worlds: players shape terrain and place buildings, lights, zones, resource nodes and props in the OpenVibe.Games map editor, then publish the world for anyone to join from the browser. A world is a sandbox: what players gather or build inside it stays in that world.',
+ priority: 0.7,
+ },
{
{
title: 'What shipped',
title: 'What shipped',
url: `${SITE}/updates`,
url: `${SITE}/updates`,
@@ -192,6 +192,7 @@ export function createHttpServer(
@@ -192,6 +192,7 @@ export function createHttpServer(
{ href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },
{ href: '/play?game=salvage-rush', label: 'Play Salvage Rush' },
{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
{ href: '/play/vibe-heroes', label: 'Play Vibe Heroes' },
{ href: '/leaderboard.html', label: 'Leaderboard' },
{ href: '/leaderboard.html', label: 'Leaderboard' },
+ { href: '/worlds', label: 'Creator worlds' },
{ href: '/editor', label: 'Map editor' },
{ href: '/editor', label: 'Map editor' },
]
]
const playLinks: NotFoundLink[] = [
const playLinks: NotFoundLink[] = [
@@ -791,7 +792,9 @@ export function createHttpServer(
@@ -791,7 +792,9 @@ export function createHttpServer(
? 'play.html'
? 'play.html'
: url === '/editor'
: url === '/editor'
? 'editor.html'
? 'editor.html'
- : null
+ : url === '/worlds'
+ ? 'worlds.html'
+ : null
// Static files with path traversal guard.
// Static files with path traversal guard.
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
@@ -65,6 +65,7 @@ function openLimits() {
@@ -65,6 +65,7 @@ function openLimits() {
modWrite: () => Promise.resolve(true),
modWrite: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
ticket: () => Promise.resolve(true),
publicRead: () => Promise.resolve(true),
publicRead: () => Promise.resolve(true),
+ worldWrite: () => Promise.resolve(true),
casualSession: () => Promise.resolve(true),
casualSession: () => Promise.resolve(true),
casualAction: () => Promise.resolve(true),
casualAction: () => Promise.resolve(true),
upgrade: (req: unknown, _res: unknown, actor: string) =>
upgrade: (req: unknown, _res: unknown, actor: string) =>
@@ -114,7 +114,12 @@ describe('account export and deletion (ADR-033)', () => {
@@ -114,7 +114,12 @@ describe('account export and deletion (ADR-033)', () => {
'/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8/confirmations',
'/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8/confirmations',
)
)
expect(validate('network.account-deletion-confirmation@1', conf.body).valid).toBe(true)
expect(validate('network.account-deletion-confirmation@1', conf.body).valid).toBe(true)
- expect(conf.body.erased).toEqual({ characters: 2, identity_rows: 0, casual_saves: 0 })
+ expect(conf.body.erased).toEqual({
+ characters: 2,
+ identity_rows: 0,
+ casual_saves: 0,
+ creator_worlds: 0,
+ })
expect(conf.body.retained).toEqual({ structures_unowned: 0 })
expect(conf.body.retained).toEqual({ structures_unowned: 0 })
expect(await data.apply(del)).toBe('unchanged')
expect(await data.apply(del)).toBe('unchanged')
expect(sent.length).toBe(2)
expect(sent.length).toBe(2)
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+import {
+ createContent,
+ creatorWorldPack,
+ emptyMapV2,
+ SCRAPLANDIA_PACK,
+ type ContentRegistry,
+} from '@openvibe/content'
+import {
+ InstanceRegistry,
+ createCreatorWorldStore,
+ openPgStore,
+ type CreatorWorld,
+ type InstanceLease,
+} from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
+import { PROTOCOL_VERSION, defaultAppearance, type ServerMessage } from '@openvibe/protocol'
+import { createConsoleLogger } from '@openvibe/shared'
+import { sql } from 'openvibe-sdk/db'
+import { expect, it } from 'vitest'
+import { loadConfig } from '../config.js'
+import { GameServer, type GameConnection } from '../game/gameServer.js'
+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 { CreatorWorldHost, mayJoin } from './worldHost.js'
+
+const log = createConsoleLogger({ app: 'creator-worlds-test' }, 'error')
+const KIM = 'usr_01J0000000000000000000000K'
+const LEE = 'usr_01J0000000000000000000000L'
+
+async function until(check: () => boolean): Promise<void> {
+ for (let i = 0; i < 300; i++) {
+ if (check()) return
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+ throw new Error('timed out')
+}
+
+it('decides who joins a world that is not published', () => {
+ const world = { ownerSubject: KIM, status: 'draft' } as CreatorWorld
+ const as = (identity: NonNullable<GameConnection['identity']>): GameConnection => ({
+ identity,
+ send: () => {},
+ close: () => {},
+ })
+ const kim = as({ subject: KIM, rank: null, authIat: null })
+ const lee = as({ subject: LEE, rank: null, authIat: null })
+ const admin = as({ subject: LEE, rank: 'admin', authIat: null })
+ const guest = as({ guestKeyHash: 'a'.repeat(64) })
+ expect([kim, lee, admin, guest].map((c) => mayJoin(world, c))).toEqual([true, false, true, false])
+ const hidden = { ...world, status: 'hidden' } as CreatorWorld
+ expect([kim, admin].map((c) => mayJoin(hidden, c))).toEqual([false, true])
+ const published = { ...world, status: 'published' } as CreatorWorld
+ expect(mayJoin(published, guest)).toBe(true)
+})
+
+it('starts a published creator world on the first join, sandboxes characters, refuses beyond the cap, stops when idle', async () => {
+ const db = await openTestDb()
+ const rapier = await loadRapier()
+ const config = loadConfig({ GAMES_PLACE_ID: 'scraplandia' })
+ // The built-in default place the router always hosts.
+ const registry = new InstanceRegistry(db, 'scraplandia')
+ await openPgStore(db, { placeId: 'scraplandia' }).ensurePlace()
+ const claimed = await registry.claim(0, 4, 'holder', Date.now(), 30_000)
+ if (!claimed) throw new Error('claim failed')
+ let lease: InstanceLease = claimed
+ const store = openPgStore(db, {
+ placeId: 'scraplandia',
+ instanceSlot: 0,
+ leaseGuard: () => registry.assertHeld(lease, Date.now()),
+ })
+ const physics = createRapierWorld(rapier)
+ const world = new GameWorld(
+ createContent([], SCRAPLANDIA_PACK),
+ physics,
+ log,
+ config.interestCell,
+ )
+ await world.seedOrRestore(store)
+ const game = new GameServer(
+ { ...config, placeId: 'scraplandia' },
+ world,
+ store,
+ new ServerMetrics(),
+ log,
+ )
+ await game.load()
+ lease = (await registry.renew(lease, Date.now(), 30_000, 'running')) ?? lease
+ const hub = new InstanceHub(
+ registry,
+ { lease, game, world, dispose: () => physics.dispose() },
+ 1,
+ 4,
+ async () => {
+ throw new Error('unexpected room creation')
+ },
+ log,
+ )
+
+ const worlds = createCreatorWorldStore(db)
+ const contents = new Map<string, ContentRegistry>()
+ const contentFor = (id: string): ContentRegistry => {
+ const built = contents.get(id) ?? createContent([], creatorWorldPack(id))
+ contents.set(id, built)
+ return built
+ }
+ const map = { ...emptyMapV2(), spawn: [3, 1, 3] as [number, number, number] }
+ const open = await worlds.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Open',
+ summary: '',
+ map,
+ now: 1,
+ })
+ await worlds.update(open.id, { status: 'published' }, 2)
+ const draft = await worlds.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Draft',
+ summary: '',
+ map,
+ now: 3,
+ })
+ const second = await worlds.create({
+ ownerSubject: LEE,
+ ownerName: 'lee',
+ title: 'Second',
+ summary: '',
+ map,
+ now: 4,
+ })
+ await worlds.update(second.id, { status: 'published' }, 5)
+
+ let now = 1_000_000
+ const host = new CreatorWorldHost({
+ store: worlds,
+ start: createWorldHubStarter({
+ db,
+ rapier,
+ config,
+ metrics: new ServerMetrics(),
+ log,
+ contentFor,
+ }),
+ log,
+ maxRunning: 1,
+ idleMs: 60_000,
+ now: () => now,
+ })
+ const router = new PlaceRouter(new Map([['scraplandia', hub]]), 'scraplandia', host)
+ const join = (place: string, key: string, digest: string) => {
+ const inbox: ServerMessage[] = []
+ const closes: string[] = []
+ const conn: GameConnection = {
+ identity: { guestKeyHash: key.repeat(64) },
+ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),
+ close: (_code, reason) => {
+ closes.push(reason)
+ router.onDisconnect(conn)
+ },
+ }
+ router.onMessage(conn, {
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ place,
+ contentDigest: digest,
+ slot: 0,
+ name: key,
+ appearance: defaultAppearance(),
+ })
+ return { conn, inbox, closes }
+ }
+ try {
+ const guest = join(`world:${open.id}`, 'a', contentFor(open.id).digest)
+ await until(() => guest.inbox.some((m) => m.t === 'welcome') || guest.closes.length > 0)
+ expect(guest.closes).toEqual([])
+ // A one-room place sends no room fields; the welcome carries the world's own def set.
+ expect(guest.inbox.find((m) => m.t === 'welcome')).toMatchObject({
+ contentDigest: contentFor(open.id).digest,
+ })
+ expect([...host.online()]).toEqual([[open.id, 1]])
+ expect(router.activeRooms.map((r) => r.world.content.world.id)).toContain(`creator_${open.id}`)
+ expect((await worlds.get(open.id))?.plays).toBe(1)
+
+ // A draft is its owner's: a guest is refused. The cap of one running world refuses the second world.
+ const refused = join(`world:${draft.id}`, 'b', contentFor(draft.id).digest)
+ const busy = join(`world:${second.id}`, 'c', contentFor(second.id).digest)
+ await until(() => refused.closes.length > 0 && busy.closes.length > 0)
+ expect(refused.closes).toEqual(['world_unavailable'])
+ expect(busy.closes).toEqual(['world_unavailable'])
+
+ // Leaving saves nothing about the character; the world stops once it has been empty long enough.
+ guest.conn.close(1000, 'bye')
+ await Promise.all(router.activeRooms.map((room) => room.game.drain(5000)))
+ expect(await db.one<{ n: number }>(sql`SELECT count(*)::int AS n FROM characters`)).toEqual({
+ n: 0,
+ })
+ await router.serialize(() => host.sweep())
+ expect(host.online().size).toBe(1)
+ now += 61_000
+ await router.serialize(() => host.sweep())
+ expect(host.online().size).toBe(0)
+ const instance = await db.one<{ state: string }>(
+ sql`SELECT state FROM game_instances WHERE place_id = ${`world:${open.id}`}`,
+ )
+ expect(instance.state).toBe('stopped')
+
+ // With the slot free the second world starts; unpublishing it closes it.
+ const lee = join(`world:${second.id}`, 'd', contentFor(second.id).digest)
+ await until(() => lee.inbox.some((m) => m.t === 'welcome'))
+ const unpublished = await worlds.update(second.id, { status: 'draft' }, 6)
+ await router.serialize(() => host.changed(unpublished, second.id))
+ expect(host.online().size).toBe(0)
+ } finally {
+ await router.shutdown()
+ }
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+import type { PersistenceStore, PlayerRepository } from '@openvibe/persistence'
+
+/**
+ * A creator world's store: the world's own rows (its props, constraints, clock) are written as in any place, but a
+ * character is never saved there. A player brings their character in and leaves with it exactly as it was: nothing
+ * gathered, crafted, traded or dropped in somebody's world reaches Scraplandia, the boards or the Inventory.
+ * Characters are still read (appearance, name and the items a player carries in), and a new character made in a
+ * creator world lives only for that session.
+ */
+export function sandboxStore(store: PersistenceStore): PersistenceStore {
+ const players: PlayerRepository = {
+ findByToken: (token) => store.players.findByToken(token),
+ listByToken: (token) => store.players.listByToken(token),
+ findByTokenSlot: (token, slot) => store.players.findByTokenSlot(token, slot),
+ findById: (id) => store.players.findById(id),
+ upsert: async () => {},
+ upsertMany: async () => {},
+ resetAllPositions: async () => {},
+ }
+ const wrapped = Object.create(store) as PersistenceStore
+ Object.defineProperty(wrapped, 'players', { value: players, enumerable: true })
+ return wrapped
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+import type { ContentRegistry } from '@openvibe/content'
+import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
+import { createRapierWorld, type RapierModule } from '@openvibe/physics/rapier'
+import type { Logger } from '@openvibe/shared'
+import type { Db } from 'openvibe-sdk/db'
+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 type { ServerMetrics } from '../observability/metrics.js'
+import { sandboxStore } from './sandboxStore.js'
+import type { StartWorldHub } from './worldHost.js'
+
+export interface WorldHubDeps {
+ db: Db
+ rapier: RapierModule
+ 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
+}
+
+/**
+ * Starts one creator world as a single-room hub: its place row, a lease on slot 0, then a sandboxed room (the
+ * world's rows are kept, characters are not; no NPCs, world events, rewards or events). The caller has already set
+ * the world's map override. Null when the slot is held elsewhere or the room fails to start.
+ */
+export function createWorldHubStarter(deps: WorldHubDeps): StartWorldHub {
+ const { db, rapier, config, metrics, log } = deps
+ return async (world, placeId) => {
+ const worldContent = 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) => {
+ const roomStore = sandboxStore(
+ openPgStore(db, {
+ placeId,
+ instanceSlot: slot,
+ leaseGuard: () => worldRegistry.assertHeld(lease, Date.now()),
+ }),
+ )
+ await roomStore.ensurePlace(world.title)
+ const roomPhysics = createRapierWorld(rapier)
+ const roomWorld = new GameWorld(worldContent, roomPhysics, worldLog, config.interestCell)
+ await roomWorld.seedOrRestore(roomStore)
+ roomWorld.reconcileMapNodes()
+ roomWorld.reconcileMapProps()
+ const roomGame = new GameServer(
+ {
+ ...config,
+ placeId,
+ instanceSlot: slot,
+ maxPlayers: config.creatorWorldPlayers,
+ sandbox: true,
+ },
+ roomWorld,
+ roomStore,
+ metrics,
+ log.child({ system: 'game', place: placeId }),
+ {},
+ )
+ await roomGame.load()
+ return {
+ lease,
+ game: roomGame,
+ world: roomWorld,
+ dispose: async () => {
+ roomPhysics.dispose()
+ },
+ }
+ }
+ // The places row first: game_instances references it.
+ await openPgStore(db, { placeId }).ensurePlace(world.title)
+ const claim = await worldRegistry.claim(
+ 0,
+ config.creatorWorldPlayers,
+ crypto.randomUUID(),
+ Date.now(),
+ 60_000,
+ )
+ if (!claim) return null
+ let room: Awaited<ReturnType<typeof createWorldRoom>>
+ try {
+ room = await createWorldRoom(0, claim)
+ } catch (err) {
+ worldLog.warn('creator world failed to start', { error: String(err) })
+ await worldRegistry.stop(claim).catch(() => undefined)
+ return null
+ }
+ const running = await worldRegistry.renew(claim, Date.now(), 15_000, 'running')
+ if (!running) {
+ await room.dispose()
+ return null
+ }
+ room.lease = running
+ return new InstanceHub(
+ worldRegistry,
+ room,
+ 1,
+ config.creatorWorldPlayers,
+ createWorldRoom,
+ log.child({ system: 'instances', place: placeId }),
+ )
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+import {
+ compileMapFileV2,
+ creatorPlaceId,
+ creatorWorldOf,
+ parseMapFile,
+ setMapOverride,
+ type MapFileV2,
+} from '@openvibe/content'
+import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { InstanceHub } from '../game/instanceHub.js'
+import type { DynamicPlaces } from '../game/placeRouter.js'
+import type { GameConnection } from '../game/systems/context.js'
+
+/** Starts a creator world's hub (main.ts builds the room: physics, world, a sandboxed GameServer). */
+export type StartWorldHub = (world: CreatorWorld, placeId: string) => Promise<InstanceHub | null>
+
+export interface WorldHostOptions {
+ store: CreatorWorldStore
+ start: StartWorldHub
+ log: Logger
+ /** Creator worlds running at once in this process (GAMES_CREATOR_WORLDS_MAX). */
+ maxRunning: number
+ /** A world with nobody in it stops after this long. */
+ idleMs: number
+ now?: () => number
+}
+
+interface Running {
+ hub: InstanceHub
+ world: CreatorWorld
+ /** The last join or the last time someone was seen inside. */
+ activeAt: number
+}
+
+/** Who may join a world that is not published: its owner, and staff. */
+export function mayJoin(world: CreatorWorld, conn: GameConnection): boolean {
+ if (world.status === 'published') return true
+ const identity = conn.identity
+ if (!identity || !('subject' in identity)) return false
+ if (identity.subject === world.ownerSubject) return world.status === 'draft'
+ return identity.rank === 'owner' || identity.rank === 'admin'
+}
+
+/**
+ * The creator worlds running in this process. A world starts when someone joins it (inside the router's admission
+ * chain), runs as the place `world:<id>` with its own map override, and stops once it has been empty for `idleMs`.
+ * At most `maxRunning` run at once: a join beyond that is refused (the client says the worlds are busy) rather than
+ * slowing Scraplandia's tick.
+ */
+export class CreatorWorldHost implements DynamicPlaces {
+ private readonly running = new Map<string, Running>()
+ /** Worlds being stopped; a join waits for the stop, then starts the world again. */
+ private readonly stopping = new Map<string, Promise<void>>()
+ private readonly now: () => number
+
+ constructor(private readonly opts: WorldHostOptions) {
+ this.now = opts.now ?? Date.now
+ }
+
+ claims(place: string): boolean {
+ return creatorWorldOf(place) !== null
+ }
+
+ hubs(): Iterable<InstanceHub> {
+ return [...this.running.values()].map((r) => r.hub)
+ }
+
+ /** Worlds running now, for /api/worlds (players inside each). */
+ online(): Map<string, number> {
+ return new Map([...this.running].map(([id, r]) => [id, r.hub.onlineCount]))
+ }
+
+ async open(place: string, conn: GameConnection): Promise<InstanceHub | null> {
+ const id = creatorWorldOf(place)
+ if (!id) return null
+ await this.stopping.get(id)
+ const world = await this.opts.store.get(id)
+ if (!world || !mayJoin(world, conn)) return null
+ const current = this.running.get(id)
+ if (current) {
+ current.activeAt = this.now()
+ current.world = world
+ void this.opts.store.countPlay(id).catch(() => undefined)
+ return current.hub
+ }
+ if (this.running.size >= this.opts.maxRunning) {
+ this.opts.log.info('creator world refused: all slots busy', { world: id })
+ return null
+ }
+ const stored = await this.opts.store.map(id)
+ const parsed = parseMapFile(stored?.map ?? null)
+ if (!parsed.ok) {
+ this.opts.log.warn('creator world map unreadable', { world: id })
+ return null
+ }
+ this.applyOverride(id, parsed.map)
+ const hub = await this.opts.start(world, creatorPlaceId(id))
+ if (!hub) {
+ setMapOverride(null, contentWorldId(id))
+ return null
+ }
+ this.running.set(id, { hub, world, activeAt: this.now() })
+ void this.opts.store.countPlay(id).catch(() => undefined)
+ this.opts.log.info('creator world started', { world: id, running: this.running.size })
+ return hub
+ }
+
+ /** A saved map reaches a running world at once (the editor's "save applies live"). */
+ applyMap(id: string, map: MapFileV2): void {
+ const current = this.running.get(id)
+ if (!current) return
+ this.applyOverride(id, map)
+ for (const room of current.hub.activeRooms) {
+ room.world.reconcileMapTerrain()
+ room.world.reconcileMapStatics()
+ room.world.reconcileMapZones()
+ room.world.reconcileMapNodes()
+ room.world.reconcileMapProps()
+ room.game.broadcastMapReload()
+ }
+ }
+
+ /** The world changed state (unpublished, hidden, deleted): a world nobody may join any more stops now. */
+ async changed(world: CreatorWorld | null, id: string): Promise<void> {
+ const current = this.running.get(id)
+ if (!current) return
+ if (world?.status === 'published') {
+ current.world = world
+ return
+ }
+ for (const room of current.hub.activeRooms)
+ room.game.announce('This world was closed by its owner or by staff.')
+ 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()
+ for (const [id, current] of [...this.running]) {
+ if (current.hub.onlineCount > 0) {
+ current.activeAt = now
+ continue
+ }
+ if (now - current.activeAt >= this.opts.idleMs) await this.stop(id)
+ }
+ }
+
+ async stop(id: string): Promise<void> {
+ const current = this.running.get(id)
+ if (!current) return
+ this.running.delete(id)
+ const done = (async () => {
+ try {
+ await current.hub.shutdown()
+ } catch (err) {
+ this.opts.log.warn('creator world stop failed', { world: id, error: String(err) })
+ } finally {
+ setMapOverride(null, contentWorldId(id))
+ this.stopping.delete(id)
+ this.opts.log.info('creator world stopped', { world: id, running: this.running.size })
+ }
+ })()
+ this.stopping.set(id, done)
+ await done
+ }
+
+ async shutdown(): Promise<boolean> {
+ const results = await Promise.all(
+ [...this.running.values()].map((r) => r.hub.shutdown().catch(() => false)),
+ )
+ this.running.clear()
+ return results.every(Boolean)
+ }
+
+ private applyOverride(id: string, map: MapFileV2): void {
+ setMapOverride(compileMapFileV2(map), contentWorldId(id))
+ }
+}
+
+/** The content world id of a creator world (creatorWorldPack's map id). */
+export function contentWorldId(worldId: string): string {
+ return `creator_${worldId}`
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { emptyMapV2, type MapFileV2 } from '@openvibe/content'
+import { createCreatorWorldStore, type CreatorWorld } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { createConsoleLogger } from '@openvibe/shared'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createGamesActorLimits } from '../net/actorLimits.js'
+import type { NetworkUser } from '../net/networkAuth.js'
+import { cleanText, createWorldRoutes } from './worldRoutes.js'
+
+const KIM = 'usr_01J0000000000000000000000K'
+const LEE = 'usr_01J0000000000000000000000L'
+const STAFF = 'usr_01J0000000000000000000000S'
+const USERS: Record<string, NetworkUser> = {
+ kim: { id: '1', name: 'kim', rank: null, subjectId: KIM },
+ lee: { id: '2', name: 'lee', rank: null, subjectId: LEE },
+ staff: { id: '3', name: 'mod', rank: 'admin', subjectId: STAFF },
+ guest: { id: '4', name: 'guest', rank: null, subjectId: null },
+}
+
+let server: Server | null = null
+afterEach(async () => {
+ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()))
+ server = null
+})
+
+async function setup(perPerson = 2) {
+ const db = await openTestDb()
+ const store = createCreatorWorldStore(db)
+ const calls = {
+ applied: [] as string[],
+ changed: [] as (string | null)[],
+ removed: [] as string[],
+ }
+ const handler = createWorldRoutes({
+ store,
+ host: {
+ online: () => new Map(),
+ applyMap: (id) => calls.applied.push(id),
+ changed: async (world: CreatorWorld | null, id: string) => {
+ calls.changed.push(world ? `${id}:${world.status}` : null)
+ },
+ remove: async (id) => {
+ calls.removed.push(id)
+ await store.remove(id)
+ },
+ },
+ resolveUser: async (token) => USERS[token] ?? null,
+ limits: createGamesActorLimits({ env: {} }),
+ address: () => '127.0.0.1',
+ log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
+ perPerson,
+ })
+ server = createServer((req, res) => {
+ if (!handler(req, res)) {
+ res.writeHead(404)
+ res.end()
+ }
+ })
+ await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
+ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+ const call = async (
+ method: string,
+ path: string,
+ as?: string,
+ body?: unknown,
+ headers: Record<string, string> = {},
+ ) => {
+ const resp = await fetch(`${base}${path}`, {
+ method,
+ headers: {
+ ...(as ? { authorization: `Bearer ${as}` } : {}),
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
+ ...headers,
+ },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ })
+ const text = await resp.text()
+ return {
+ status: resp.status,
+ etag: resp.headers.get('etag'),
+ body: (text ? JSON.parse(text) : null) as Record<string, unknown> & {
+ worlds?: Record<string, unknown>[]
+ issues?: string[]
+ },
+ }
+ }
+ return { call, calls, store }
+}
+
+describe('/api/worlds', () => {
+ it('cleans titles: trimmed, control characters out, length capped', () => {
+ expect(cleanText(' Moon\u0007 base ', 60)).toBe('Moon base')
+ expect(cleanText('x'.repeat(61), 60)).toBeNull()
+ expect(cleanText(42, 60)).toBeNull()
+ })
+
+ it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {
+ const { call, calls } = await setup()
+ expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
+ expect((await call('POST', '/api/worlds', 'guest', { title: 'x' })).status).toBe(401)
+ expect((await call('POST', '/api/worlds', 'kim', { title: ' ' })).status).toBe(422)
+
+ const made = await call('POST', '/api/worlds', 'kim', {
+ title: 'Moon base',
+ summary: 'Low gravity vibes',
+ })
+ expect(made.status).toBe(201)
+ const id = String(made.body.id)
+ expect(made.body).toMatchObject({
+ status: 'draft',
+ edit_url: `/editor?world=${id}`,
+ play_url: `/play?world=${id}`,
+ })
+
+ // A draft is the owner's (and staff's) only.
+ expect((await call('GET', '/api/worlds')).body.worlds).toEqual([])
+ expect((await call('GET', `/api/worlds/${id}`)).status).toBe(404)
+ expect((await call('GET', `/api/worlds/${id}`, 'lee')).status).toBe(404)
+ expect((await call('GET', `/api/worlds/${id}`, 'staff')).status).toBe(200)
+ const map = await call('GET', `/api/worlds/${id}/map`, 'kim')
+ expect(map.status).toBe(200)
+ expect(map.etag).toMatch(/^"[0-9a-f]{24}"$/)
+
+ // Saves: creator limits, If-Match, owner only.
+ const uploads: MapFileV2 = {
+ ...emptyMapV2(),
+ textures: [{ name: 'photo', url: '/map-assets/a.png' }],
+ }
+ const refused = await call('PUT', `/api/worlds/${id}/map`, 'kim', uploads)
+ expect(refused.status).toBe(422)
+ expect(refused.body.issues?.some((i) => i.startsWith('textures:'))).toBe(true)
+ const next: MapFileV2 = { ...emptyMapV2(), spawn: [2, 1, 2] }
+ const saved = await call('PUT', `/api/worlds/${id}/map`, 'kim', next, {
+ 'if-match': map.etag ?? '',
+ })
+ expect(saved.status).toBe(200)
+ expect(calls.applied).toEqual([id])
+ expect(
+ (await call('PUT', `/api/worlds/${id}/map`, 'kim', next, { 'if-match': map.etag ?? '' }))
+ .status,
+ ).toBe(409)
+ expect((await call('PUT', `/api/worlds/${id}/map`, 'lee', next)).status).toBe(404)
+ expect((await call('PUT', `/api/worlds/${id}/map`, 'staff', next)).status).toBe(404)
+
+ // Publishing lists it for everyone, without the owner's fields.
+ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'hidden' })).status).toBe(403)
+ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
+ 200,
+ )
+ expect(calls.changed).toEqual([`${id}:published`])
+ const listed = (await call('GET', '/api/worlds')).body.worlds ?? []
+ expect(listed.map((w) => w.id)).toEqual([id])
+ expect(listed[0]).toMatchObject({
+ title: 'Moon base',
+ owner: 'kim',
+ summary: 'Low gravity vibes',
+ })
+ expect(listed[0]).not.toHaveProperty('status')
+ expect((await call('GET', `/api/worlds/${id}/map`)).status).toBe(200)
+
+ // Staff take it down; the owner cannot put it back, nor can staff edit its title.
+ expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { title: 'Mine now' })).status).toBe(
+ 403,
+ )
+ expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { status: 'hidden' })).status).toBe(
+ 200,
+ )
+ expect((await call('GET', '/api/worlds')).body.worlds).toEqual([])
+ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
+ 403,
+ )
+ expect((await call('GET', '/api/worlds/mine', 'staff')).body).toMatchObject({ staff: true })
+
+ // Limits: two per person here.
+ expect((await call('POST', '/api/worlds', 'kim', { title: 'Two' })).status).toBe(201)
+ expect((await call('POST', '/api/worlds', 'kim', { title: 'Three' })).status).toBe(409)
+ expect(((await call('GET', '/api/worlds/mine', 'kim')).body.worlds ?? []).length).toBe(2)
+
+ expect((await call('DELETE', `/api/worlds/${id}`, 'lee')).status).toBe(404)
+ expect((await call('DELETE', `/api/worlds/${id}`, 'kim')).status).toBe(204)
+ expect(calls.removed).toEqual([id])
+ expect((await call('GET', `/api/worlds/${id}`, 'kim')).status).toBe(404)
+ expect((await call('GET', '/api/worlds/not-an-id')).status).toBe(404)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,420 @@
@@ -0,0 +1,420 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import {
+ CREATOR_MAP_LIMITS,
+ CREATOR_WORLD_ID,
+ creatorMapIssues,
+ emptyMapV2,
+ parseMapFile,
+ type MapFileV2,
+} from '@openvibe/content'
+import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { GamesActorLimits } from '../net/actorLimits.js'
+import type { NetworkUser } from '../net/networkAuth.js'
+
+/**
+ * /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
+ * signed-in person (a Network account, not a guest) makes up to `perPerson` worlds, edits their maps in /editor and
+ * publishes them; staff hide a world. Every write is counted per person (games.world.write, games.map.save).
+ *
+ * GET /api/worlds[?page=] published worlds, newest first
+ * GET /api/worlds/mine the caller's worlds
+ * POST /api/worlds { title, summary? } → a new draft with an empty map
+ * GET /api/worlds/:id one world (published, or the caller's, or staff)
+ * GET /api/worlds/:id/map its map document, ETag = revision
+ * PUT /api/worlds/:id/map the owner saves (If-Match: revision); a running world reloads at once
+ * PATCH /api/worlds/:id { title?, summary?, status? } owner: draft|published; staff: hidden|published
+ * DELETE /api/worlds/:id the owner or games staff
+ */
+
+/** The running worlds, as the routes see them (main.ts runs the changes in the router's admission chain). */
+export interface WorldHostView {
+ online(): Map<string, number>
+ applyMap(id: string, map: MapFileV2): void
+ changed(world: CreatorWorld | null, id: string): Promise<void>
+ /** Stops the world (its last save lands) and removes it with its place's rows. */
+ remove(id: string): Promise<void>
+}
+
+export interface WorldRouteOptions {
+ store: CreatorWorldStore
+ host: WorldHostView
+ /** The Network account behind a bearer token (networkAuth.resolveNetworkUser), or null. */
+ resolveUser: (token: string) => Promise<NetworkUser | null>
+ limits: GamesActorLimits
+ address: (req: IncomingMessage) => string
+ log: Logger
+ perPerson: number
+ now?: () => number
+}
+
+const PAGE_SIZE = 24
+const META_BYTES = 16 * 1024
+const TITLE_MAX = 60
+const SUMMARY_MAX = 280
+
+type Problem = {
+ status: number
+ error: string
+ detail?: string
+ issues?: string[]
+ revision?: string
+}
+
+class RouteError extends Error {
+ constructor(readonly problem: Problem) {
+ super(problem.error)
+ }
+}
+
+const fail = (
+ status: number,
+ error: string,
+ extra: Omit<Problem, 'status' | 'error'> = {},
+): never => {
+ throw new RouteError({ status, error, ...extra })
+}
+
+/** Plain text of at most `max` characters: trimmed, control characters out, inner runs of space kept to one. */
+export function cleanText(value: unknown, max: number): string | null {
+ if (typeof value !== 'string') return null
+ const visible = Array.from(value, (ch) => {
+ const code = ch.charCodeAt(0)
+ return code < 32 || code === 127 ? ' ' : ch
+ }).join('')
+ const text = visible.replace(/\s+/g, ' ').trim()
+ return text.length > max ? null : text
+}
+
+function isStaff(user: NetworkUser | null): boolean {
+ return user?.rank === 'owner' || user?.rank === 'admin' || user?.rank === 'moderator'
+}
+
+function isGamesStaff(user: NetworkUser | null): boolean {
+ return user?.rank === 'owner' || user?.rank === 'admin'
+}
+
+function publicView(world: CreatorWorld, online: number) {
+ return {
+ id: world.id,
+ title: world.title,
+ summary: world.summary,
+ owner: world.ownerName,
+ plays: world.plays,
+ online,
+ published_at: world.publishedAt === null ? null : new Date(world.publishedAt).toISOString(),
+ updated_at: new Date(world.updatedAt).toISOString(),
+ play_url: `/play?world=${world.id}`,
+ }
+}
+
+function ownerView(world: CreatorWorld, online: number) {
+ return {
+ ...publicView(world, online),
+ status: world.status,
+ revision: world.revision,
+ created_at: new Date(world.createdAt).toISOString(),
+ edit_url: `/editor?world=${world.id}`,
+ }
+}
+
+function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = []
+ let size = 0
+ let over = false
+ req.on('data', (chunk: Buffer) => {
+ size += chunk.length
+ if (size > max) {
+ over = true
+ req.destroy()
+ } else chunks.push(chunk)
+ })
+ req.on('end', () => resolve({ text: Buffer.concat(chunks).toString('utf8'), bytes: size }))
+ req.on('close', () => {
+ if (over) reject(new RouteError({ status: 413, error: 'too_large' }))
+ })
+ req.on('error', reject)
+ })
+}
+
+function parseJson(text: string): unknown {
+ try {
+ return JSON.parse(text) as unknown
+ } catch {
+ return fail(400, 'invalid_json')
+ }
+}
+
+export function createWorldRoutes(opts: WorldRouteOptions) {
+ const now = opts.now ?? Date.now
+ const online = (id: string): number => opts.host.online().get(id) ?? 0
+
+ const send = (
+ res: ServerResponse,
+ status: number,
+ body: unknown,
+ headers: Record<string, string> = {},
+ ) => {
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ ...headers,
+ })
+ res.end(JSON.stringify(body))
+ }
+
+ const bearer = (req: IncomingMessage): string | null =>
+ /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null
+
+ /** The caller's Network account, or null for a guest / no token. */
+ const caller = async (req: IncomingMessage): Promise<NetworkUser | null> => {
+ const token = bearer(req)
+ if (!token) return null
+ const user = await opts.resolveUser(token)
+ return user?.subjectId ? user : null
+ }
+
+ const signedIn = async (req: IncomingMessage): Promise<NetworkUser & { subjectId: string }> => {
+ const user = await caller(req)
+ if (!user?.subjectId)
+ fail(401, 'sign_in_required', { detail: 'Sign in with your OpenVibe account.' })
+ return user as NetworkUser & { subjectId: string }
+ }
+
+ /** A world the caller may see: published, or theirs, or staff. Anything else is not found. */
+ const visible = async (id: string, user: NetworkUser | null): Promise<CreatorWorld> => {
+ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
+ if (!world) return fail(404, 'world_not_found')
+ if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)
+ return world
+ return fail(404, 'world_not_found')
+ }
+
+ const owned = async (
+ id: string,
+ user: NetworkUser & { subjectId: string },
+ ): Promise<CreatorWorld> => {
+ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
+ if (!world || world.ownerSubject !== user.subjectId) return fail(404, 'world_not_found')
+ return world
+ }
+
+ const limited = (
+ kind: 'publicRead' | 'mapSave',
+ req: IncomingMessage,
+ res: ServerResponse,
+ actor: string,
+ ): Promise<boolean> => opts.limits[kind](req, res, actor)
+
+ async function route(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
+ const path = url.pathname
+ const method = req.method ?? 'GET'
+
+ if (path === '/api/worlds' && method === 'GET') {
+ if (!(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return
+ const page = Number(url.searchParams.get('page') ?? '1')
+ if (!Number.isSafeInteger(page) || page < 1 || page > 1000) fail(400, 'invalid_query')
+ const rows = await opts.store.listPublished(PAGE_SIZE + 1, (page - 1) * PAGE_SIZE)
+ send(res, 200, {
+ worlds: rows.slice(0, PAGE_SIZE).map((w) => publicView(w, online(w.id))),
+ page,
+ more: rows.length > PAGE_SIZE,
+ })
+ return
+ }
+
+ if (path === '/api/worlds/mine' && method === 'GET') {
+ const user = await signedIn(req)
+ const worlds = await opts.store.listByOwner(user.subjectId)
+ send(res, 200, {
+ worlds: worlds.map((w) => ownerView(w, online(w.id))),
+ max: opts.perPerson,
+ limits: CREATOR_MAP_LIMITS,
+ staff: isStaff(user),
+ })
+ return
+ }
+
+ if (path === '/api/worlds' && method === 'POST') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const body = parseJson((await readBody(req, META_BYTES)).text) as Record<
+ string,
+ unknown
+ > | null
+ const title = cleanText(body?.title, TITLE_MAX)
+ const summary = body?.summary === undefined ? '' : cleanText(body.summary, SUMMARY_MAX)
+ if (!title) fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })
+ if (summary === null)
+ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
+ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)
+ fail(409, 'world_limit', {
+ detail: `You can keep ${opts.perPerson} worlds. Delete one to make another.`,
+ })
+ const world = await opts.store.create({
+ ownerSubject: user.subjectId,
+ ownerName: user.name,
+ title: title as string,
+ summary: summary as string,
+ map: emptyMapV2(),
+ now: now(),
+ })
+ opts.log.info('creator world created', { world: world.id })
+ send(res, 201, ownerView(world, 0))
+ return
+ }
+
+ const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
+ if (!match) fail(404, 'not_found')
+ const id = match?.[1] ?? ''
+ const isMap = match?.[2] === '/map'
+
+ if (isMap && method === 'GET') {
+ const user = await caller(req)
+ if (!user && !(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return
+ await visible(id, user)
+ const stored = await opts.store.map(id)
+ if (!stored) fail(404, 'world_not_found')
+ send(res, 200, stored?.map, { etag: `"${stored?.revision}"`, 'cache-control': 'no-cache' })
+ return
+ }
+
+ if (isMap && (method === 'PUT' || method === 'POST')) {
+ const user = await signedIn(req)
+ await owned(id, user)
+ if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
+ const { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)
+ const parsed = parseMapFile(parseJson(text))
+ if (!parsed.ok) fail(422, 'map_invalid', { issues: parsed.issues.slice(0, 40) })
+ const map = (parsed as { map: MapFileV2 }).map
+ const issues = creatorMapIssues(map, bytes)
+ if (issues.length) fail(422, 'map_invalid', { issues })
+ const { revision: _client, ...doc } = map
+ const ifMatch = (req.headers['if-match'] as string | undefined)?.replace(/"/g, '') || null
+ const saved = await opts.store.saveMap(id, doc, ifMatch, now())
+ if (saved === null) fail(404, 'world_not_found')
+ if (saved === 'conflict') {
+ const current = await opts.store.get(id)
+ fail(409, 'stale_revision', current ? { revision: current.revision } : {})
+ }
+ const world = saved as CreatorWorld
+ void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
+ opts.host.applyMap(id, doc as MapFileV2)
+ send(
+ res,
+ 200,
+ { ok: true, live: true, revision: world.revision },
+ { etag: `"${world.revision}"` },
+ )
+ return
+ }
+
+ if (!isMap && method === 'GET') {
+ const user = await caller(req)
+ if (!user && !(await limited('publicRead', req, res, `ip:${opts.address(req)}`))) return
+ const world = await visible(id, user)
+ const mine = user?.subjectId === world.ownerSubject
+ send(
+ res,
+ 200,
+ mine || isStaff(user) ? ownerView(world, online(id)) : publicView(world, online(id)),
+ )
+ return
+ }
+
+ if (!isMap && method === 'PATCH') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const body = parseJson((await readBody(req, META_BYTES)).text) as Record<
+ string,
+ unknown
+ > | null
+ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
+ const mine = !!world && world.ownerSubject === user.subjectId
+ if (!world || (!mine && !isStaff(user))) return fail(404, 'world_not_found')
+ const patch: { title?: string; summary?: string; status?: CreatorWorldStatus } = {}
+ if (body?.title !== undefined) {
+ if (!mine) fail(403, 'owner_only')
+ const title = cleanText(body.title, TITLE_MAX)
+ if (!title)
+ fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })
+ patch.title = title as string
+ }
+ if (body?.summary !== undefined) {
+ if (!mine) fail(403, 'owner_only')
+ const summary = cleanText(body.summary, SUMMARY_MAX)
+ if (summary === null)
+ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
+ patch.summary = summary as string
+ }
+ if (body?.status !== undefined) {
+ const status = body.status
+ if (status !== 'draft' && status !== 'published' && status !== 'hidden')
+ fail(422, 'invalid_status')
+ if (isStaff(user) && !mine) {
+ // Staff take a world down or put it back; they never edit it.
+ if (status === 'draft') fail(422, 'invalid_status')
+ } else {
+ if (status === 'hidden') fail(403, 'staff_only')
+ if (world.status === 'hidden')
+ fail(403, 'world_hidden', {
+ detail: 'Staff took this world down. Ask on openvibe.help to bring it back.',
+ })
+ }
+ patch.status = status as CreatorWorldStatus
+ }
+ const updated = await opts.store.update(id, patch, now())
+ if (!updated) fail(404, 'world_not_found')
+ if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
+ if (patch.status && patch.status !== world.status) {
+ opts.log.info('creator world status', {
+ world: id,
+ from: world.status,
+ to: patch.status,
+ staff: !mine,
+ })
+ await opts.host.changed(updated, id)
+ }
+ send(res, 200, ownerView(updated as CreatorWorld, online(id)))
+ return
+ }
+
+ if (!isMap && method === 'DELETE') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
+ if (!world || (world.ownerSubject !== user.subjectId && !isGamesStaff(user)))
+ return fail(404, 'world_not_found')
+ await opts.host.remove(id)
+ opts.log.info('creator world deleted', {
+ world: id,
+ staff: world.ownerSubject !== user.subjectId,
+ })
+ res.writeHead(204, { 'cache-control': 'no-store' })
+ res.end()
+ return
+ }
+
+ fail(405, 'method_not_allowed')
+ }
+
+ /** true when the request was /api/worlds… (answered, or being answered). */
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const url = new URL(req.url ?? '/', 'http://games.local')
+ if (url.pathname !== '/api/worlds' && !url.pathname.startsWith('/api/worlds/')) return false
+ route(req, res, url).catch((err: unknown) => {
+ if (res.headersSent) return
+ if (err instanceof RouteError) {
+ const { status, ...body } = err.problem
+ send(res, status, body)
+ return
+ }
+ opts.log.warn('worlds request failed', {
+ error: String((err as Error | undefined)?.message ?? err),
+ })
+ send(res, 500, { error: 'internal' })
+ })
+ return true
+ }
+}
@@ -38,6 +38,31 @@ The read-only `/leaderboard.html` page shows Salvage Rush's best round scores an
@@ -38,6 +38,31 @@ The read-only `/leaderboard.html` page shows Salvage Rush's best round scores an
Network account exports include results, best scores, unlocks and hauls; account erasure removes them.
Network account exports include results, best scores, unlocks and hauls; account erasure removes them.
Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.
Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.
+## Creator worlds
+
+`/worlds` lists the worlds players built and published; a signed-in OpenVibe account (not a guest) can keep up to
+five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in the map editor at
+`/editor?world=<id>` and played at `/play?world=<id>`.
+
+- **Storage:** `creator_worlds` (migration 0010) holds the title, summary, status (`draft`, `published`, `hidden`)
+ and the editor's v2 map document; `revision` is its hash and the editor's `If-Match`.
+- **Editing:** the owner alone, over `PUT /api/worlds/:id/map`, with no collaboration socket (`/editor-ws` stays
+ staff-only). Creator maps use the built-in materials: no uploaded textures, models or paint masks (they wait for
+ the Pics safety pipeline), and smaller limits than staff maps (`CREATOR_MAP_LIMITS`: 2 MB, 4,000 statics, 16
+ terrains, 400 nodes and props, 64 lights and zones). A save reaches a running world at once.
+- **Running:** a world is the place `world:<id>` with the plain creator pack (`creatorWorldPack`, the same def set
+ on server and client) and its map as the override. It starts on the first join (inside the router's admission
+ chain), runs one room of `GAMES_CREATOR_WORLD_PLAYERS` (16), and stops after `GAMES_CREATOR_WORLD_IDLE_SECONDS`
+ (180) empty. At most `GAMES_CREATOR_WORLDS_MAX` (2) run at once, so Scraplandia's tick keeps its budget; a join
+ beyond that closes with 4005 `world_unavailable` and the client says the world is not open right now.
+- **Sandbox:** characters are read but never saved in a creator world (`worlds/sandboxStore.ts`), and the room has
+ no NPCs, world events, rewards, boards or events. Nothing gathered, crafted, traded or dropped in somebody's world
+ reaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.
+- **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. Worlds are in the account export, follow an
+ account merge and go with an account deletion.
+
## Adding another game
## Adding another game
1. Create a `games-content@2` built-in pack with a validated map document and declarative
1. Create a `games-content@2` built-in pack with a validated map document and declarative
@@ -22,6 +22,7 @@ export * from './defs/recipes.js'
@@ -22,6 +22,7 @@ export * from './defs/recipes.js'
export * from './defs/skills.js'
export * from './defs/skills.js'
export * from './defs/resources.js'
export * from './defs/resources.js'
export * from './packs/scraplandia.js'
export * from './packs/scraplandia.js'
+export * from './packs/creatorWorld.js'
import { ContentRegistry, type ContentDefs } from './registry.js'
import { ContentRegistry, type ContentDefs } from './registry.js'
import { mergePackDefs, type ContentPackV2 } from './pack.js'
import { mergePackDefs, type ContentPackV2 } from './pack.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { createContent } from '../index.js'
+import { emptyMapV2, type MapFileV2 } from '../mapFileV2.js'
+import {
+ CREATOR_MAP_LIMITS,
+ creatorMapIssues,
+ creatorPlaceId,
+ creatorWorldOf,
+ creatorWorldPack,
+} from './creatorWorld.js'
+
+describe('creator worlds', () => {
+ it('maps world ids to places and back, refusing anything else', () => {
+ expect(creatorPlaceId('wabc123def0')).toBe('world:wabc123def0')
+ expect(creatorWorldOf('world:wabc123def0')).toBe('wabc123def0')
+ expect(creatorWorldOf('world:WABC123DEF0')).toBeNull()
+ expect(creatorWorldOf('world:wabc')).toBeNull()
+ expect(creatorWorldOf('scraplandia')).toBeNull()
+ expect(creatorWorldOf(undefined)).toBeNull()
+ expect(() => creatorWorldPack('../etc')).toThrow(/creator world id/)
+ })
+
+ it('builds the same def set on server and client, one per world, never Scraplandia’s', () => {
+ const a = createContent([], creatorWorldPack('w0000000001'))
+ expect(createContent([], creatorWorldPack('w0000000001')).digest).toBe(a.digest)
+ expect(createContent([], creatorWorldPack('w0000000002')).digest).not.toBe(a.digest)
+ expect(createContent().digest).not.toBe(a.digest)
+ expect(a.world.id).toBe('creator_w0000000001')
+ expect(a.world.zones).toEqual([])
+ })
+
+ it('takes a plain map and refuses uploads, paint and oversized maps', () => {
+ const plain: MapFileV2 = emptyMapV2()
+ expect(creatorMapIssues(plain, 200)).toEqual([])
+ const uploads: MapFileV2 = {
+ ...plain,
+ textures: [{ name: 'photo', url: '/map-assets/abc.png' }],
+ models: [{ id: 'm', name: 'm', glb: '/map-assets/m.glb', bounds: [1, 1, 1] }],
+ }
+ const issues = creatorMapIssues(uploads, CREATOR_MAP_LIMITS.bytes + 1)
+ expect(issues.some((i) => i.startsWith('bytes:'))).toBe(true)
+ expect(issues.some((i) => i.startsWith('textures:'))).toBe(true)
+ expect(issues.some((i) => i.startsWith('models:'))).toBe(true)
+ const lights = Array.from({ length: CREATOR_MAP_LIMITS.lights + 1 }, (_, n) => ({
+ ...(plain.lights[0] ?? {}),
+ id: `l${n}`,
+ })) as MapFileV2['lights']
+ expect(creatorMapIssues({ ...plain, lights }, 10).some((i) => i.startsWith('lights:'))).toBe(
+ true,
+ )
+ const painted = {
+ ...plain,
+ statics: [
+ {
+ id: 's',
+ surface: {
+ base: {},
+ paint: {
+ layers: [{ id: 'l', tex: 'none', color: '#ffffff', channel: 'r' }],
+ mask: 'm',
+ },
+ },
+ },
+ ],
+ } as unknown as MapFileV2
+ expect(creatorMapIssues(painted, 10).some((i) => i.startsWith('statics:'))).toBe(true)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+import type { ContentPackV2 } from '../pack.js'
+import type { MapFileV2 } from '../mapFileV2.js'
+
+/**
+ * Creator worlds: a place a signed-in player builds in /editor and publishes at /worlds. The server and
+ * the client build the same pack from the world id, so the def-set digest agrees without shipping a pack.
+ * The pack is deliberately plain (flat ground, no zones, no achievements): everything a creator adds lives in
+ * the world's map document, and the title stays out of the pack so renaming a world never changes its digest.
+ */
+
+/** A creator world id: `w` and ten lower-case base-36 characters. */
+export const CREATOR_WORLD_ID = /^w[0-9a-z]{10}$/
+
+/** The place a creator world runs as; `hello.place` carries it. */
+export function creatorPlaceId(worldId: string): string {
+ return `world:${worldId}`
+}
+
+/** The world id inside a creator place id, or null for any other place. */
+export function creatorWorldOf(placeId: string | undefined | null): string | null {
+ const id = placeId?.startsWith('world:') ? placeId.slice('world:'.length) : null
+ return id && CREATOR_WORLD_ID.test(id) ? id : null
+}
+
+export function creatorWorldPack(worldId: string): ContentPackV2 {
+ if (!CREATOR_WORLD_ID.test(worldId)) throw new Error(`not a creator world id: ${worldId}`)
+ return {
+ map: {
+ id: `creator_${worldId}`,
+ name: 'Creator world',
+ flatTerrain: true,
+ groundHalfExtent: 80,
+ spawnPoint: [0, 1.2, 4],
+ spawnYaw: 0,
+ statics: [],
+ resourceNodes: [],
+ initialProps: [],
+ zones: [],
+ },
+ }
+}
+
+/** What a creator world's map may hold. Staff maps keep the schema's own (much larger) limits. */
+export const CREATOR_MAP_LIMITS = {
+ bytes: 2 * 1024 * 1024,
+ terrains: 16,
+ statics: 4000,
+ nodes: 400,
+ props: 400,
+ lights: 64,
+ zones: 64,
+} as const
+
+/**
+ * Checks a parsed map against what creator worlds take: the limits above, and only the built-in materials.
+ * Uploads (custom textures, models and paint masks) are staff-only for now: an uploaded image is shown to
+ * everyone who joins, and creator uploads wait for the Pics safety pipeline.
+ */
+export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
+ const issues: string[] = []
+ const limit = (what: keyof typeof CREATOR_MAP_LIMITS, count: number): void => {
+ if (count > CREATOR_MAP_LIMITS[what])
+ issues.push(
+ `${what}: ${count} is more than the ${CREATOR_MAP_LIMITS[what]} a creator world takes`,
+ )
+ }
+ limit('bytes', bytes)
+ limit('terrains', map.terrains.length)
+ limit('statics', map.statics.length)
+ limit('nodes', map.nodes.length)
+ limit('props', map.props.length)
+ limit('lights', map.lights.length)
+ limit('zones', map.zones.length)
+ if (map.models.length > 0)
+ issues.push('models: uploaded models are not available in creator worlds yet')
+ if (map.textures.length > 0)
+ issues.push('textures: uploaded textures are not available in creator worlds yet')
+ const painted = (
+ surface: { paint?: { layers: unknown[]; mask?: string | undefined } | undefined } | undefined,
+ ): boolean => !!surface?.paint && (surface.paint.layers.length > 0 || !!surface.paint.mask)
+ if (map.terrains.some((t) => painted(t.surface)))
+ issues.push('terrains: painted surfaces are not available in creator worlds yet')
+ if (
+ map.statics.some(
+ (s) =>
+ painted(s.surface) || Object.values(s.surfaces ?? {}).some((surface) => painted(surface)),
+ )
+ )
+ issues.push('statics: painted surfaces are not available in creator worlds yet')
+ return issues
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+-- phase: expand
+-- Creator worlds: a signed-in player's own place, built in /editor and published at /worlds. The map document is
+-- the editor's v2 file (validated before it is stored); `revision` is its content hash, the editor's If-Match.
+-- A world runs as the place 'world:<id>' (its rows in places/world_entities/... are removed with the world).
+-- `status`: draft (only the owner sees it), published (listed and joinable), hidden (staff took it down; the
+-- owner can still open it in the editor, nobody can join it).
+CREATE TABLE creator_worlds (
+ id text COLLATE "C" PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL,
+ owner_name text NOT NULL,
+ title text NOT NULL,
+ summary text NOT NULL DEFAULT '',
+ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'hidden')),
+ map jsonb NOT NULL,
+ revision text COLLATE "C" NOT NULL,
+ plays integer NOT NULL DEFAULT 0,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ published_at bigint
+);
+CREATE INDEX creator_worlds_owner ON creator_worlds (owner_subject, updated_at DESC);
+CREATE INDEX creator_worlds_listed ON creator_worlds (published_at DESC) WHERE status = 'published';
@@ -5,3 +5,14 @@ export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/
@@ -5,3 +5,14 @@ export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/
export { InstanceRegistry, type InstanceLease, type InstanceState } from './pg/instanceRegistry.js'
export { InstanceRegistry, type InstanceLease, type InstanceState } from './pg/instanceRegistry.js'
export { MIGRATIONS_DIR } from './migrations.js'
export { MIGRATIONS_DIR } from './migrations.js'
export { moveCasualSaves, exportCasual, eraseCasual } from './pg/casualData.js'
export { moveCasualSaves, exportCasual, eraseCasual } from './pg/casualData.js'
+export {
+ createCreatorWorldStore,
+ mapRevision,
+ removeWorldPlaces,
+ exportCreatorWorlds,
+ eraseCreatorWorlds,
+ moveCreatorWorlds,
+ type CreatorWorld,
+ type CreatorWorldStatus,
+ type CreatorWorldStore,
+} from './pg/creatorWorlds.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import { sql } from 'openvibe-sdk/db'
+import { openTestDb } from '../testing.js'
+import { openPgStore } from './pgStore.js'
+import { createIdentityRepository } from './platformRepositories.js'
+import { createCreatorWorldStore, mapRevision } from './creatorWorlds.js'
+
+const KIM = 'usr_01J0000000000000000000000K'
+const LEE = 'usr_01J0000000000000000000000L'
+const MAP = {
+ v: 2,
+ terrains: [],
+ statics: [],
+ nodes: [],
+ props: [],
+ lights: [],
+ zones: [],
+ models: [],
+ textures: [],
+}
+
+describe('creator worlds store', () => {
+ it('creates, lists, saves with If-Match, publishes once and removes the world with its place', async () => {
+ const db = await openTestDb()
+ const store = createCreatorWorldStore(db)
+ const world = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Moon base',
+ summary: '',
+ map: MAP,
+ now: 1000,
+ })
+ expect(world.id).toMatch(/^w[0-9a-z]{10}$/)
+ expect(world).toMatchObject({
+ status: 'draft',
+ revision: mapRevision(MAP),
+ publishedAt: null,
+ plays: 0,
+ })
+ expect(await store.countByOwner(KIM)).toBe(1)
+ expect(await store.listPublished(10, 0)).toEqual([])
+
+ const next = { ...MAP, lights: [] as unknown[], spawn: [1, 2, 3] }
+ expect(await store.saveMap(world.id, next, 'stale', 2000)).toBe('conflict')
+ const saved = await store.saveMap(world.id, next, world.revision, 2000)
+ expect(saved).not.toBe('conflict')
+ expect((await store.map(world.id))?.map).toEqual(next)
+ expect(await store.saveMap('wnope000000', next, null, 2000)).toBeNull()
+
+ const published = await store.update(world.id, { status: 'published' }, 3000)
+ expect(published?.publishedAt).toBe(3000)
+ await store.update(world.id, { status: 'draft' }, 4000)
+ const again = await store.update(world.id, { status: 'published', title: 'Moon base 2' }, 5000)
+ expect(again).toMatchObject({ publishedAt: 3000, title: 'Moon base 2' })
+ expect((await store.listPublished(10, 0)).map((w) => w.id)).toEqual([world.id])
+ await store.countPlay(world.id)
+ expect((await store.get(world.id))?.plays).toBe(1)
+ await store.rename(KIM, 'Kim')
+ expect((await store.get(world.id))?.ownerName).toBe('Kim')
+
+ // The place's rows go with the world.
+ const place = `world:${world.id}`
+ const placeStore = openPgStore(db, { placeId: place })
+ await placeStore.ensurePlace('Moon base')
+ await placeStore.meta.set('world_seeded', true)
+ expect(await store.remove(world.id)).toBe(true)
+ expect(await store.get(world.id)).toBeNull()
+ expect(await db.maybe(sql`SELECT 1 FROM places WHERE id = ${place}`)).toBeNull()
+ expect(await db.maybe(sql`SELECT 1 FROM world_meta WHERE place_id = ${place}`)).toBeNull()
+ })
+
+ it('follows the account: export, merge and erasure (ADR-033, ADR-029)', async () => {
+ const db = await openTestDb()
+ const store = createCreatorWorldStore(db)
+ const identity = createIdentityRepository(db)
+ const kims = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'A',
+ summary: 's',
+ map: MAP,
+ now: 1,
+ })
+ const exported = await identity.exportSubject(KIM)
+ expect(exported.creatorWorlds).toHaveLength(1)
+ expect(exported.creatorWorlds[0]).toMatchObject({ id: kims.id, title: 'A' })
+ expect(exported.creatorWorlds[0]).not.toHaveProperty('owner_subject')
+
+ await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
+ expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
+
+ const erased = await identity.eraseSubjects([LEE])
+ expect(erased.creator_worlds).toBe(1)
+ expect(await store.get(kims.id)).toBeNull()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -0,0 +1,254 @@
+import { createHash, randomBytes } from 'node:crypto'
+import { sql, type Db } from 'openvibe-sdk/db'
+import { idList } from './sqlUtils.js'
+
+/**
+ * Creator worlds (migration 0010): a signed-in player's place, built in /editor and published at /worlds. The map
+ * is stored as the validated v2 document; callers validate it first (the content package's parseMapFile and
+ * creatorMapIssues), this module only stores, lists and removes. A world runs as the place `world:<id>`, so removing
+ * one also removes that place's rows.
+ */
+
+export type CreatorWorldStatus = 'draft' | 'published' | 'hidden'
+
+export interface CreatorWorld {
+ id: string
+ ownerSubject: string
+ ownerName: string
+ title: string
+ summary: string
+ status: CreatorWorldStatus
+ revision: string
+ plays: number
+ createdAt: number
+ updatedAt: number
+ publishedAt: number | null
+}
+
+interface Row {
+ id: string
+ owner_subject: string
+ owner_name: string
+ title: string
+ summary: string
+ status: CreatorWorldStatus
+ revision: string
+ plays: number
+ created_at: number | string
+ updated_at: number | string
+ published_at: number | string | null
+}
+
+const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at`
+
+function fromRow(row: Row): CreatorWorld {
+ return {
+ id: row.id,
+ ownerSubject: row.owner_subject,
+ ownerName: row.owner_name,
+ title: row.title,
+ summary: row.summary,
+ status: row.status,
+ revision: row.revision,
+ plays: Number(row.plays),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ publishedAt: row.published_at === null ? null : Number(row.published_at),
+ }
+}
+
+/** The revision of a stored map: a hash of the document as it is stored. */
+export function mapRevision(map: unknown): string {
+ return createHash('sha256').update(JSON.stringify(map)).digest('hex').slice(0, 24)
+}
+
+/** `w` + ten base-36 characters (content's CREATOR_WORLD_ID). */
+export function newCreatorWorldId(): string {
+ const bytes = randomBytes(10)
+ let id = 'w'
+ for (const b of bytes) id += (b % 36).toString(36)
+ return id
+}
+
+/** The place ids a world's state lives under: its own and any extra room's (`<place>:instance:<n>`). */
+function placeMatch(worldId: string) {
+ const place = `world:${worldId}`
+ return sql`(place_id = ${place} OR place_id LIKE ${`${place}:instance:%`})`
+}
+
+export interface CreatorWorldStore {
+ create(input: {
+ ownerSubject: string
+ ownerName: string
+ title: string
+ summary: string
+ map: unknown
+ now: number
+ }): Promise<CreatorWorld>
+ get(id: string): Promise<CreatorWorld | null>
+ /** The stored map document and its revision. */
+ map(id: string): Promise<{ map: unknown; revision: string } | null>
+ countByOwner(ownerSubject: string): Promise<number>
+ listByOwner(ownerSubject: string): Promise<CreatorWorld[]>
+ /** Published worlds, newest first. */
+ listPublished(limit: number, offset: number): Promise<CreatorWorld[]>
+ /**
+ * Replaces the map when `ifMatch` is the stored revision (or absent). Answers the new world, 'conflict' when the
+ * revision moved, or null when the world is gone.
+ */
+ saveMap(
+ id: string,
+ map: unknown,
+ ifMatch: string | null,
+ now: number,
+ ): Promise<CreatorWorld | 'conflict' | null>
+ update(
+ id: string,
+ patch: { title?: string; summary?: string; status?: CreatorWorldStatus },
+ now: number,
+ ): Promise<CreatorWorld | null>
+ /** The owner's display name follows the Network's (set at each signed-in write). */
+ rename(ownerSubject: string, ownerName: string): Promise<void>
+ countPlay(id: string): Promise<void>
+ /** Removes the world and its place's rows. */
+ remove(id: string): Promise<boolean>
+}
+
+export function createCreatorWorldStore(db: Db): CreatorWorldStore {
+ return {
+ async create(input) {
+ const id = newCreatorWorldId()
+ const row = await db.one<Row>(sql`
+ INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at)
+ VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},
+ ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now})
+ RETURNING ${COLUMNS}
+ `)
+ return fromRow(row)
+ },
+ async get(id) {
+ const row = await db.maybe<Row>(sql`SELECT ${COLUMNS} FROM creator_worlds WHERE id = ${id}`)
+ return row ? fromRow(row) : null
+ },
+ async map(id) {
+ const row = await db.maybe<{ map: unknown; revision: string }>(
+ sql`SELECT map, revision FROM creator_worlds WHERE id = ${id}`,
+ )
+ return row ? { map: row.map, revision: row.revision } : 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}`,
+ )
+ return Number(row.n)
+ },
+ async listByOwner(ownerSubject) {
+ const rows = await db.many<Row>(
+ sql`SELECT ${COLUMNS} FROM creator_worlds WHERE owner_subject = ${ownerSubject} ORDER BY updated_at DESC`,
+ )
+ return rows.map(fromRow)
+ },
+ async listPublished(limit, offset) {
+ const rows = await db.many<Row>(sql`
+ SELECT ${COLUMNS} FROM creator_worlds WHERE status = 'published'
+ ORDER BY published_at DESC, id LIMIT ${limit} OFFSET ${offset}
+ `)
+ return rows.map(fromRow)
+ },
+ async saveMap(id, map, ifMatch, now) {
+ return db.tx(async () => {
+ const current = await db.maybe<{ revision: string }>(
+ sql`SELECT revision FROM creator_worlds WHERE id = ${id} FOR UPDATE`,
+ )
+ if (!current) return null
+ if (ifMatch && ifMatch !== current.revision) return 'conflict' as const
+ const row = await db.one<Row>(sql`
+ UPDATE creator_worlds SET map = ${sql.json(map)}, revision = ${mapRevision(map)}, updated_at = ${now}
+ WHERE id = ${id} RETURNING ${COLUMNS}
+ `)
+ return fromRow(row)
+ })
+ },
+ async update(id, patch, now) {
+ const sets = [sql`updated_at = ${now}`]
+ if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`)
+ if (patch.summary !== undefined) sets.push(sql`summary = ${patch.summary}`)
+ if (patch.status !== undefined) {
+ sets.push(sql`status = ${patch.status}`)
+ // The first publication dates the listing; unpublishing and publishing again keeps the date.
+ if (patch.status === 'published')
+ sets.push(sql`published_at = COALESCE(published_at, ${now})`)
+ }
+ const row = await db.maybe<Row>(
+ sql`UPDATE creator_worlds SET ${sql.join(sets)} WHERE id = ${id} RETURNING ${COLUMNS}`,
+ )
+ return row ? fromRow(row) : null
+ },
+ async rename(ownerSubject, ownerName) {
+ await db.exec(
+ sql`UPDATE creator_worlds SET owner_name = ${ownerName} WHERE owner_subject = ${ownerSubject} AND owner_name <> ${ownerName}`,
+ )
+ },
+ async countPlay(id) {
+ await db.exec(sql`UPDATE creator_worlds SET plays = plays + 1 WHERE id = ${id}`)
+ },
+ async remove(id) {
+ return db.tx(async () => {
+ const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
+ await removeWorldPlaces(db, [id])
+ return gone > 0
+ })
+ },
+ }
+}
+
+/** Every row a creator world's place left behind (call inside the transaction that removes the worlds). */
+export async function removeWorldPlaces(db: Db, worldIds: readonly string[]): Promise<void> {
+ for (const id of worldIds) {
+ const where = placeMatch(id)
+ await db.exec(sql`
+ DELETE FROM character_instance_positions
+ WHERE instance_id IN (SELECT id FROM game_instances WHERE ${where})
+ `)
+ await db.exec(sql`DELETE FROM game_instances WHERE ${where}`)
+ await db.exec(sql`DELETE FROM world_constraints WHERE ${where}`)
+ await db.exec(sql`DELETE FROM world_entities WHERE ${where}`)
+ await db.exec(sql`DELETE FROM world_meta WHERE ${where}`)
+ await db.exec(sql`DELETE FROM mod_storage WHERE ${where}`)
+ const place = `world:${id}`
+ await db.exec(sql`DELETE FROM places WHERE id = ${place} OR id LIKE ${`${place}:instance:%`}`)
+ }
+}
+
+/** A person's worlds, for their data export (ADR-033): everything but the account key. */
+export async function exportCreatorWorlds(
+ db: Db,
+ subject: string,
+): Promise<Record<string, unknown>[]> {
+ return db.many<Record<string, unknown>>(sql`
+ SELECT id, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, map
+ FROM creator_worlds WHERE owner_subject = ${subject} ORDER BY created_at
+ `)
+}
+
+/** Erasure (ADR-033): the worlds go with their places. Answers how many. */
+export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): Promise<number> {
+ if (!subjects.length) return 0
+ const ids = (
+ await db.many<{ id: string }>(
+ sql`SELECT id FROM creator_worlds WHERE owner_subject IN (${idList(subjects)})`,
+ )
+ ).map((r) => r.id)
+ if (!ids.length) return 0
+ await db.exec(sql`DELETE FROM creator_worlds WHERE id IN (${idList(ids)})`)
+ await removeWorldPlaces(db, ids)
+ return ids.length
+}
+
+/** Account merge (ADR-029): the folded-in account's worlds belong to the survivor. */
+export async function moveCreatorWorlds(db: Db, from: string, into: string): Promise<number> {
+ if (from === into) return 0
+ return db.exec(
+ sql`UPDATE creator_worlds SET owner_subject = ${into} WHERE owner_subject = ${from}`,
+ )
+}
@@ -165,6 +165,7 @@ describe('account export and deletion (ADR-033)', () => {
@@ -165,6 +165,7 @@ describe('account export and deletion (ADR-033)', () => {
structures_unowned: 1,
structures_unowned: 1,
identity_rows: 1,
identity_rows: 1,
casual_saves: 2,
casual_saves: 2,
+ creator_worlds: 0,
})
})
expect(
expect(
await store.db.many(sql`SELECT account_key FROM casual_ledger ORDER BY account_key`),
await store.db.many(sql`SELECT account_key FROM casual_ledger ORDER BY account_key`),
@@ -185,6 +186,7 @@ describe('account export and deletion (ADR-033)', () => {
@@ -185,6 +186,7 @@ describe('account export and deletion (ADR-033)', () => {
structures_unowned: 0,
structures_unowned: 0,
identity_rows: 0,
identity_rows: 0,
casual_saves: 0,
casual_saves: 0,
+ creator_worlds: 0,
})
})
})
})
})
})
@@ -12,6 +12,7 @@ import type {
@@ -12,6 +12,7 @@ import type {
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import { idList } from './sqlUtils.js'
import { idList } from './sqlUtils.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
+import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'
/**
/**
* PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
* PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
@@ -163,6 +164,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -163,6 +164,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
moved++
moved++
}
}
await moveProgress(from, into)
await moveProgress(from, into)
+ await moveCreatorWorlds(db, from, into)
await recordAdoption(key, into, 'merge', moved, kept, now)
await recordAdoption(key, into, 'merge', moved, kept, now)
return { moved, kept, already: false }
return { moved, kept, already: false }
})
})
@@ -191,6 +193,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -191,6 +193,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
)
)
const casual = await exportCasual(db, subject)
const casual = await exportCasual(db, subject)
+ const creatorWorlds = await exportCreatorWorlds(db, subject)
return {
return {
characters: characters.map(withoutAccountKey),
characters: characters.map(withoutAccountKey),
structures,
structures,
@@ -200,12 +203,19 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -200,12 +203,19 @@ export function createIdentityRepository(db: Db): IdentityRepository {
gameHauls,
gameHauls,
casualSaves: casual.saves,
casualSaves: casual.saves,
casualLedger: casual.ledger,
casualLedger: casual.ledger,
+ creatorWorlds,
}
}
},
},
eraseSubjects(subjects) {
eraseSubjects(subjects) {
return db.tx(async () => {
return db.tx(async () => {
- const out = { characters: 0, structures_unowned: 0, identity_rows: 0, casual_saves: 0 }
+ const out = {
+ characters: 0,
+ structures_unowned: 0,
+ identity_rows: 0,
+ casual_saves: 0,
+ creator_worlds: 0,
+ }
if (!subjects.length) return out
if (!subjects.length) return out
const ids = (
const ids = (
await db.many<{ id: string }>(
await db.many<{ id: string }>(
@@ -229,6 +239,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -229,6 +239,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
)
)
await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)
await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)
out.casual_saves = await eraseCasual(db, subjects)
out.casual_saves = await eraseCasual(db, subjects)
+ out.creator_worlds = await eraseCreatorWorlds(db, subjects)
return out
return out
})
})
},
},
@@ -91,6 +91,8 @@ export interface IdentityRepository {
@@ -91,6 +91,8 @@ export interface IdentityRepository {
/** Casual-kit saves (plan T22) and their ledger. */
/** Casual-kit saves (plan T22) and their ledger. */
casualSaves: Record<string, unknown>[]
casualSaves: Record<string, unknown>[]
casualLedger: Record<string, unknown>[]
casualLedger: Record<string, unknown>[]
+ /** The person's creator worlds, maps included. */
+ creatorWorlds: Record<string, unknown>[]
}>
}>
/**
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
@@ -102,6 +104,7 @@ export interface IdentityRepository {
@@ -102,6 +104,7 @@ export interface IdentityRepository {
structures_unowned: number
structures_unowned: number
identity_rows: number
identity_rows: number
casual_saves: number
casual_saves: number
+ creator_worlds: number
}>
}>
}
}
@@ -21,6 +21,7 @@ const TABLES = [
@@ -21,6 +21,7 @@ const TABLES = [
'casual_ledger',
'casual_ledger',
'casual_actions',
'casual_actions',
'play_sync',
'play_sync',
+ 'creator_worlds',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
'world_meta',
'world_meta',
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/editor/net/mapEndpoints.test.ts'
+// covers: 'apps/client/src/editor/net'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/worlds/creatorWorlds.test.ts'
+// covers: 'apps/server/src/worlds'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/worlds/worldRoutes.test.ts'
+// covers: 'apps/server/src/worlds'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'packages/content/src/packs/creatorWorld.test.ts'
+// covers: 'packages/content/src/packs'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'packages/persistence/src/pg/creatorWorlds.test.ts'
+// covers: 'packages/persistence/src/pg'
+// Generated by node test/run.js --index; do not edit.