openvibers/openvibe.games Commits
The asset store: share builds and scripts for anyone's worlds (free first) (#163)
Changes
@@@ -20,6 +20,7 @@ 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_ASSETS_PER_PERSON=50# GAMES_CREATOR_WORLD_IDLE_SECONDS=180# GAMES_CREATOR_WORLD_PLAYERS=16nnew file mode 100644@@@ -0,0 +1,206 @@++/**++ * The dock's Store tab: the asset store (migration 0016) inside the editor. It lists the builds creators share and++ * places one where the camera looks, and shares the selected pieces as a new build. Talks to /api/assets with the++ * signed-in Network token; placing and sharing go through the callbacks, so the document changes stay in editorApp++ * (one undo step each).++ */++import type { PrefabBody } from '@openvibe/content'++++export interface StoreBuild {++ id: string++ title: string++ owner: string++ uses: number++ contents: { pieces?: number }++}++++export interface StorePanelOptions {++ /** The creator world being edited (a shared build credits it), or null on the place's own map. */++ worldId: string | null++ token: () => string++ /** Puts a build in the document where the camera looks (one undo step). */++ place: (body: PrefabBody, title: string) => void++ /** The selected pieces as a build, or why there is none to share. */++ selectionBuild: () => { body: PrefabBody; pieces: number } | { error: string }++ setMessage: (m: string) => void++ fetch?: typeof fetch++}++++export class StorePanel {++ private builds: StoreBuild[] = []++ private page = 1++ private more = false++ private loaded = false++ private status = ''++++ constructor(++ private readonly root: HTMLElement,++ private readonly opts: StorePanelOptions,++ ) {++ this.render()++ }++++ /** Loads the first page the first time the tab is shown. */++ show(): void {++ if (!this.loaded) void this.load(true)++ }++++ private async api<T>(path: string, init: RequestInit = {}): Promise<T> {++ const token = this.opts.token()++ const resp = await (this.opts.fetch ?? fetch)(path, {++ ...init,++ headers: {++ ...(init.body ? { 'content-type': 'application/json' } : {}),++ ...(token ? { authorization: `Bearer ${token}` } : {}),++ },++ })++ const body = (await resp.json().catch(() => ({}))) as {++ error?: string++ detail?: string++ issues?: string[]++ }++ if (!resp.ok)++ throw new Error(body.detail ?? body.issues?.[0] ?? body.error ?? `HTTP ${resp.status}`)++ return body as T++ }++++ async load(reset = false): Promise<void> {++ if (reset) this.page = 1++ try {++ const data = await this.api<{ assets: StoreBuild[]; more: boolean }>(++ `/api/assets?kind=prefab&page=${this.page}`,++ )++ this.builds = this.page === 1 ? data.assets : [...this.builds, ...data.assets]++ this.more = data.more++ this.loaded = true++ this.status = this.builds.length ? '' : 'No builds shared yet. Share yours below.'++ } catch {++ this.status = 'The store could not be loaded. Open this tab again to retry.'++ this.loaded = false++ }++ this.render()++ }++++ /** A build from the store, its use counted when signed in, placed where the camera looks. */++ async use(id: string): Promise<void> {++ try {++ const signedIn = !!this.opts.token()++ const asset = await this.api<{ title: string; body: PrefabBody }>(++ signedIn ? `/api/assets/${id}/use` : `/api/assets/${id}`,++ signedIn ? { method: 'POST' } : {},++ )++ this.opts.place(asset.body, asset.title)++ } catch (err) {++ this.opts.setMessage(++ `⛔ ${err instanceof Error ? err.message : 'that build could not be placed'}`,++ )++ }++ }++++ private async share(title: string, summary: string, button: HTMLButtonElement): Promise<void> {++ const picked = this.opts.selectionBuild()++ if ('error' in picked) {++ this.status = picked.error++ this.render()++ return++ }++ button.disabled = true++ try {++ await this.api('/api/assets', {++ method: 'POST',++ body: JSON.stringify({++ kind: 'prefab',++ title,++ summary,++ body: picked.body,++ ...(this.opts.worldId ? { from_world: this.opts.worldId } : {}),++ }),++ })++ await this.load(true)++ this.status = `Shared “${title.trim()}” (${picked.pieces} pieces). Anyone can use it from the store now.`++ this.render()++ } catch (err) {++ this.status = err instanceof Error ? err.message : 'That did not work.'++ this.render()++ } finally {++ button.disabled = false++ }++ }++++ private render(): void {++ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls = '', text?: string) => {++ const node = document.createElement(tag)++ if (cls) node.className = cls++ if (text !== undefined) node.textContent = text++ return node++ }++ const list = el('div', 'store-list')++ for (const build of this.builds) {++ const row = el('div', 'store-row')++ const what = el('span', 'store-what')++ what.append(++ el('b', '', build.title),++ el(++ 'small',++ '',++ ` by ${build.owner} · ${build.contents.pieces ?? 0} pieces${build.uses ? ` · used by ${build.uses}` : ''}`,++ ),++ )++ const place = el('button', 'mini', 'Place')++ place.type = 'button'++ place.title = 'Put it where the camera looks'++ place.addEventListener('click', () => void this.use(build.id))++ row.append(what, place)++ list.append(row)++ }++ const moreButton = el('button', 'mini', 'More builds')++ moreButton.type = 'button'++ moreButton.hidden = !this.more++ moreButton.addEventListener('click', () => {++ this.page++++ void this.load()++ })++++ const shareBox = el('div', 'store-share')++ const title = el('input')++ title.maxLength = 60++ title.placeholder = 'Title of your build'++ title.setAttribute('aria-label', 'Build title')++ const summary = el('input')++ summary.maxLength = 280++ summary.placeholder = 'What is it? (optional)'++ summary.setAttribute('aria-label', 'Build summary')++ const shareButton = el('button', 'mini', 'Share the selection')++ shareButton.type = 'button'++ shareButton.disabled = !this.opts.token()++ shareButton.addEventListener('click', () => {++ if (!title.value.trim()) {++ this.status = 'Give your build a title first.'++ this.render()++ return++ }++ void this.share(title.value, summary.value, shareButton)++ })++ shareBox.append(++ el(++ 'p',++ 'store-hint',++ this.opts.token()++ ? 'Select boxes, nodes, props and lights in the viewport, then share them as a build anyone can place.'++ : 'Sign in to share a build.',++ ),++ title,++ summary,++ shareButton,++ )++ const status = el('p', 'store-status', this.status)++ status.setAttribute('role', 'status')++ this.root.replaceChildren(++ el('p', 'store-hint', 'Builds other creators share, free. Place one where the camera looks.'),++ list,++ moreButton,++ shareBox,++ status,++ )++ }++}@@@ -871,6 +871,52 @@ select:disabled {.dock-body.on {display: block;}++/* The Store tab (assets/storePanel.ts): the asset store's builds, and sharing the selection. */++.store-list {++ display: grid;++ gap: 4px;++ margin: 4px 0;++}++.store-row {++ display: flex;++ align-items: center;++ justify-content: space-between;++ gap: 8px;++}++.store-what {++ min-width: 0;++ overflow: hidden;++ text-overflow: ellipsis;++ white-space: nowrap;++}++.store-what small {++ color: #9aa6b2;++}++.store-share {++ display: grid;++ gap: 4px;++ margin-top: 8px;++ padding-top: 8px;++ border-top: 1px solid #2a3340;++}++.store-share input {++ background: #11161c;++ color: #dde3ea;++ border: 1px solid #2a3340;++ border-radius: 6px;++ padding: 4px 6px;++}++.store-share .mini {++ justify-self: start;++}++.store-hint,++.store-status {++ margin: 2px 0;++ color: #9aa6b2;++}++.store-status:empty {++ display: none;++}.asset-tabs {display: flex;gap: 4px;@@@ -22,10 +22,14 @@ import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'import {createContent,emptyMapV2,++ CREATOR_ASSET_ID,parseMapFile,++ placePrefab,++ prefabFromObjects,setMapOverride,type MapFileV2,type MapTextureEntry,++ type PrefabBody,} from '@openvibe/content'import { Environment } from '../render/environment.js'import { registerCustomTextures } from '../render/mapStyle.js'@@@ -78,6 +82,7 @@ import { ActionRouter } from './input/actionRouter.js'import { ACTIONS, loadBindings, type Binding } from './bindings.js'import { editorPerf } from './perf/editorProfiler.js'import { installEditorProbe } from './devProbe.js'++import { StorePanel } from './assets/storePanel.js'const content = createContent()@@@ -861,6 +866,75 @@ export async function bootEditor(): Promise<void> {},})++ // ── The asset store (Store tab, /editor?asset=<id>) ─────────────────++ /** Where the camera looks: what the editor picks at the middle of the view, else the ground ahead. */++ const lookPoint = (): [number, number, number] => {++ const hit = scene.pick(engine.getRenderWidth() / 2, engine.getRenderHeight() / 2, (m) =>++ pickable(m),++ )++ if (hit?.hit && hit.pickedPoint)++ return [hit.pickedPoint.x, hit.pickedPoint.y, hit.pickedPoint.z]++ const dir = camera.getDirection(Vector3.Forward())++ const along = dir.y < -0.05 ? -camera.position.y / dir.y : -1++ const p = camera.position.add(dir.scale(along > 0 && along < 400 ? along : 18))++ return [p.x, along > 0 && along < 400 ? 0 : Math.max(0, p.y), p.z]++ }++ /** A build from the store, with new ids, where the camera looks: one undo step, then selected. */++ const placeBuild = (body: PrefabBody, title: string): void => {++ const [x, y, z] = lookPoint()++ const snap = (v: number) => Math.round(v * 2) / 2++ const placed = placePrefab(body, [snap(x), y, snap(z)], newId)++ if (placed.length === 0) return++ history.apply({++ label: `place ${title}`,++ execute: (d) => placed.forEach((m) => d.add(m.kind, m.object as never)),++ undo: (d) => placed.forEach((m) => d.remove(m.object.id)),++ })++ selection.replaceMany(placed.map((m) => m.object.id))++ ui.refreshAll()++ ui.setMessage(++ `🧱 "${title}" placed (${placed.length} pieces) — move it with the gizmo, Save to keep it`,++ )++ }++ const store = new StorePanel(shell.dockPanels['store']!, {++ worldId: endpoints.worldId,++ token: editorToken,++ place: placeBuild,++ selectionBuild: () => {++ const pick = <K extends 'static' | 'node' | 'prop' | 'light'>(kind: K) =>++ selection++ .ids()++ .filter((id) => doc.typeOf(id) === kind)++ .map((id) => doc.snapshot(id))++ .filter((o): o is NonNullable<typeof o> => o !== null) as never[]++ const body = prefabFromObjects({++ statics: pick('static'),++ nodes: pick('node'),++ props: pick('prop'),++ lights: pick('light'),++ })++ const pieces =++ body.statics.length + body.nodes.length + body.props.length + body.lights.length++ return pieces++ ? { body, pieces }++ : {++ error:++ 'Select the boxes, nodes, props and lights to share first (terrain and zones stay in your world).',++ }++ },++ setMessage: (m) => ui.setMessage(m),++ })++ store.show()++ /** /editor?world=<id>&asset=<id> (the store's "Use in a world"): places that build once the map is ready. */++ const placeAssetFromUrl = async (): Promise<void> => {++ const params = new URLSearchParams(location.search)++ const asset = params.get('asset')++ if (!asset || !CREATOR_ASSET_ID.test(asset)) return++ params.delete('asset')++ window.history.replaceState(null, '', `${location.pathname}?${params.toString()}`)++ await store.use(asset)++ }++// ── Actions ─────────────────────────────────────────────────────────const router = new ActionRouter(ACTIONS, bindingOf, (id) => runAction(id))@@@ -1010,7 +1084,7 @@ export async function bootEditor(): Promise<void> {// Offer any unsaved work from a previous session. Never automatic: a// crash recovering itself into everyone else's world would be worse than// losing it, so this restores locally and leaves the map dirty.-- void saveController.restoreDraftIfAny()++ void saveController.restoreDraftIfAny().then(() => placeAssetFromUrl())// ── Probe (harness only; NOT a public API) ──────────────────────────installEditorProbe({@@@ -267,6 +267,7 @@ export function buildShell(const dockPanels: Record<string, HTMLElement> = {}for (const [id, title] of [['assets', 'Assets'],++ ['store', 'Store'],['issues', 'Issues'],['history', 'History'],['scene', 'Scene'],@@@ -4,6 +4,7 @@ export {}* /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete, remix). A remix* copies a published world into a new draft of yours, credited (owner, 2026-10-27: "the same way you can copy our* game"); /worlds?remix=<id> (a world page's Remix link) makes one and opens it in the editor, after sign-in if needed.++ * The asset store (migration 0016) lists the builds and scripts creators share; using one puts it in a world you build.*/interface World {@@@ -321,7 +322,11 @@ async function openScripts(world: World): Promise<void> {card.remove()rows.splice(rows.indexOf(read), 1)})-- card.append(remove)++ const share = button(++ 'Share in the asset store',++ (b) => void shareScript(world, read(), b, status),++ )++ card.append(el('div', { className: 'row' }, remove, share))rows.push(read)list.append(card)}@@@ -617,8 +622,10 @@ async function loadMine(): Promise<void> {try {const mine = await api<Mine>('/api/worlds/mine')staff = mine.staff++ myWorlds = [...mine.worlds, ...(mine.team ?? [])]signedOut.hidden = truesignedIn.hidden = false++ yoursButton.hidden = falsemineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))const full = mine.worlds.length >= mine.maxcreateButton.disabled = full@@@ -654,6 +661,285 @@ async function loadPublished(reset = false): Promise<void> {}}++// ── The asset store (migration 0016): builds and scripts creators share for anyone's worlds ──++interface Asset {++ id: string++ kind: 'prefab' | 'script'++ title: string++ summary: string++ owner: string++ uses: number++ contents: { pieces?: number; hooks?: string[]; capabilities?: string[] }++ from_world: { id: string; title: string; url: string } | null++ created_at: string++ /** Only on your own (and to staff). */++ status?: 'published' | 'hidden'++}++++const assetsGrid = $('assets')++const assetsStatus = $('assets-status')++const assetsMoreRow = $('assets-more-row')++const assetsMore = $<HTMLButtonElement>('assets-more')++const kindButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('.kinds button'))++const yoursButton = kindButtons.find((b) => b.dataset['kind'] === 'mine') as HTMLButtonElement++let assetKind = 'all'++let assetPage = 1++/** The worlds you build (yours and your teams'): where an asset can go. */++let myWorlds: World[] = []++++function assetStats(asset: Asset): string {++ const used = asset.uses ? `used by ${count(asset.uses, 'person', 'people')}` : 'not used yet'++ if (asset.kind === 'prefab')++ return `${count(asset.contents.pieces ?? 0, 'piece', 'pieces')} · ${used}`++ const hooks = (asset.contents.hooks ?? []).join(', ')++ return `${hooks || 'no hooks'} · ${used}`++}++++function assetCard(asset: Asset, own: boolean): HTMLElement {++ const hidden = asset.status === 'hidden'++ const card = el(++ 'article',++ { className: 'card' },++ el('span', {++ className: `badge ${hidden ? 'hidden' : asset.kind}`,++ text: hidden ? 'Taken down' : asset.kind === 'prefab' ? 'Build' : 'Script',++ }),++ el('h3', { text: asset.title }),++ el('p', { className: 'by', text: `by ${asset.owner}` }),++ )++ if (asset.from_world) {++ const from = el('p', { className: 'stats' }, document.createTextNode('from '))++ from.append(link(asset.from_world.title, asset.from_world.url, false, 'title'))++ card.append(from)++ }++ if (asset.summary) card.append(el('p', { className: 'summary', text: asset.summary }))++ card.append(el('p', { className: 'stats', text: assetStats(asset) }))++ const actions = el('div', { className: 'actions' })++ if (!hidden) {++ if (token())++ actions.append(++ button(++ asset.kind === 'prefab' ? 'Use in a world' : 'Add to a world',++ () => void useAsset(asset),++ 'primary',++ ),++ )++ else actions.append(link('Sign in to use it', '/auth/login?next=%2Fworlds', true))++ }++ if (own)++ actions.append(++ button('Rename', (b) => {++ const title = prompt('New title (up to 60 characters)', asset.title)++ if (title !== null && title.trim() && title !== asset.title)++ void changeAsset(b, asset.id, { title })++ }),++ button(++ 'Delete',++ (b) =>++ void (async () => {++ if (++ !confirm(++ `Delete “${asset.title}” from the asset store? Worlds that use it keep their copy.`,++ )++ )++ return++ b.disabled = true++ try {++ await api(`/api/assets/${asset.id}`, { method: 'DELETE' })++ await loadAssets(true)++ } catch (err) {++ b.disabled = false++ alert(err instanceof Error ? err.message : 'That did not work. Try again.')++ }++ })(),++ 'danger',++ ),++ )++ else if (staff)++ actions.append(++ hidden++ ? button('Put back', (b) => void changeAsset(b, asset.id, { status: 'published' }))++ : button(++ 'Take down',++ (b) =>++ void changeAsset(++ b,++ asset.id,++ { status: 'hidden' },++ 'Take this asset down for everyone?',++ ),++ 'danger',++ ),++ )++ card.append(actions)++ return card++}++++async function changeAsset(++ b: HTMLButtonElement,++ id: string,++ patch: Record<string, string>,++ confirmText?: string,++): Promise<void> {++ if (confirmText && !confirm(confirmText)) return++ b.disabled = true++ try {++ await api(`/api/assets/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })++ await loadAssets(true)++ } catch (err) {++ b.disabled = false++ alert(err instanceof Error ? err.message : 'That did not work. Try again.')++ }++}++++/** Which of your worlds an asset goes into: a small list to pick from. */++function pickWorld(asset: Asset): Promise<World | null> {++ return new Promise((resolve) => {++ const dialog = el('dialog', { className: 'scripts' })++ let chosen: World | null = null++ const list = el('div')++ for (const world of myWorlds)++ list.append(++ el(++ 'div',++ { className: 'row pick' },++ el('span', { text: world.role === 'editor' ? `${world.title} (team)` : world.title }),++ button(++ asset.kind === 'prefab' ? 'Open in the editor' : 'Add here',++ () => {++ chosen = world++ dialog.close()++ },++ 'primary',++ ),++ ),++ )++ dialog.append(++ el('h2', {++ text: asset.kind === 'prefab' ? `Use “${asset.title}” in…` : `Add “${asset.title}” to…`,++ }),++ myWorlds.length++ ? list++ : el('p', { className: 'status', text: 'Make a world first (above), then come back.' }),++ el(++ 'div',++ { className: 'row' },++ button('Cancel', () => dialog.close()),++ ),++ )++ dialog.addEventListener('close', () => {++ dialog.remove()++ resolve(chosen)++ })++ document.body.append(dialog)++ dialog.showModal()++ })++}++++/** A build opens in the editor of the world you pick (it lands where you look); a script joins that world's scripts. */++async function useAsset(asset: Asset): Promise<void> {++ const world = await pickWorld(asset)++ if (!world) return++ if (asset.kind === 'prefab') {++ location.href = `/editor?world=${world.id}&asset=${asset.id}`++ return++ }++ assetsStatus.textContent = `Adding “${asset.title}” to ${world.title}…`++ try {++ const current = await api<{ scripts: WorldScript[]; limits: { mods: number } }>(++ `/api/worlds/${world.id}/scripts`,++ )++ if (current.scripts.length >= current.limits.mods) {++ assetsStatus.textContent = `${world.title} already has ${current.limits.mods} scripts, the most a world takes. Remove one in its Scripts first.`++ return++ }++ const used = await api<{ body: WorldScript }>(`/api/assets/${asset.id}/use`, { method: 'POST' })++ // Two scripts in a world never share a name: a second "welcome" becomes "welcome-2".++ const taken = new Set(current.scripts.map((s) => s.id))++ let id = used.body.id++ for (let n = 2; taken.has(id); n++) id = `${used.body.id.slice(0, 36)}-${n}`++ const script = { ...used.body, id, entry: `${id}.js` }++ await api(`/api/worlds/${world.id}/scripts`, {++ method: 'PUT',++ body: JSON.stringify({ scripts: [...current.scripts, script] }),++ })++ await loadAssets(true)++ assetsStatus.textContent = `Added “${asset.title}” to ${world.title} as the script “${id}”. Change it in that world's Scripts.`++ } catch (err) {++ assetsStatus.textContent = err instanceof Error ? err.message : 'That did not work.'++ }++}++++/** A world's script shared in the asset store, credited to the world. */++async function shareScript(++ world: World,++ script: WorldScript,++ b: HTMLButtonElement,++ status: HTMLElement,++): Promise<void> {++ const title = prompt('Its title in the asset store (up to 60 characters)', script.id)++ if (title === null || !title.trim()) return++ b.disabled = true++ status.textContent = 'Sharing…'++ try {++ await api('/api/assets', {++ method: 'POST',++ body: JSON.stringify({++ kind: 'script',++ title,++ body: { ...script, entry: `${script.id || 'main'}.js` },++ from_world: world.id,++ }),++ })++ status.textContent = `Shared “${title.trim()}” in the asset store.`++ void loadAssets(true)++ } catch (err) {++ status.textContent = err instanceof Error ? err.message : 'That did not work.'++ } finally {++ b.disabled = false++ }++}++++async function loadAssets(reset = false): Promise<void> {++ if (reset) assetPage = 1++ try {++ if (assetKind === 'mine') {++ const data = await api<{ assets: Asset[]; max: number }>('/api/assets/mine')++ assetsGrid.replaceChildren(...data.assets.map((a) => assetCard(a, true)))++ assetsMoreRow.hidden = true++ assetsStatus.textContent = data.assets.length++ ? ''++ : 'You have not shared anything yet. Select pieces in the editor and open Store, or share a script from a world.'++ return++ }++ const kind = assetKind === 'all' ? '' : `&kind=${assetKind}`++ const data = await api<{ assets: Asset[]; more: boolean }>(++ `/api/assets?page=${assetPage}${kind}`,++ )++ const cards = data.assets.map((a) => assetCard(a, false))++ if (assetPage === 1) assetsGrid.replaceChildren(...cards)++ else assetsGrid.append(...cards)++ assetsMoreRow.hidden = !data.more++ assetsStatus.textContent =++ assetPage === 1 && data.assets.length === 0++ ? 'Nothing shared here yet. Yours could be the first.'++ : ''++ } catch {++ assetsStatus.textContent = 'The asset store could not be loaded. Refresh to try again.'++ }++}++++for (const b of kindButtons)++ b.addEventListener('click', () => {++ assetKind = b.dataset['kind'] ?? 'all'++ for (const other of kindButtons) other.setAttribute('aria-pressed', String(other === b))++ void loadAssets(true)++ })++assetsMore.addEventListener('click', () => {++ assetPage++++ void loadAssets()++})++createForm.addEventListener('submit', (event) => {event.preventDefault()void (async () => {@@@ -708,5 +994,5 @@ void loadMine().then(() => {'Sign in with your OpenVibe account to remix that world into one of your own.'}}-- return loadPublished(true)++ return Promise.all([loadPublished(true), loadAssets(true)])})@@@ -42,7 +42,9 @@}/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a lightpage read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */-- :root[data-theme-mode="light"] { --secondary: #0e7490; }++ :root[data-theme-mode='light'] {++ --secondary: #0e7490;++ }* {box-sizing: border-box;}@@@ -355,6 +357,30 @@font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 13px;}++ /* The asset store (worlds.ts loadAssets). */++ .badge.prefab {++ color: var(--good);++ border-color: rgba(52, 211, 153, 0.45);++ }++ .badge.script {++ color: var(--accent-light);++ border-color: var(--accent-light);++ }++ .kinds button[aria-pressed='true'] {++ border-color: var(--accent-light);++ color: var(--accent-light);++ }++ #assets {++ margin-top: 14px;++ }++ #assets-more-row {++ margin-top: 14px;++ }++ .row.pick {++ justify-content: space-between;++ padding: 6px 0;++ border-bottom: 1px solid var(--border);++ }</style><script type="module" src="/src/pages/worlds.ts"></script></head>@@@ -412,6 +438,24 @@<button type="button" id="more">More worlds</button></div>++ <h2 id="assets-title">Asset store</h2>++ <p class="lede">++ Builds and scripts other creators share, free. Use one in a world you build: a build lands++ where you look in the editor, a script joins the world's scripts. Share your own from the++ editor (select pieces, then Store) or from a world's Scripts.++ </p>++ <div class="row kinds" role="group" aria-label="Show">++ <button type="button" data-kind="all" aria-pressed="true">Everything</button>++ <button type="button" data-kind="prefab" aria-pressed="false">Builds</button>++ <button type="button" data-kind="script" aria-pressed="false">Scripts</button>++ <button type="button" data-kind="mine" aria-pressed="false" hidden>Yours</button>++ </div>++ <div class="grid" id="assets" aria-labelledby="assets-title"></div>++ <p class="status" id="assets-status" role="status">Loading assets…</p>++ <div class="row" id="assets-more-row" hidden>++ <button type="button" id="assets-more">More assets</button>++ </div>++<p class="note">Creator worlds use the editor's built-in materials for now; uploaded textures and modelsstay with staff maps. Characters are not saved inside someone's world, so nothing earned@@@ -75,6 +75,8 @@ export interface ServerConfig {creatorWorldsMax: number/** GAMES_CREATOR_WORLDS_PER_PERSON: worlds one person can keep. */creatorWorldsPerPerson: number++ /** GAMES_CREATOR_ASSETS_PER_PERSON: builds and scripts one person keeps in the asset store. */++ creatorAssetsPerPerson: number/** GAMES_CREATOR_WORLD_IDLE_SECONDS: an empty creator world stops after this long. */creatorWorldIdleSeconds: number/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */@@@ -232,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {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),++ creatorAssetsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_ASSETS_PER_PERSON', 50),creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),creatorScripts: env.GAMES_CREATOR_SCRIPTS === '1' || env.GAMES_CREATOR_SCRIPTS === 'true',@@@ -17,6 +17,7 @@ import {import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'import {InstanceRegistry,++ createCreatorAssetStore,createCreatorWorldStore,openPgStore,type InstanceLease,@@@ -89,6 +90,7 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'import { CreatorWorldHost } from './worlds/worldHost.js'import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'import { createWorldRoutes } from './worlds/worldRoutes.js'++import { createAssetRoutes } from './worlds/assetRoutes.js'import { createWorldPage } from './worlds/worldPage.js'import { createSteamCatalog } from './steam/steamCatalog.js'import { createSteamRoutes } from './steam/steamPage.js'@@@ -843,6 +845,20 @@ async function main(): Promise<void> {events: { db, sink, siteUrl: gamesSiteUrl },...(worldSearch ? { search: worldSearch } : {}),})++ // The asset store's JSON API (worlds/assetRoutes.ts): builds and scripts creators publish for others' worlds.++ const serveAssets = createAssetRoutes({++ store: createCreatorAssetStore(db),++ worlds: creatorWorlds,++ resolveUser: (token) =>++ config.networkAuthUrl++ ? resolveNetworkUser(config.networkAuthUrl, token)++ : Promise.resolve(null),++ limits: actorLimits,++ address: clientAddress,++ log: log.child({ system: 'assets' }),++ perPerson: config.creatorAssetsPerPerson,++ events: { db, sink },++ })// The home page's live sections (net/portalHome.ts): players online, Play's boards and achievements, Vibe Heroes'// furthest teams, creator worlds and Steam's lists, built at most once a minute.const playTitlesUrl = config.platform.playUrl@@@ -986,6 +1002,7 @@ async function main(): Promise<void> {}if (serveCasual(req, res)) return trueif (serveWorlds(req, res)) return true++ if (serveAssets(req, res)) return trueif (serveWorldPage(req, res)) return trueif (serveSteam(req, res)) return trueif (@@@ -119,6 +119,7 @@ describe('account export and deletion (ADR-033)', () => {identity_rows: 0,casual_saves: 0,creator_worlds: 0,++ creator_assets: 0,})expect(conf.body.retained).toEqual({ structures_unowned: 0 })expect(await data.apply(del)).toBe('unchanged')nnew file mode 100644@@@ -0,0 +1,268 @@++import { createServer, type Server } from 'node:http'++import type { AddressInfo } from 'node:net'++import { createRequire } from 'node:module'++import { emptyMapV2 } from '@openvibe/content'++import { createCreatorAssetStore, createCreatorWorldStore } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import { createConsoleLogger } from '@openvibe/shared'++import type { EventInput } from 'openvibe-sdk/events'++import { afterEach, describe, expect, it } from 'vitest'++import { createGamesActorLimits } from '../net/actorLimits.js'++import type { NetworkUser } from '../net/networkAuth.js'++import { createAssetRoutes } from './assetRoutes.js'++++const { validate } = createRequire(import.meta.url)('openvibe-contracts') as {++ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }++}++++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 },++}++++const BUILD = {++ statics: [++ {++ id: 'st-1',++ shape: { type: 'box', size: [2, 2, 2] },++ pos: [-1, 0, 0],++ yaw: 0,++ color: '#8a8d90',++ },++ {++ id: 'st-2',++ shape: { type: 'box', size: [2, 2, 2] },++ pos: [1, 0, 0],++ yaw: 0,++ color: '#9a9187',++ },++ ],++ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [0, 0, 3] }],++ props: [],++ lights: [{ id: 'li-1', type: 'point', pos: [0, 4, 0] }],++}++const SCRIPT = {++ format: 'games-quickjs@1',++ id: 'welcome',++ version: '1.0.0',++ entry: 'welcome.js',++ source: "function onPlayerJoin(e) { game.message(e.player.id, 'Welcome!') }",++ hooks: ['onPlayerJoin'],++ capabilities: ['players.read', 'player.message'],++}++++let server: Server | null = null++afterEach(async () => {++ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()))++ server = null++})++++async function setup(perPerson = 3) {++ const db = await openTestDb()++ const worlds = createCreatorWorldStore(db)++ const events: EventInput[] = []++ const handler = createAssetRoutes({++ store: createCreatorAssetStore(db),++ worlds,++ resolveUser: async (token) => USERS[token] ?? null,++ limits: createGamesActorLimits({ env: {} }),++ address: () => '127.0.0.1',++ log: createConsoleLogger({ app: 'asset-routes-test' }, 'error'),++ perPerson,++ events: {++ db,++ sink: {++ enabled: true,++ enqueue: async (_t, event) => {++ expect(db.inTransaction()).toBe(true)++ events.push(event)++ },++ },++ },++ })++ 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) => {++ const resp = await fetch(`${base}${path}`, {++ method,++ headers: {++ ...(as ? { authorization: `Bearer ${as}` } : {}),++ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),++ },++ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),++ })++ const text = await resp.text()++ return {++ status: resp.status,++ body: (text ? JSON.parse(text) : null) as Record<string, unknown> & {++ assets?: Record<string, unknown>[]++ issues?: string[]++ },++ }++ }++ return { db, worlds, events, call }++}++++describe('asset store routes', () => {++ it('publishes a build and a script, lists them by kind, and keeps bodies out of the list', async () => {++ const { worlds, call } = await setup()++ const world = await worlds.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Moon base',++ summary: '',++ map: emptyMapV2(),++ now: 1,++ })++ expect(++ (await call('POST', '/api/assets', undefined, { kind: 'prefab', title: 'x', body: BUILD }))++ .status,++ ).toBe(401)++ expect(++ (await call('POST', '/api/assets', 'guest', { kind: 'prefab', title: 'x', body: BUILD }))++ .status,++ ).toBe(401)++ const build = await call('POST', '/api/assets', 'kim', {++ kind: 'prefab',++ title: ' Two crates and a tree ',++ summary: 'A camp corner',++ body: BUILD,++ from_world: world.id,++ })++ expect(build.status).toBe(201)++ expect(build.body).toMatchObject({++ kind: 'prefab',++ title: 'Two crates and a tree',++ owner: 'kim',++ status: 'published',++ uses: 0,++ contents: { statics: 2, nodes: 1, props: 0, lights: 1, pieces: 4 },++ from_world: { id: world.id, title: 'Moon base', url: `/worlds/${world.id}` },++ })++ expect(build.body.id).toMatch(/^a[0-9a-z]{10}$/)++ // A credit names a world you build: someone else's is refused.++ expect(++ (++ await call('POST', '/api/assets', 'lee', {++ kind: 'script',++ title: 'Hi',++ body: SCRIPT,++ from_world: world.id,++ })++ ).body.error,++ ).toBe('invalid_world')++ const script = await call('POST', '/api/assets', 'lee', {++ kind: 'script',++ title: 'Welcome',++ body: SCRIPT,++ })++ expect(script.status).toBe(201)++ expect(script.body.contents).toEqual({++ hooks: ['onPlayerJoin'],++ capabilities: ['players.read', 'player.message'],++ })++++ const all = await call('GET', '/api/assets')++ expect(all.body.assets?.map((a) => a.title)).toEqual(['Welcome', 'Two crates and a tree'])++ expect(all.body.assets?.[0]).not.toHaveProperty('body')++ expect(all.body.assets?.[0]).not.toHaveProperty('status')++ expect((await call('GET', '/api/assets?kind=prefab')).body.assets?.map((a) => a.kind)).toEqual([++ 'prefab',++ ])++ expect((await call('GET', '/api/assets?kind=sound')).status).toBe(400)++ const one = await call('GET', `/api/assets/${String(build.body.id)}`)++ expect((one.body.body as { statics: unknown[] }).statics).toHaveLength(2)++ const mine = await call('GET', '/api/assets/mine', 'kim')++ expect(mine.body).toMatchObject({ max: 3, staff: false })++ expect(mine.body.assets?.map((a) => a.status)).toEqual(['published'])++ })++++ it('refuses what a creator world would refuse, and past the per-person limit', async () => {++ const { call } = await setup(1)++ const model = await call('POST', '/api/assets', 'kim', {++ kind: 'prefab',++ title: 'Statue',++ body: { statics: [{ ...BUILD.statics[0], model: 'm1' }] },++ })++ expect(model.status).toBe(422)++ expect(model.body.error).toBe('asset_invalid')++ const badScript = await call('POST', '/api/assets', 'kim', {++ kind: 'script',++ title: 'Bad',++ body: { ...SCRIPT, capabilities: ['network.fetch'] },++ })++ expect(badScript.status).toBe(422)++ expect(++ (await call('POST', '/api/assets', 'kim', { kind: 'sound', title: 'Boom', body: {} })).body++ .issues,++ ).toEqual(['kind: prefab or script'])++ expect(++ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: '', body: BUILD })).body++ .error,++ ).toBe('invalid_title')++ expect(++ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: 'One', body: BUILD }))++ .status,++ ).toBe(201)++ const full = await call('POST', '/api/assets', 'kim', {++ kind: 'prefab',++ title: 'Two',++ body: BUILD,++ })++ expect(full.status).toBe(409)++ expect(full.body.error).toBe('asset_limit')++ })++++ it('counts a use once per person and never its creator; staff hide and restore with an event; owner deletes', async () => {++ const { call, events } = await setup()++ const id = String(++ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: 'Camp', body: BUILD }))++ .body.id,++ )++ expect((await call('POST', `/api/assets/${id}/use`)).status).toBe(401)++ const used = await call('POST', `/api/assets/${id}/use`, 'lee')++ expect(used.status).toBe(200)++ expect(used.body.uses).toBe(1)++ expect((used.body.body as { nodes: unknown[] }).nodes).toHaveLength(1)++ expect((await call('POST', `/api/assets/${id}/use`, 'lee')).body.uses).toBe(1)++ expect((await call('POST', `/api/assets/${id}/use`, 'kim')).body.uses).toBe(1)++++ // The creator renames it; nobody else may, and the creator cannot hide or unhide it.++ expect(++ (await call('PATCH', `/api/assets/${id}`, 'kim', { title: 'Camp corner' })).body.title,++ ).toBe('Camp corner')++ expect((await call('PATCH', `/api/assets/${id}`, 'lee', { title: 'Mine now' })).status).toBe(++ 404,++ )++ expect((await call('PATCH', `/api/assets/${id}`, 'kim', { status: 'hidden' })).status).toBe(403)++++ const hidden = await call('PATCH', `/api/assets/${id}`, 'staff', { status: 'hidden' })++ expect(hidden.body.status).toBe('hidden')++ expect(events.map((e) => (e.payload as { action: string }).action)).toEqual(['asset.hidden'])++ expect(validate('games.moderation.action@1', events[0]?.payload).valid).toBe(true)++ expect((await call('GET', '/api/assets')).body.assets).toEqual([])++ expect((await call('GET', `/api/assets/${id}`)).status).toBe(404)++ expect((await call('POST', `/api/assets/${id}/use`, 'lee')).status).toBe(404)++ expect((await call('GET', `/api/assets/${id}`, 'kim')).body.status).toBe('hidden')++ await call('PATCH', `/api/assets/${id}`, 'staff', { status: 'published' })++ expect(events.map((e) => (e.payload as { action: string }).action)).toEqual([++ 'asset.hidden',++ 'asset.restored',++ ])++++ expect((await call('DELETE', `/api/assets/${id}`, 'lee')).status).toBe(404)++ expect((await call('DELETE', `/api/assets/${id}`, 'kim')).status).toBe(204)++ expect((await call('GET', `/api/assets/${id}`)).status).toBe(404)++ })++})nnew file mode 100644@@@ -0,0 +1,353 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import {++ CREATOR_ASSET_ID,++ CREATOR_ASSET_LIMITS,++ CREATOR_SCRIPT_LIMITS,++ CREATOR_WORLD_ID,++ creatorAssetOf,++ prefabCounts,++ type PrefabBody,++ type ScriptMod,++} from '@openvibe/content'++import type {++ CreatorAsset,++ CreatorAssetKind,++ CreatorAssetStatus,++ CreatorAssetStore,++ CreatorWorldStore,++} from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { Db } from 'openvibe-sdk/db'++import { actorFor, moderationEvent, type EventSink } from '../platform/gameEvents.js'++import type { GamesActorLimits } from '../net/actorLimits.js'++import type { NetworkUser } from '../net/networkAuth.js'++import {++ RouteError,++ cleanText,++ fail,++ isGamesStaff,++ isStaff,++ parseJson,++ readBody,++} from './worldRoutes.js'++++/**++ * /api/assets: the asset store (migration 0016; docs/platform-direction.md step 5, free first). Builds (pieces placed++ * in the editor) and scripts (one games-quickjs@1 mod) that a signed-in creator publishes for anyone to use in their++ * own worlds. Anyone reads the store; using an asset counts once per person; staff hide one.++ *++ * GET /api/assets[?kind=prefab|script&page=] published assets, newest first (no bodies)++ * GET /api/assets/mine the caller's assets++ * POST /api/assets { kind, title, summary?, body, from_world? } publish (from_world: a world you build, credited)++ * GET /api/assets/:id one asset with its body (published, or yours, or staff)++ * POST /api/assets/:id/use its body, and the caller's use counted once++ * PATCH /api/assets/:id { title?, summary?, status? } owner: title, summary; staff: hidden|published++ * DELETE /api/assets/:id the owner or games staff++ */++++export interface AssetRouteOptions {++ store: CreatorAssetStore++ /** To credit the world a build or script came from (one the caller builds). */++ worlds: CreatorWorldStore++ resolveUser: (token: string) => Promise<NetworkUser | null>++ limits: GamesActorLimits++ address: (req: IncomingMessage) => string++ log: Logger++ perPerson: number++ /** games.moderation.action when staff hide or restore someone else's asset, in the same transaction. */++ events?: { db: Db; sink: EventSink }++ now?: () => number++}++++const PAGE_SIZE = 24++const META_BYTES = 16 * 1024++const TITLE_MAX = 60++const SUMMARY_MAX = 280++++/** What a card shows: a build's pieces, or a script's hooks and what it may do. */++function contents(asset: CreatorAsset) {++ if (asset.kind === 'prefab') return prefabCounts(asset.body as PrefabBody)++ const mod = asset.body as ScriptMod++ return { hooks: mod.hooks ?? [], capabilities: mod.capabilities ?? [] }++}++++function publicView(asset: CreatorAsset) {++ return {++ id: asset.id,++ kind: asset.kind,++ title: asset.title,++ summary: asset.summary,++ owner: asset.ownerName,++ uses: asset.uses,++ contents: contents(asset),++ from_world: asset.fromWorld++ ? {++ id: asset.fromWorld.id,++ title: asset.fromWorld.title,++ url: `/worlds/${asset.fromWorld.id}`,++ }++ : null,++ created_at: new Date(asset.createdAt).toISOString(),++ updated_at: new Date(asset.updatedAt).toISOString(),++ }++}++++function ownerView(asset: CreatorAsset) {++ return { ...publicView(asset), status: asset.status }++}++++export function createAssetRoutes(opts: AssetRouteOptions) {++ const now = opts.now ?? Date.now++++ const send = (res: ServerResponse, status: number, body: unknown) => {++ res.writeHead(status, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end(JSON.stringify(body))++ }++++ const caller = async (req: IncomingMessage): Promise<NetworkUser | null> => {++ const token = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null++ 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 }++ }++++ /** An asset the caller may see: published, or theirs, or staff. Anything else is not found. */++ const visible = async (id: string, user: NetworkUser | null): Promise<CreatorAsset> => {++ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null++ if (!asset) return fail(404, 'asset_not_found')++ if (asset.status === 'published' || isStaff(user) || asset.ownerSubject === user?.subjectId)++ return asset++ return fail(404, 'asset_not_found')++ }++++ const publicRead = async (req: IncomingMessage, res: ServerResponse): Promise<boolean> =>++ opts.limits.publicRead(req, res, `ip:${opts.address(req)}`)++++ async function route(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {++ const path = url.pathname++ const method = req.method ?? 'GET'++++ if (path === '/api/assets' && method === 'GET') {++ if (!(await publicRead(req, res))) return++ const page = Number(url.searchParams.get('page') ?? '1')++ const kind = url.searchParams.get('kind')++ if (!Number.isSafeInteger(page) || page < 1 || page > 1000) fail(400, 'invalid_query')++ if (kind !== null && kind !== 'prefab' && kind !== 'script') fail(400, 'invalid_query')++ const rows = await opts.store.listPublished(++ kind as CreatorAssetKind | null,++ PAGE_SIZE + 1,++ (page - 1) * PAGE_SIZE,++ )++ send(res, 200, {++ assets: rows.slice(0, PAGE_SIZE).map(publicView),++ page,++ more: rows.length > PAGE_SIZE,++ })++ return++ }++++ if (path === '/api/assets/mine' && method === 'GET') {++ const user = await signedIn(req)++ const assets = await opts.store.listByOwner(user.subjectId)++ send(res, 200, {++ assets: assets.map(ownerView),++ max: opts.perPerson,++ limits: { build: CREATOR_ASSET_LIMITS, script: CREATOR_SCRIPT_LIMITS },++ staff: isStaff(user),++ })++ return++ }++++ if (path === '/api/assets' && method === 'POST') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const raw = parseJson(++ (await readBody(req, CREATOR_ASSET_LIMITS.bytes + CREATOR_SCRIPT_LIMITS.bytes + META_BYTES))++ .text,++ ) as Record<string, unknown> | null++ const title = cleanText(raw?.title, TITLE_MAX)++ const summary = raw?.summary === undefined ? '' : cleanText(raw.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.` })++ const checked = creatorAssetOf(raw?.kind, raw?.body)++ if (!checked.kind || !checked.body)++ fail(422, 'asset_invalid', { issues: checked.issues.slice(0, 40) })++ // A credit names a world the caller builds (theirs or their team's), as it is now.++ let fromWorld: { id: string; title: string } | null = null++ if (raw?.from_world !== undefined && raw.from_world !== null) {++ const wid = String(raw.from_world)++ const world = CREATOR_WORLD_ID.test(wid) ? await opts.worlds.get(wid) : null++ const builds =++ !!world &&++ (world.ownerSubject === user.subjectId ||++ (await opts.worlds.isMember(world.id, user.subjectId)))++ if (!world || !builds)++ fail(422, 'invalid_world', { detail: 'Name one of the worlds you build.' })++ fromWorld = { id: (world as { id: string }).id, title: (world as { title: string }).title }++ }++ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)++ fail(409, 'asset_limit', {++ detail: `You can keep ${opts.perPerson} assets in the store. Delete one to publish another.`,++ })++ const asset = await opts.store.create({++ ownerSubject: user.subjectId,++ ownerName: user.name,++ kind: checked.kind as CreatorAssetKind,++ title: title as string,++ summary: summary as string,++ body: checked.body,++ fromWorld,++ now: now(),++ })++ void opts.store.rename(user.subjectId, user.name).catch(() => undefined)++ opts.log.info('creator asset published', { asset: asset.id, kind: asset.kind })++ send(res, 201, { ...ownerView(asset), body: asset.body })++ return++ }++++ const match = /^\/api\/assets\/([^/]+)(\/use)?$/.exec(path)++ if (!match) fail(404, 'not_found')++ const id = match?.[1] ?? ''++ const isUse = match?.[2] === '/use'++++ if (isUse && method === 'POST') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const asset = await visible(id, user)++ // Its creator using it is not a use; anyone else counts once.++ if (asset.ownerSubject !== user.subjectId)++ await opts.store.countUse(asset.id, user.subjectId, now())++ const fresh = (await opts.store.get(asset.id)) ?? asset++ send(res, 200, { ...publicView(fresh), body: fresh.body })++ return++ }++++ if (!isUse && method === 'GET') {++ const user = await caller(req)++ if (!user && !(await publicRead(req, res))) return++ const asset = await visible(id, user)++ const mine = user?.subjectId === asset.ownerSubject++ send(res, 200, {++ ...(mine || isStaff(user) ? ownerView(asset) : publicView(asset)),++ body: asset.body,++ })++ return++ }++++ if (!isUse && method === 'PATCH') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const raw = parseJson((await readBody(req, META_BYTES)).text) as Record<++ string,++ unknown++ > | null++ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null++ const mine = !!asset && asset.ownerSubject === user.subjectId++ if (!asset || (!mine && !isStaff(user))) return fail(404, 'asset_not_found')++ const patch: { title?: string; summary?: string; status?: CreatorAssetStatus } = {}++ if (raw?.title !== undefined) {++ if (!mine) fail(403, 'owner_only')++ const title = cleanText(raw.title, TITLE_MAX)++ if (!title)++ fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })++ patch.title = title as string++ }++ if (raw?.summary !== undefined) {++ if (!mine) fail(403, 'owner_only')++ const summary = cleanText(raw.summary, SUMMARY_MAX)++ if (summary === null)++ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })++ patch.summary = summary as string++ }++ if (raw?.status !== undefined) {++ // Staff take an asset down or put it back; its creator cannot bring back one staff took down.++ if (raw.status !== 'published' && raw.status !== 'hidden') fail(422, 'invalid_status')++ if (!isStaff(user)) fail(403, 'staff_only')++ patch.status = raw.status as CreatorAssetStatus++ }++ const events = opts.events?.sink.enabled ? opts.events : null++ const moderated = !mine && patch.status !== undefined && patch.status !== asset.status++ const apply = () => opts.store.update(id, patch, now())++ const updated =++ events && moderated++ ? await events.db.tx(async (t) => {++ const next = await apply()++ await events.sink.enqueue(++ t,++ moderationEvent(++ patch.status === 'hidden' ? 'asset.hidden' : 'asset.restored',++ { type: 'asset', id, ownerSubject: asset.ownerSubject },++ actorFor(user.subjectId),++ { details: { from: asset.status, to: patch.status, kind: asset.kind } },++ ),++ )++ return next++ })++ : await apply()++ if (!updated) fail(404, 'asset_not_found')++ if (moderated) opts.log.info('creator asset status', { asset: id, to: patch.status })++ send(res, 200, ownerView(updated as CreatorAsset))++ return++ }++++ if (!isUse && method === 'DELETE') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null++ if (!asset || (asset.ownerSubject !== user.subjectId && !isGamesStaff(user)))++ return fail(404, 'asset_not_found')++ const byStaff = asset.ownerSubject !== user.subjectId++ const events = opts.events?.sink.enabled ? opts.events : null++ if (events && byStaff)++ await events.db.tx(async (t) => {++ await opts.store.remove(id)++ await events.sink.enqueue(++ t,++ moderationEvent(++ 'asset.deleted',++ { type: 'asset', id, ownerSubject: asset.ownerSubject },++ actorFor(user.subjectId),++ { details: { kind: asset.kind } },++ ),++ )++ })++ else await opts.store.remove(id)++ opts.log.info('creator asset deleted', { asset: id, staff: byStaff })++ res.writeHead(204, { 'cache-control': 'no-store' })++ res.end()++ return++ }++++ fail(405, 'method_not_allowed')++ }++++ /** true when the request was /api/assets… (answered, or being answered). */++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const url = new URL(req.url ?? '/', 'http://games.local')++ if (url.pathname !== '/api/assets' && !url.pathname.startsWith('/api/assets/')) 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('assets request failed', {++ error: String((err as Error | undefined)?.message ?? err),++ })++ send(res, 500, { error: 'internal' })++ })++ return true++ }++}@@@ -91,13 +91,13 @@ type Problem = {revision?: string}--class RouteError extends Error {++export class RouteError extends Error {constructor(readonly problem: Problem) {super(problem.error)}}--const fail = (++export const fail = (status: number,error: string,extra: Omit<Problem, 'status' | 'error'> = {},@@@ -116,11 +116,11 @@ export function cleanText(value: unknown, max: number): string | null {return text.length > max ? null : text}--function isStaff(user: NetworkUser | null): boolean {++export function isStaff(user: NetworkUser | null): boolean {return user?.rank === 'owner' || user?.rank === 'admin' || user?.rank === 'moderator'}--function isGamesStaff(user: NetworkUser | null): boolean {++export function isGamesStaff(user: NetworkUser | null): boolean {return user?.rank === 'owner' || user?.rank === 'admin'}@@@ -168,7 +168,10 @@ export function parseIfMatch(header: string | string[] | undefined): string | nureturn value || null}--function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {++export function readBody(++ req: IncomingMessage,++ max: number,++): Promise<{ text: string; bytes: number }> {return new Promise((resolve, reject) => {const chunks: Buffer[] = []let size = 0@@@ -188,7 +191,7 @@ function readBody(req: IncomingMessage, max: number): Promise<{ text: string; by})}--function parseJson(text: string): unknown {++export function parseJson(text: string): unknown {try {return JSON.parse(text) as unknown} catch {@@@ -103,6 +103,19 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built inteam (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge anddeletion. `/api/worlds/mine` lists them as `team`.++- **Asset store:** builds and scripts creators share, free, for anyone's worlds (migration 0016; `/api/assets`,++ the **Asset store** section of `/worlds` and the editor's **Store** tab; up to `GAMES_CREATOR_ASSETS_PER_PERSON`++ (50) each). A **build** is pieces chosen in the editor (boxes and other statics, resource nodes, props and lights)++ with positions relative to the middle of their footprint at its lowest point (`prefabFromObjects`). It passes the++ same checks as a creator map, with smaller limits (`CREATOR_ASSET_LIMITS`: 256 KB, 400 statics, 100 nodes and++ props, 16 lights, within 256 m of its origin). A **script** is one `games-quickjs@1` mod checked as a world's own.++ Sharing credits the world it came from (one you build). Using a build opens the editor of a world you build at++ `/editor?world=<id>&asset=<id>`, which places it where the camera looks, with new ids, as one undo step. Using a++ script adds it to the world's scripts, renamed `…-2` when the name is taken, within the world's four. A use counts++ once per person (`POST /api/assets/:id/use`; never the creator's own). The creator renames or deletes an asset++ (worlds keep their copy); staff take one down (`asset.hidden`, `asset.restored`, `asset.deleted` as++ `games.moderation.action`). Assets and uses follow account export, merge and deletion. Uploaded models, textures++ and sounds wait for the Pics safety pipeline, and paid assets for the owner's prices.- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event@@@ -65,6 +65,8 @@ Already here:- Moderation: staff can switch a world's scripts off.4. **Teams.** A world gets editors besides its owner. The collaboration socket (`/editor-ws`, staff-only today) opens to a world's team.5. **The asset store.** Creators publish models, materials, sounds and scripts for others to use and remix, free first.++ - Done for builds and scripts (migration 0016): pieces placed in the editor and `games-quickjs@1` mods, shared from++ the editor's Store tab or a world's Scripts, used in any world you build (see `games.md` "Asset store").- Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.- Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.@@@ -23,6 +23,7 @@ export * from './defs/skills.js'export * from './defs/resources.js'export * from './packs/scraplandia.js'export * from './packs/creatorWorld.js'++export * from './packs/creatorAsset.js'import { ContentRegistry, type ContentDefs } from './registry.js'import { mergePackDefs, type ContentPackV2 } from './pack.js'nnew file mode 100644@@@ -0,0 +1,107 @@++import { describe, expect, it } from 'vitest'++import {++ CREATOR_ASSET_ID,++ CREATOR_ASSET_LIMITS,++ creatorAssetOf,++ creatorPrefabOf,++ creatorScriptAssetOf,++ placePrefab,++ prefabCounts,++ prefabFromObjects,++ type PrefabBody,++} from './creatorAsset.js'++++const box = (id: string, pos: [number, number, number]) => ({++ id,++ shape: { type: 'box' as const, size: [2, 2, 2] as [number, number, number] },++ pos,++ yaw: 0,++ color: '#8a8d90',++})++++const script = {++ format: 'games-quickjs@1',++ id: 'welcome',++ version: '1.0.0',++ entry: 'welcome.js',++ source: "function onPlayerJoin(e) { game.message(e.player.id, 'hi') }",++ hooks: ['onPlayerJoin'],++ capabilities: ['players.read', 'player.message'],++}++++describe('creator assets', () => {++ it('names assets like worlds, with an a', () => {++ expect(CREATOR_ASSET_ID.test('a0123456789')).toBe(true)++ expect(CREATOR_ASSET_ID.test('w0123456789')).toBe(false)++ expect(CREATOR_ASSET_ID.test('A0123456789')).toBe(false)++ })++++ it('makes a build from chosen pieces around the middle of their footprint, on its lowest point', () => {++ const body = prefabFromObjects({++ statics: [box('st-1', [10, 2, 20]), box('st-2', [14, 5, 24])],++ lights: [{ id: 'li-1', type: 'point', pos: [12, 8, 22] }],++ })++ expect(body.statics.map((s) => s.pos)).toEqual([++ [-2, 0, -2],++ [2, 3, 2],++ ])++ expect(body.lights[0]?.pos).toEqual([0, 6, 0])++ expect(body.statics[0]?.id).toBe('st-1')++ expect(prefabCounts(body)).toEqual({ statics: 2, nodes: 0, props: 0, lights: 1, pieces: 3 })++ })++++ it('checks a build as a creator world map: built-in pieces pass, uploads, paint and NPC-free rules hold', () => {++ const ok = creatorPrefabOf(prefabFromObjects({ statics: [box('st-1', [0, 0, 0])] }))++ expect(ok.issues).toEqual([])++ expect(ok.body?.v).toBe(1)++ const model = creatorPrefabOf({ statics: [{ ...box('st-1', [0, 0, 0]), model: 'm1' }] })++ expect(model.body).toBeNull()++ const painted = creatorPrefabOf({++ statics: [++ {++ ...box('st-1', [0, 0, 0]),++ surface: { base: {}, paint: { layers: [{ id: 'l', tex: 'red_brick', channel: 'r' }] } },++ },++ ],++ })++ expect(painted.body).toBeNull()++ expect(painted.issues.join(' ')).toMatch(/paint/)++ expect(creatorPrefabOf({}).issues).toEqual(['body: a build has at least one piece'])++ expect(creatorPrefabOf([]).issues[0]).toMatch(/an object/)++ })++++ it('refuses a build past its limits or reaching too far from its origin', () => {++ const many = Array.from({ length: CREATOR_ASSET_LIMITS.statics + 1 }, (_, i) =>++ box(`st-${i}`, [0, 0, 0]),++ )++ expect(creatorPrefabOf({ statics: many }).issues.join(' ')).toMatch(/statics: 401 is more/)++ const far = creatorPrefabOf({++ statics: [box('st-far', [CREATOR_ASSET_LIMITS.reach + 1, 0, 0])],++ })++ expect(far.issues.join(' ')).toMatch(/st-far: more than 256 m/)++ })++++ it('checks a script asset as a world script, and keeps it as written', () => {++ const ok = creatorScriptAssetOf(script)++ expect(ok.issues).toEqual([])++ expect(ok.body).toMatchObject({ id: 'welcome', hooks: ['onPlayerJoin'] })++ expect(ok.body && 'granted' in ok.body).toBe(false)++ expect(creatorScriptAssetOf({ ...script, hooks: ['onEverything'] }).body).toBeNull()++ expect(creatorAssetOf('script', script).kind).toBe('script')++ expect(creatorAssetOf('sound', {}).issues).toEqual(['kind: prefab or script'])++ })++++ it('places a build with new ids where it is put down', () => {++ const body: PrefabBody = prefabFromObjects({++ statics: [box('st-1', [0, 0, 0])],++ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [3, 0, 0] }],++ props: [{ id: 'pr-1', item: 'wooden_crate', pos: [0, 0, 3] }],++ })++ let n = 0++ const placed = placePrefab(body, [100, 1, -50], (prefix) => `${prefix}-new-${n++}`)++ expect(placed.map((p) => p.kind)).toEqual(['static', 'node', 'prop'])++ expect(placed.map((p) => p.object.id)).toEqual(['st-new-0', 'no-new-1', 'pr-new-2'])++ expect(placed[1]?.object.pos).toEqual([101.5, 1, -51.5])++ expect(body.statics[0]?.id).toBe('st-1')++ })++})nnew file mode 100644@@@ -0,0 +1,191 @@++import { emptyMapV2, parseMapFile, type MapFileV2 } from '../mapFileV2.js'++import { creatorMapIssues, creatorScriptsOf } from './creatorWorld.js'++import type { ScriptMod } from '../schema/scriptMod.js'++++/**++ * The asset store (docs/platform-direction.md step 5, free first): what creators publish for others to use in their++ * own worlds. Uploaded models, textures and sounds wait on the Pics safety pipeline, so an asset is made of what a++ * creator world already takes:++ *++ * - a **build** (`prefab`): pieces placed in the editor (statics, resource nodes, props and lights) with their++ * positions relative to the build's own origin, the centre of its footprint at ground level. It passes the same++ * checks as a creator world's map (built-in materials only, no models, textures or paint), with smaller limits.++ * - a **script** (`script`): one `games-quickjs@1` mod, checked exactly as a world's own scripts are.++ *++ * The server and the editor both use these: the server checks what it stores, the editor places a build with new++ * ids where the person points (placePrefab).++ */++++export type CreatorAssetKind = 'prefab' | 'script'++export const CREATOR_ASSET_KINDS: readonly CreatorAssetKind[] = ['prefab', 'script']++++/** A creator asset id: `a` and ten lower-case base-36 characters. */++export const CREATOR_ASSET_ID = /^a[0-9a-z]{10}$/++++export const CREATOR_ASSET_LIMITS = {++ /** A build's JSON. */++ bytes: 256 * 1024,++ statics: 400,++ nodes: 100,++ props: 100,++ lights: 16,++ /** How far a piece may sit from the build's origin, in metres. */++ reach: 256,++} as const++++/** The pieces of a build, positions relative to its origin. */++export interface PrefabBody {++ v: 1++ statics: MapFileV2['statics']++ nodes: MapFileV2['nodes']++ props: MapFileV2['props']++ lights: MapFileV2['lights']++}++++export type CreatorAssetBody = PrefabBody | ScriptMod++++/** What a build is made of, for lists and cards. */++export function prefabCounts(body: PrefabBody): {++ statics: number++ nodes: number++ props: number++ lights: number++ pieces: number++} {++ const statics = body.statics.length++ const nodes = body.nodes.length++ const props = body.props.length++ const lights = body.lights.length++ return { statics, nodes, props, lights, pieces: statics + nodes + props + lights }++}++++const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])++++/** A build, checked: the body to store (ids and positions as given), or the reasons it cannot be stored. */++export function creatorPrefabOf(raw: unknown): { body: PrefabBody | null; issues: string[] } {++ const r = (raw ?? {}) as Record<string, unknown>++ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))++ return {++ body: null,++ issues: ['body: a build is an object with statics, nodes, props and lights'],++ }++ const bytes = new TextEncoder().encode(JSON.stringify(raw)).length++ if (bytes > CREATOR_ASSET_LIMITS.bytes)++ return {++ body: null,++ issues: [`body: ${bytes} bytes is more than the ${CREATOR_ASSET_LIMITS.bytes} a build takes`],++ }++ // The same checks as a creator world's map: the pieces go into an empty one.++ const parsed = parseMapFile({++ ...emptyMapV2(),++ statics: list(r['statics']),++ nodes: list(r['nodes']),++ props: list(r['props']),++ lights: list(r['lights']),++ })++ if (!parsed.ok) return { body: null, issues: parsed.issues.slice(0, 40) }++ const map = parsed.map++ const issues = creatorMapIssues(map, bytes)++ const limit = (what: 'statics' | 'nodes' | 'props' | 'lights', count: number): void => {++ if (count > CREATOR_ASSET_LIMITS[what])++ issues.push(`${what}: ${count} is more than the ${CREATOR_ASSET_LIMITS[what]} a build takes`)++ }++ limit('statics', map.statics.length)++ limit('nodes', map.nodes.length)++ limit('props', map.props.length)++ limit('lights', map.lights.length)++ const body: PrefabBody = {++ v: 1,++ statics: map.statics,++ nodes: map.nodes,++ props: map.props,++ lights: map.lights,++ }++ if (prefabCounts(body).pieces === 0) issues.push('body: a build has at least one piece')++ const far = [...body.statics, ...body.nodes, ...body.props, ...body.lights].find((o) =>++ o.pos.some((v) => Math.abs(v) > CREATOR_ASSET_LIMITS.reach),++ )++ if (far)++ issues.push(++ `${far.id}: more than ${CREATOR_ASSET_LIMITS.reach} m from the build's origin (positions are relative to it)`,++ )++ return issues.length ? { body: null, issues } : { body, issues }++}++++/** A script asset, checked as a world's own script is: the mod to store, or the reasons it cannot be stored. */++export function creatorScriptAssetOf(raw: unknown): { body: ScriptMod | null; issues: string[] } {++ const checked = creatorScriptsOf([raw])++ if (checked.issues.length) return { body: null, issues: checked.issues }++ // Stored as the creator wrote it; a world that adds it sets `granted` when it loads.++ const { granted: _granted, ...mod } = checked.scripts[0] as ScriptMod++ return { body: mod as ScriptMod, issues: [] }++}++++/** Either kind, by name. */++export function creatorAssetOf(++ kind: unknown,++ raw: unknown,++): { kind: CreatorAssetKind | null; body: CreatorAssetBody | null; issues: string[] } {++ if (kind === 'prefab') return { kind, ...creatorPrefabOf(raw) }++ if (kind === 'script') return { kind, ...creatorScriptAssetOf(raw) }++ return { kind: null, body: null, issues: ['kind: prefab or script'] }++}++++type Vec3 = [number, number, number]++type Placed = { pos: Vec3; id: string }++++/**++ * Pieces chosen in the editor as a build: their positions made relative to the centre of their footprint at its++ * lowest point, so the build lands on the ground where it is placed. Ids are kept (placing gives new ones).++ */++export function prefabFromObjects(objects: {++ statics?: readonly MapFileV2['statics'][number][]++ nodes?: readonly MapFileV2['nodes'][number][]++ props?: readonly MapFileV2['props'][number][]++ lights?: readonly MapFileV2['lights'][number][]++}): PrefabBody {++ const all: Placed[] = [++ ...(objects.statics ?? []),++ ...(objects.nodes ?? []),++ ...(objects.props ?? []),++ ...(objects.lights ?? []),++ ] as Placed[]++ const axis = (i: 0 | 1 | 2) => all.map((o) => o.pos[i])++ const mid = (values: number[]) =>++ values.length ? (Math.min(...values) + Math.max(...values)) / 2 : 0++ const origin: Vec3 = [mid(axis(0)), all.length ? Math.min(...axis(1)) : 0, mid(axis(2))]++ const round = (v: number) => Math.round(v * 1000) / 1000++ const shift = <T extends Placed>(o: T): T => ({++ ...o,++ pos: [round(o.pos[0] - origin[0]), round(o.pos[1] - origin[1]), round(o.pos[2] - origin[2])],++ })++ return {++ v: 1,++ statics: (objects.statics ?? []).map((o) => shift(o as never)),++ nodes: (objects.nodes ?? []).map((o) => shift(o as never)),++ props: (objects.props ?? []).map((o) => shift(o as never)),++ lights: (objects.lights ?? []).map((o) => shift(o as never)),++ }++}++++/**++ * A build placed at `at`: every piece with a new id (from `newId`, given the kind's short prefix) and its position++ * moved by `at`. Answers the pieces by editor kind, ready to add to a document.++ */++export function placePrefab(++ body: PrefabBody,++ at: Vec3,++ newId: (prefix: string) => string,++): { kind: 'static' | 'node' | 'prop' | 'light'; object: Placed & Record<string, unknown> }[] {++ const move = <T extends Placed>(o: T, prefix: string): T => ({++ ...o,++ id: newId(prefix),++ pos: [o.pos[0] + at[0], o.pos[1] + at[1], o.pos[2] + at[2]],++ })++ return [++ ...body.statics.map((o) => ({ kind: 'static' as const, object: move(o as never, 'st') })),++ ...body.nodes.map((o) => ({ kind: 'node' as const, object: move(o as never, 'no') })),++ ...body.props.map((o) => ({ kind: 'prop' as const, object: move(o as never, 'pr') })),++ ...body.lights.map((o) => ({ kind: 'light' as const, object: move(o as never, 'li') })),++ ]++}nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- The asset store (docs/platform-direction.md step 5, free first): builds (pieces placed in the editor, positions++-- relative to the build's origin) and scripts (one games-quickjs@1 mod) that creators publish for anyone to use in++-- their own worlds. `body` is checked before it is stored (the content package's creatorAssetOf). Published at once;++-- `hidden` is staff taking one down. `from_world` and `from_world_title` credit the world a build or script came++-- from, as it was then. A use is counted once per person (creator_asset_uses), never by the asset's own creator.++CREATE TABLE IF NOT EXISTS creator_assets (++ id text COLLATE "C" PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL,++ owner_name text NOT NULL,++ kind text NOT NULL CHECK (kind IN ('prefab', 'script')),++ title text NOT NULL,++ summary text NOT NULL DEFAULT '',++ body jsonb NOT NULL,++ status text NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'hidden')),++ uses integer NOT NULL DEFAULT 0,++ from_world text COLLATE "C",++ from_world_title text,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS creator_assets_owner ON creator_assets (owner_subject, created_at DESC);++CREATE INDEX IF NOT EXISTS creator_assets_listed ON creator_assets (created_at DESC, id) WHERE status = 'published';++CREATE TABLE IF NOT EXISTS creator_asset_uses (++ asset_id text COLLATE "C" NOT NULL REFERENCES creator_assets(id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL,++ used_at bigint NOT NULL,++ PRIMARY KEY (asset_id, subject)++);++CREATE INDEX IF NOT EXISTS creator_asset_uses_subject ON creator_asset_uses (subject);@@@ -16,3 +16,13 @@ export {type CreatorWorldStatus,type CreatorWorldStore,} from './pg/creatorWorlds.js'++export {++ createCreatorAssetStore,++ exportCreatorAssets,++ eraseCreatorAssets,++ moveCreatorAssets,++ type CreatorAsset,++ type CreatorAssetKind,++ type CreatorAssetStatus,++ type CreatorAssetStore,++} from './pg/creatorAssets.js'nnew file mode 100644@@@ -0,0 +1,120 @@++import { describe, expect, it } from 'vitest'++import { sql } from 'openvibe-sdk/db'++import { openTestDb } from '../testing.js'++import { createIdentityRepository } from './platformRepositories.js'++import { createCreatorAssetStore } from './creatorAssets.js'++++const KIM = 'usr_01J0000000000000000000000K'++const LEE = 'usr_01J0000000000000000000000L'++const ZED = 'usr_01J0000000000000000000000Z'++const BUILD = {++ v: 1,++ statics: [],++ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [0, 0, 0] }],++ props: [],++ lights: [],++}++const SCRIPT = {++ format: 'games-quickjs@1',++ id: 'welcome',++ version: '1.0.0',++ entry: 'welcome.js',++ source: '',++ hooks: [],++}++++describe('creator assets store', () => {++ it('publishes, lists by kind and owner, counts a use once per person, hides and removes', async () => {++ const db = await openTestDb()++ const store = createCreatorAssetStore(db)++ const build = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ kind: 'prefab',++ title: 'Oak',++ summary: 'One tree',++ body: BUILD,++ fromWorld: { id: 'w0000000001', title: 'Moon base' },++ now: 1000,++ })++ expect(build.id).toMatch(/^a[0-9a-z]{10}$/)++ expect(build).toMatchObject({++ status: 'published',++ uses: 0,++ fromWorld: { id: 'w0000000001', title: 'Moon base' },++ })++ expect(build.body).toEqual(BUILD)++ const script = await store.create({++ ownerSubject: LEE,++ ownerName: 'lee',++ kind: 'script',++ title: 'Welcome',++ summary: '',++ body: SCRIPT,++ now: 2000,++ })++ expect(script.fromWorld).toBeNull()++++ expect((await store.listPublished(null, 10, 0)).map((a) => a.id)).toEqual([script.id, build.id])++ expect((await store.listPublished('prefab', 10, 0)).map((a) => a.id)).toEqual([build.id])++ expect((await store.listPublished(null, 1, 1)).map((a) => a.id)).toEqual([build.id])++ expect(await store.countByOwner(KIM)).toBe(1)++ expect((await store.listByOwner(LEE)).map((a) => a.id)).toEqual([script.id])++++ expect(await store.countUse(build.id, LEE, 3000)).toBe(true)++ expect(await store.countUse(build.id, LEE, 3001)).toBe(false)++ expect((await store.get(build.id))?.uses).toBe(1)++++ const hidden = await store.update(build.id, { status: 'hidden' }, 4000)++ expect(hidden?.status).toBe('hidden')++ expect((await store.listPublished(null, 10, 0)).map((a) => a.id)).toEqual([script.id])++ expect(++ (await store.update(build.id, { title: 'Big oak', status: 'published' }, 4001))?.title,++ ).toBe('Big oak')++++ await store.rename(KIM, 'kimberly')++ expect((await store.get(build.id))?.ownerName).toBe('kimberly')++ expect(await store.remove(build.id)).toBe(true)++ expect(await store.get(build.id)).toBeNull()++ expect(await db.many(sql`SELECT * FROM creator_asset_uses`)).toEqual([])++ })++++ it('follows the account: export, merge into the survivor (uses recounted) and erasure', async () => {++ const db = await openTestDb()++ const store = createCreatorAssetStore(db)++ const identity = createIdentityRepository(db)++ const mk = (owner: string, title: string) =>++ store.create({++ ownerSubject: owner,++ ownerName: owner.slice(-1),++ kind: 'prefab',++ title,++ summary: '',++ body: BUILD,++ now: 1,++ })++ const kims = await mk(KIM, 'Kim build')++ const lees = await mk(LEE, 'Lee build')++ const zeds = await mk(ZED, 'Zed build')++ // KIM used LEE's and ZED's builds; LEE used ZED's too.++ await store.countUse(lees.id, KIM, 2)++ await store.countUse(zeds.id, KIM, 3)++ await store.countUse(zeds.id, LEE, 4)++++ const out = await identity.exportSubject(KIM)++ expect(out.creatorAssets.map((a) => a.id)).toEqual([kims.id])++ expect(out.creatorAssetUses.map((u) => u.asset_id).sort()).toEqual([lees.id, zeds.id].sort())++++ // KIM folds into LEE: KIM's build is LEE's; KIM's use of LEE's own build no longer counts, and ZED's build++ // counts one use by LEE, not two.++ await identity.mergeSubject(KIM, LEE, 'merge-1', 10)++ expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)++ expect((await store.get(lees.id))?.uses).toBe(0)++ expect((await store.get(zeds.id))?.uses).toBe(1)++++ expect(await identity.eraseSubjects([LEE])).toMatchObject({ creator_assets: 2 })++ expect((await store.listByOwner(LEE)).length).toBe(0)++ expect(await db.many(sql`SELECT subject FROM creator_asset_uses`)).toEqual([])++ expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)++ })++})nnew file mode 100644@@@ -0,0 +1,228 @@++import { randomBytes } from 'node:crypto'++import { sql, type Db } from 'openvibe-sdk/db'++import { idList } from './sqlUtils.js'++++/**++ * The asset store (migration 0016): builds and scripts creators publish for anyone to use in their own worlds. Callers++ * check a body first (the content package's creatorAssetOf); this module stores, lists, counts uses and removes.++ */++++export type CreatorAssetKind = 'prefab' | 'script'++export type CreatorAssetStatus = 'published' | 'hidden'++++export interface CreatorAsset {++ id: string++ ownerSubject: string++ ownerName: string++ kind: CreatorAssetKind++ title: string++ summary: string++ status: CreatorAssetStatus++ /** How many people (besides its creator) used it. */++ uses: number++ /** The world it came from, as it was then; null when none was named. */++ fromWorld: { id: string; title: string } | null++ createdAt: number++ updatedAt: number++ body: unknown++}++++interface Row {++ id: string++ owner_subject: string++ owner_name: string++ kind: CreatorAssetKind++ title: string++ summary: string++ status: CreatorAssetStatus++ uses: number | string++ from_world: string | null++ from_world_title: string | null++ created_at: number | string++ updated_at: number | string++ body: unknown++}++++const COLUMNS = sql`id, owner_subject, owner_name, kind, title, summary, status, uses, from_world, from_world_title, created_at, updated_at, body`++++function fromRow(row: Row): CreatorAsset {++ return {++ id: row.id,++ ownerSubject: row.owner_subject,++ ownerName: row.owner_name,++ kind: row.kind,++ title: row.title,++ summary: row.summary,++ status: row.status,++ uses: Number(row.uses),++ fromWorld: row.from_world ? { id: row.from_world, title: row.from_world_title ?? '' } : null,++ createdAt: Number(row.created_at),++ updatedAt: Number(row.updated_at),++ body: row.body,++ }++}++++/** `a` + ten base-36 characters (content's CREATOR_ASSET_ID). */++export function newCreatorAssetId(): string {++ const bytes = randomBytes(10)++ let id = 'a'++ for (const b of bytes) id += (b % 36).toString(36)++ return id++}++++export interface CreatorAssetStore {++ create(input: {++ ownerSubject: string++ ownerName: string++ kind: CreatorAssetKind++ title: string++ summary: string++ body: unknown++ fromWorld?: { id: string; title: string } | null++ now: number++ }): Promise<CreatorAsset>++ get(id: string): Promise<CreatorAsset | null>++ countByOwner(ownerSubject: string): Promise<number>++ listByOwner(ownerSubject: string): Promise<CreatorAsset[]>++ /** Published assets, newest first, of one kind or both. */++ listPublished(++ kind: CreatorAssetKind | null,++ limit: number,++ offset: number,++ ): Promise<CreatorAsset[]>++ update(++ id: string,++ patch: { title?: string; summary?: string; status?: CreatorAssetStatus },++ now: number,++ ): Promise<CreatorAsset | null>++ /** The creator's display name follows the Network's (set at each signed-in write). */++ rename(ownerSubject: string, ownerName: string): Promise<void>++ /** Counts this person's use of the asset once; true when it was their first. */++ countUse(id: string, subject: string, now: number): Promise<boolean>++ remove(id: string): Promise<boolean>++}++++export function createCreatorAssetStore(db: Db): CreatorAssetStore {++ return {++ async create(input) {++ const id = newCreatorAssetId()++ const from = input.fromWorld ?? null++ const row = await db.one<Row>(sql`++ INSERT INTO creator_assets (id, owner_subject, owner_name, kind, title, summary, body, from_world, from_world_title,++ created_at, updated_at)++ VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.kind}, ${input.title}, ${input.summary},++ ${sql.json(input.body)}, ${from ? from.id : null}, ${from ? from.title : null}, ${input.now}, ${input.now})++ RETURNING ${COLUMNS}++ `)++ return fromRow(row)++ },++ async get(id) {++ const row = await db.maybe<Row>(sql`SELECT ${COLUMNS} FROM creator_assets WHERE id = ${id}`)++ return row ? fromRow(row) : null++ },++ async countByOwner(ownerSubject) {++ const row = await db.one<{ n: number | string }>(++ sql`SELECT count(*)::int AS n FROM creator_assets WHERE owner_subject = ${ownerSubject}`,++ )++ return Number(row.n)++ },++ async listByOwner(ownerSubject) {++ const rows = await db.many<Row>(++ sql`SELECT ${COLUMNS} FROM creator_assets WHERE owner_subject = ${ownerSubject} ORDER BY created_at DESC, id`,++ )++ return rows.map(fromRow)++ },++ async listPublished(kind, limit, offset) {++ const rows = await db.many<Row>(sql`++ SELECT ${COLUMNS} FROM creator_assets++ WHERE status = 'published' ${kind ? sql`AND kind = ${kind}` : sql``}++ ORDER BY created_at DESC, id LIMIT ${limit} OFFSET ${offset}++ `)++ return rows.map(fromRow)++ },++ 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}`)++ const row = await db.maybe<Row>(++ sql`UPDATE creator_assets SET ${sql.join(sets)} WHERE id = ${id} RETURNING ${COLUMNS}`,++ )++ return row ? fromRow(row) : null++ },++ async rename(ownerSubject, ownerName) {++ await db.exec(++ sql`UPDATE creator_assets SET owner_name = ${ownerName} WHERE owner_subject = ${ownerSubject} AND owner_name <> ${ownerName}`,++ )++ },++ async countUse(id, subject, now) {++ return db.tx(async () => {++ const added = await db.exec(sql`++ INSERT INTO creator_asset_uses (asset_id, subject, used_at) VALUES (${id}, ${subject}, ${now})++ ON CONFLICT (asset_id, subject) DO NOTHING++ `)++ if (added > 0)++ await db.exec(sql`UPDATE creator_assets SET uses = uses + 1 WHERE id = ${id}`)++ return added > 0++ })++ },++ async remove(id) {++ return (await db.exec(sql`DELETE FROM creator_assets WHERE id = ${id}`)) > 0++ },++ }++}++++/** A person's assets and the ones they used, for their data export (ADR-033). */++export async function exportCreatorAssets(++ db: Db,++ subject: string,++): Promise<{ assets: Record<string, unknown>[]; uses: Record<string, unknown>[] }> {++ const assets = await db.many<Record<string, unknown>>(sql`++ SELECT id, owner_name, kind, title, summary, status, uses, from_world, from_world_title, created_at, updated_at, body++ FROM creator_assets WHERE owner_subject = ${subject} ORDER BY created_at++ `)++ const uses = await db.many<Record<string, unknown>>(sql`++ SELECT u.asset_id, a.title, u.used_at FROM creator_asset_uses u++ JOIN creator_assets a ON a.id = u.asset_id WHERE u.subject = ${subject} ORDER BY u.used_at++ `)++ return { assets, uses }++}++++/** Erasure (ADR-033): the person's assets go, and so do the records of what they used. Answers how many assets. */++export async function eraseCreatorAssets(db: Db, subjects: readonly string[]): Promise<number> {++ if (!subjects.length) return 0++ await db.exec(sql`DELETE FROM creator_asset_uses WHERE subject IN (${idList(subjects)})`)++ return db.exec(sql`DELETE FROM creator_assets WHERE owner_subject IN (${idList(subjects)})`)++}++++/**++ * Account merge (ADR-029): the folded-in account's assets belong to the survivor, and so do its uses (one per asset;++ * none on the survivor's own assets, which never count their creator).++ */++export async function moveCreatorAssets(db: Db, from: string, into: string): Promise<number> {++ if (from === into) return 0++ const moved = await db.exec(++ sql`UPDATE creator_assets SET owner_subject = ${into} WHERE owner_subject = ${from}`,++ )++ const touched = (++ await db.many<{ asset_id: string }>(++ sql`SELECT asset_id FROM creator_asset_uses WHERE subject = ${from}`,++ )++ ).map((r) => r.asset_id)++ await db.exec(sql`++ UPDATE creator_asset_uses u SET subject = ${into}++ WHERE u.subject = ${from}++ AND NOT EXISTS (SELECT 1 FROM creator_asset_uses o WHERE o.asset_id = u.asset_id AND o.subject = ${into})++ `)++ await db.exec(sql`DELETE FROM creator_asset_uses WHERE subject = ${from}`)++ await db.exec(sql`++ DELETE FROM creator_asset_uses u USING creator_assets a++ WHERE u.asset_id = a.id AND u.subject = a.owner_subject++ `)++ // Two uses that became one, or a use of what is now one's own, no longer count.++ await db.exec(sql`++ UPDATE creator_assets a SET uses = (SELECT count(*)::int FROM creator_asset_uses u WHERE u.asset_id = a.id)++ WHERE a.owner_subject = ${into}${touched.length ? sql` OR a.id IN (${idList(touched)})` : sql``}++ `)++ return moved++}@@@ -178,6 +178,7 @@ describe('account export and deletion (ADR-033)', () => {identity_rows: 1,casual_saves: 2,creator_worlds: 0,++ creator_assets: 0,})expect(await store.db.many(sql`SELECT account_key FROM casual_ledger ORDER BY account_key`),@@@ -199,6 +200,7 @@ describe('account export and deletion (ADR-033)', () => {identity_rows: 0,casual_saves: 0,creator_worlds: 0,++ creator_assets: 0,})})})@@@ -19,6 +19,7 @@ import {exportWorldTeams,moveCreatorWorlds,} from './creatorWorlds.js'++import { eraseCreatorAssets, exportCreatorAssets, moveCreatorAssets } from './creatorAssets.js'/** The character places of one account, in order (protocol MAX_CHARACTERS). */const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)@@@ -174,6 +175,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {}await moveProgress(from, into)await moveCreatorWorlds(db, from, into)++ await moveCreatorAssets(db, from, into)await recordAdoption(key, into, 'merge', moved, kept, now)return { moved, kept, already: false }})@@@ -204,6 +206,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {const casual = await exportCasual(db, subject)const creatorWorlds = await exportCreatorWorlds(db, subject)const creatorWorldTeams = await exportWorldTeams(db, subject)++ const creatorAssets = await exportCreatorAssets(db, subject)return {characters: characters.map(withoutAccountKey),structures,@@@ -215,6 +218,8 @@ export function createIdentityRepository(db: Db): IdentityRepository {casualLedger: casual.ledger,creatorWorlds,creatorWorldTeams,++ creatorAssets: creatorAssets.assets,++ creatorAssetUses: creatorAssets.uses,}},@@@ -226,6 +231,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {identity_rows: 0,casual_saves: 0,creator_worlds: 0,++ creator_assets: 0,}if (!subjects.length) return outconst ids = (@@@ -251,6 +257,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)++ out.creator_assets = await eraseCreatorAssets(db, subjects)return out})},@@@ -95,6 +95,10 @@ export interface IdentityRepository {creatorWorlds: Record<string, unknown>[]/** Worlds this person edits on someone else's team (migration 0015). */creatorWorldTeams: Record<string, unknown>[]++ /** The builds and scripts this person published in the asset store (migration 0016), bodies included. */++ creatorAssets: Record<string, unknown>[]++ /** The assets this person used, once each. */++ creatorAssetUses: Record<string, unknown>[]}>/*** Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their@@@ -107,6 +111,7 @@ export interface IdentityRepository {identity_rows: numbercasual_saves: numbercreator_worlds: number++ creator_assets: number}>}@@@ -22,6 +22,7 @@ const TABLES = ['casual_actions','play_sync','creator_worlds',++ 'creator_assets','games_index_revisions','game_instances','character_instance_positions',nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/worlds/assetRoutes.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/creatorAsset.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/creatorAssets.test.ts'++// covers: 'packages/persistence/src/pg'++// Generated by node test/run.js --index; do not edit.
@@ -20,6 +20,7 @@ GAMES_INTEREST_RADIUS=80
@@ -20,6 +20,7 @@ GAMES_INTEREST_RADIUS=80
# Creator worlds (/worlds): running at once, kept per account, idle stop (seconds), players per world.
# Creator worlds (/worlds): running at once, kept per account, idle stop (seconds), players per world.
# GAMES_CREATOR_WORLDS_MAX=2
# GAMES_CREATOR_WORLDS_MAX=2
# GAMES_CREATOR_WORLDS_PER_PERSON=5
# GAMES_CREATOR_WORLDS_PER_PERSON=5
+# GAMES_CREATOR_ASSETS_PER_PERSON=50
# GAMES_CREATOR_WORLD_IDLE_SECONDS=180
# GAMES_CREATOR_WORLD_IDLE_SECONDS=180
# GAMES_CREATOR_WORLD_PLAYERS=16
# GAMES_CREATOR_WORLD_PLAYERS=16
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+/**
+ * The dock's Store tab: the asset store (migration 0016) inside the editor. It lists the builds creators share and
+ * places one where the camera looks, and shares the selected pieces as a new build. Talks to /api/assets with the
+ * signed-in Network token; placing and sharing go through the callbacks, so the document changes stay in editorApp
+ * (one undo step each).
+ */
+import type { PrefabBody } from '@openvibe/content'
+
+export interface StoreBuild {
+ id: string
+ title: string
+ owner: string
+ uses: number
+ contents: { pieces?: number }
+}
+
+export interface StorePanelOptions {
+ /** The creator world being edited (a shared build credits it), or null on the place's own map. */
+ worldId: string | null
+ token: () => string
+ /** Puts a build in the document where the camera looks (one undo step). */
+ place: (body: PrefabBody, title: string) => void
+ /** The selected pieces as a build, or why there is none to share. */
+ selectionBuild: () => { body: PrefabBody; pieces: number } | { error: string }
+ setMessage: (m: string) => void
+ fetch?: typeof fetch
+}
+
+export class StorePanel {
+ private builds: StoreBuild[] = []
+ private page = 1
+ private more = false
+ private loaded = false
+ private status = ''
+
+ constructor(
+ private readonly root: HTMLElement,
+ private readonly opts: StorePanelOptions,
+ ) {
+ this.render()
+ }
+
+ /** Loads the first page the first time the tab is shown. */
+ show(): void {
+ if (!this.loaded) void this.load(true)
+ }
+
+ private async api<T>(path: string, init: RequestInit = {}): Promise<T> {
+ const token = this.opts.token()
+ const resp = await (this.opts.fetch ?? fetch)(path, {
+ ...init,
+ headers: {
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ })
+ const body = (await resp.json().catch(() => ({}))) as {
+ error?: string
+ detail?: string
+ issues?: string[]
+ }
+ if (!resp.ok)
+ throw new Error(body.detail ?? body.issues?.[0] ?? body.error ?? `HTTP ${resp.status}`)
+ return body as T
+ }
+
+ async load(reset = false): Promise<void> {
+ if (reset) this.page = 1
+ try {
+ const data = await this.api<{ assets: StoreBuild[]; more: boolean }>(
+ `/api/assets?kind=prefab&page=${this.page}`,
+ )
+ this.builds = this.page === 1 ? data.assets : [...this.builds, ...data.assets]
+ this.more = data.more
+ this.loaded = true
+ this.status = this.builds.length ? '' : 'No builds shared yet. Share yours below.'
+ } catch {
+ this.status = 'The store could not be loaded. Open this tab again to retry.'
+ this.loaded = false
+ }
+ this.render()
+ }
+
+ /** A build from the store, its use counted when signed in, placed where the camera looks. */
+ async use(id: string): Promise<void> {
+ try {
+ const signedIn = !!this.opts.token()
+ const asset = await this.api<{ title: string; body: PrefabBody }>(
+ signedIn ? `/api/assets/${id}/use` : `/api/assets/${id}`,
+ signedIn ? { method: 'POST' } : {},
+ )
+ this.opts.place(asset.body, asset.title)
+ } catch (err) {
+ this.opts.setMessage(
+ `⛔ ${err instanceof Error ? err.message : 'that build could not be placed'}`,
+ )
+ }
+ }
+
+ private async share(title: string, summary: string, button: HTMLButtonElement): Promise<void> {
+ const picked = this.opts.selectionBuild()
+ if ('error' in picked) {
+ this.status = picked.error
+ this.render()
+ return
+ }
+ button.disabled = true
+ try {
+ await this.api('/api/assets', {
+ method: 'POST',
+ body: JSON.stringify({
+ kind: 'prefab',
+ title,
+ summary,
+ body: picked.body,
+ ...(this.opts.worldId ? { from_world: this.opts.worldId } : {}),
+ }),
+ })
+ await this.load(true)
+ this.status = `Shared “${title.trim()}” (${picked.pieces} pieces). Anyone can use it from the store now.`
+ this.render()
+ } catch (err) {
+ this.status = err instanceof Error ? err.message : 'That did not work.'
+ this.render()
+ } finally {
+ button.disabled = false
+ }
+ }
+
+ private render(): void {
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls = '', text?: string) => {
+ const node = document.createElement(tag)
+ if (cls) node.className = cls
+ if (text !== undefined) node.textContent = text
+ return node
+ }
+ const list = el('div', 'store-list')
+ for (const build of this.builds) {
+ const row = el('div', 'store-row')
+ const what = el('span', 'store-what')
+ what.append(
+ el('b', '', build.title),
+ el(
+ 'small',
+ '',
+ ` by ${build.owner} · ${build.contents.pieces ?? 0} pieces${build.uses ? ` · used by ${build.uses}` : ''}`,
+ ),
+ )
+ const place = el('button', 'mini', 'Place')
+ place.type = 'button'
+ place.title = 'Put it where the camera looks'
+ place.addEventListener('click', () => void this.use(build.id))
+ row.append(what, place)
+ list.append(row)
+ }
+ const moreButton = el('button', 'mini', 'More builds')
+ moreButton.type = 'button'
+ moreButton.hidden = !this.more
+ moreButton.addEventListener('click', () => {
+ this.page++
+ void this.load()
+ })
+
+ const shareBox = el('div', 'store-share')
+ const title = el('input')
+ title.maxLength = 60
+ title.placeholder = 'Title of your build'
+ title.setAttribute('aria-label', 'Build title')
+ const summary = el('input')
+ summary.maxLength = 280
+ summary.placeholder = 'What is it? (optional)'
+ summary.setAttribute('aria-label', 'Build summary')
+ const shareButton = el('button', 'mini', 'Share the selection')
+ shareButton.type = 'button'
+ shareButton.disabled = !this.opts.token()
+ shareButton.addEventListener('click', () => {
+ if (!title.value.trim()) {
+ this.status = 'Give your build a title first.'
+ this.render()
+ return
+ }
+ void this.share(title.value, summary.value, shareButton)
+ })
+ shareBox.append(
+ el(
+ 'p',
+ 'store-hint',
+ this.opts.token()
+ ? 'Select boxes, nodes, props and lights in the viewport, then share them as a build anyone can place.'
+ : 'Sign in to share a build.',
+ ),
+ title,
+ summary,
+ shareButton,
+ )
+ const status = el('p', 'store-status', this.status)
+ status.setAttribute('role', 'status')
+ this.root.replaceChildren(
+ el('p', 'store-hint', 'Builds other creators share, free. Place one where the camera looks.'),
+ list,
+ moreButton,
+ shareBox,
+ status,
+ )
+ }
+}
@@ -871,6 +871,52 @@ select:disabled {
@@ -871,6 +871,52 @@ select:disabled {
.dock-body.on {
.dock-body.on {
display: block;
display: block;
}
}
+/* The Store tab (assets/storePanel.ts): the asset store's builds, and sharing the selection. */
+.store-list {
+ display: grid;
+ gap: 4px;
+ margin: 4px 0;
+}
+.store-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.store-what {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.store-what small {
+ color: #9aa6b2;
+}
+.store-share {
+ display: grid;
+ gap: 4px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid #2a3340;
+}
+.store-share input {
+ background: #11161c;
+ color: #dde3ea;
+ border: 1px solid #2a3340;
+ border-radius: 6px;
+ padding: 4px 6px;
+}
+.store-share .mini {
+ justify-self: start;
+}
+.store-hint,
+.store-status {
+ margin: 2px 0;
+ color: #9aa6b2;
+}
+.store-status:empty {
+ display: none;
+}
.asset-tabs {
.asset-tabs {
display: flex;
display: flex;
gap: 4px;
gap: 4px;
@@ -22,10 +22,14 @@ import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
@@ -22,10 +22,14 @@ import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
import {
import {
createContent,
createContent,
emptyMapV2,
emptyMapV2,
+ CREATOR_ASSET_ID,
parseMapFile,
parseMapFile,
+ placePrefab,
+ prefabFromObjects,
setMapOverride,
setMapOverride,
type MapFileV2,
type MapFileV2,
type MapTextureEntry,
type MapTextureEntry,
+ type PrefabBody,
} from '@openvibe/content'
} from '@openvibe/content'
import { Environment } from '../render/environment.js'
import { Environment } from '../render/environment.js'
import { registerCustomTextures } from '../render/mapStyle.js'
import { registerCustomTextures } from '../render/mapStyle.js'
@@ -78,6 +82,7 @@ import { ActionRouter } from './input/actionRouter.js'
@@ -78,6 +82,7 @@ import { ActionRouter } from './input/actionRouter.js'
import { ACTIONS, loadBindings, type Binding } from './bindings.js'
import { ACTIONS, loadBindings, type Binding } from './bindings.js'
import { editorPerf } from './perf/editorProfiler.js'
import { editorPerf } from './perf/editorProfiler.js'
import { installEditorProbe } from './devProbe.js'
import { installEditorProbe } from './devProbe.js'
+import { StorePanel } from './assets/storePanel.js'
const content = createContent()
const content = createContent()
@@ -861,6 +866,75 @@ export async function bootEditor(): Promise<void> {
@@ -861,6 +866,75 @@ export async function bootEditor(): Promise<void> {
},
},
})
})
+ // ── The asset store (Store tab, /editor?asset=<id>) ─────────────────
+ /** Where the camera looks: what the editor picks at the middle of the view, else the ground ahead. */
+ const lookPoint = (): [number, number, number] => {
+ const hit = scene.pick(engine.getRenderWidth() / 2, engine.getRenderHeight() / 2, (m) =>
+ pickable(m),
+ )
+ if (hit?.hit && hit.pickedPoint)
+ return [hit.pickedPoint.x, hit.pickedPoint.y, hit.pickedPoint.z]
+ const dir = camera.getDirection(Vector3.Forward())
+ const along = dir.y < -0.05 ? -camera.position.y / dir.y : -1
+ const p = camera.position.add(dir.scale(along > 0 && along < 400 ? along : 18))
+ return [p.x, along > 0 && along < 400 ? 0 : Math.max(0, p.y), p.z]
+ }
+ /** A build from the store, with new ids, where the camera looks: one undo step, then selected. */
+ const placeBuild = (body: PrefabBody, title: string): void => {
+ const [x, y, z] = lookPoint()
+ const snap = (v: number) => Math.round(v * 2) / 2
+ const placed = placePrefab(body, [snap(x), y, snap(z)], newId)
+ if (placed.length === 0) return
+ history.apply({
+ label: `place ${title}`,
+ execute: (d) => placed.forEach((m) => d.add(m.kind, m.object as never)),
+ undo: (d) => placed.forEach((m) => d.remove(m.object.id)),
+ })
+ selection.replaceMany(placed.map((m) => m.object.id))
+ ui.refreshAll()
+ ui.setMessage(
+ `🧱 "${title}" placed (${placed.length} pieces) — move it with the gizmo, Save to keep it`,
+ )
+ }
+ const store = new StorePanel(shell.dockPanels['store']!, {
+ worldId: endpoints.worldId,
+ token: editorToken,
+ place: placeBuild,
+ selectionBuild: () => {
+ const pick = <K extends 'static' | 'node' | 'prop' | 'light'>(kind: K) =>
+ selection
+ .ids()
+ .filter((id) => doc.typeOf(id) === kind)
+ .map((id) => doc.snapshot(id))
+ .filter((o): o is NonNullable<typeof o> => o !== null) as never[]
+ const body = prefabFromObjects({
+ statics: pick('static'),
+ nodes: pick('node'),
+ props: pick('prop'),
+ lights: pick('light'),
+ })
+ const pieces =
+ body.statics.length + body.nodes.length + body.props.length + body.lights.length
+ return pieces
+ ? { body, pieces }
+ : {
+ error:
+ 'Select the boxes, nodes, props and lights to share first (terrain and zones stay in your world).',
+ }
+ },
+ setMessage: (m) => ui.setMessage(m),
+ })
+ store.show()
+ /** /editor?world=<id>&asset=<id> (the store's "Use in a world"): places that build once the map is ready. */
+ const placeAssetFromUrl = async (): Promise<void> => {
+ const params = new URLSearchParams(location.search)
+ const asset = params.get('asset')
+ if (!asset || !CREATOR_ASSET_ID.test(asset)) return
+ params.delete('asset')
+ window.history.replaceState(null, '', `${location.pathname}?${params.toString()}`)
+ await store.use(asset)
+ }
+
// ── Actions ─────────────────────────────────────────────────────────
// ── Actions ─────────────────────────────────────────────────────────
const router = new ActionRouter(ACTIONS, bindingOf, (id) => runAction(id))
const router = new ActionRouter(ACTIONS, bindingOf, (id) => runAction(id))
@@ -1010,7 +1084,7 @@ export async function bootEditor(): Promise<void> {
@@ -1010,7 +1084,7 @@ export async function bootEditor(): Promise<void> {
// Offer any unsaved work from a previous session. Never automatic: a
// Offer any unsaved work from a previous session. Never automatic: a
// crash recovering itself into everyone else's world would be worse than
// crash recovering itself into everyone else's world would be worse than
// losing it, so this restores locally and leaves the map dirty.
// losing it, so this restores locally and leaves the map dirty.
- void saveController.restoreDraftIfAny()
+ void saveController.restoreDraftIfAny().then(() => placeAssetFromUrl())
// ── Probe (harness only; NOT a public API) ──────────────────────────
// ── Probe (harness only; NOT a public API) ──────────────────────────
installEditorProbe({
installEditorProbe({
@@ -267,6 +267,7 @@ export function buildShell(
@@ -267,6 +267,7 @@ export function buildShell(
const dockPanels: Record<string, HTMLElement> = {}
const dockPanels: Record<string, HTMLElement> = {}
for (const [id, title] of [
for (const [id, title] of [
['assets', 'Assets'],
['assets', 'Assets'],
+ ['store', 'Store'],
['issues', 'Issues'],
['issues', 'Issues'],
['history', 'History'],
['history', 'History'],
['scene', 'Scene'],
['scene', 'Scene'],
@@ -4,6 +4,7 @@ export {}
@@ -4,6 +4,7 @@ export {}
* /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete, remix). A remix
* /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete, remix). A remix
* copies a published world into a new draft of yours, credited (owner, 2026-10-27: "the same way you can copy our
* copies a published world into a new draft of yours, credited (owner, 2026-10-27: "the same way you can copy our
* game"); /worlds?remix=<id> (a world page's Remix link) makes one and opens it in the editor, after sign-in if needed.
* game"); /worlds?remix=<id> (a world page's Remix link) makes one and opens it in the editor, after sign-in if needed.
+ * The asset store (migration 0016) lists the builds and scripts creators share; using one puts it in a world you build.
*/
*/
interface World {
interface World {
@@ -321,7 +322,11 @@ async function openScripts(world: World): Promise<void> {
@@ -321,7 +322,11 @@ async function openScripts(world: World): Promise<void> {
card.remove()
card.remove()
rows.splice(rows.indexOf(read), 1)
rows.splice(rows.indexOf(read), 1)
})
})
- card.append(remove)
+ const share = button(
+ 'Share in the asset store',
+ (b) => void shareScript(world, read(), b, status),
+ )
+ card.append(el('div', { className: 'row' }, remove, share))
rows.push(read)
rows.push(read)
list.append(card)
list.append(card)
}
}
@@ -617,8 +622,10 @@ async function loadMine(): Promise<void> {
@@ -617,8 +622,10 @@ async function loadMine(): Promise<void> {
try {
try {
const mine = await api<Mine>('/api/worlds/mine')
const mine = await api<Mine>('/api/worlds/mine')
staff = mine.staff
staff = mine.staff
+ myWorlds = [...mine.worlds, ...(mine.team ?? [])]
signedOut.hidden = true
signedOut.hidden = true
signedIn.hidden = false
signedIn.hidden = false
+ yoursButton.hidden = false
mineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))
mineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))
const full = mine.worlds.length >= mine.max
const full = mine.worlds.length >= mine.max
createButton.disabled = full
createButton.disabled = full
@@ -654,6 +661,285 @@ async function loadPublished(reset = false): Promise<void> {
@@ -654,6 +661,285 @@ async function loadPublished(reset = false): Promise<void> {
}
}
}
}
+// ── The asset store (migration 0016): builds and scripts creators share for anyone's worlds ──
+interface Asset {
+ id: string
+ kind: 'prefab' | 'script'
+ title: string
+ summary: string
+ owner: string
+ uses: number
+ contents: { pieces?: number; hooks?: string[]; capabilities?: string[] }
+ from_world: { id: string; title: string; url: string } | null
+ created_at: string
+ /** Only on your own (and to staff). */
+ status?: 'published' | 'hidden'
+}
+
+const assetsGrid = $('assets')
+const assetsStatus = $('assets-status')
+const assetsMoreRow = $('assets-more-row')
+const assetsMore = $<HTMLButtonElement>('assets-more')
+const kindButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('.kinds button'))
+const yoursButton = kindButtons.find((b) => b.dataset['kind'] === 'mine') as HTMLButtonElement
+let assetKind = 'all'
+let assetPage = 1
+/** The worlds you build (yours and your teams'): where an asset can go. */
+let myWorlds: World[] = []
+
+function assetStats(asset: Asset): string {
+ const used = asset.uses ? `used by ${count(asset.uses, 'person', 'people')}` : 'not used yet'
+ if (asset.kind === 'prefab')
+ return `${count(asset.contents.pieces ?? 0, 'piece', 'pieces')} · ${used}`
+ const hooks = (asset.contents.hooks ?? []).join(', ')
+ return `${hooks || 'no hooks'} · ${used}`
+}
+
+function assetCard(asset: Asset, own: boolean): HTMLElement {
+ const hidden = asset.status === 'hidden'
+ const card = el(
+ 'article',
+ { className: 'card' },
+ el('span', {
+ className: `badge ${hidden ? 'hidden' : asset.kind}`,
+ text: hidden ? 'Taken down' : asset.kind === 'prefab' ? 'Build' : 'Script',
+ }),
+ el('h3', { text: asset.title }),
+ el('p', { className: 'by', text: `by ${asset.owner}` }),
+ )
+ if (asset.from_world) {
+ const from = el('p', { className: 'stats' }, document.createTextNode('from '))
+ from.append(link(asset.from_world.title, asset.from_world.url, false, 'title'))
+ card.append(from)
+ }
+ if (asset.summary) card.append(el('p', { className: 'summary', text: asset.summary }))
+ card.append(el('p', { className: 'stats', text: assetStats(asset) }))
+ const actions = el('div', { className: 'actions' })
+ if (!hidden) {
+ if (token())
+ actions.append(
+ button(
+ asset.kind === 'prefab' ? 'Use in a world' : 'Add to a world',
+ () => void useAsset(asset),
+ 'primary',
+ ),
+ )
+ else actions.append(link('Sign in to use it', '/auth/login?next=%2Fworlds', true))
+ }
+ if (own)
+ actions.append(
+ button('Rename', (b) => {
+ const title = prompt('New title (up to 60 characters)', asset.title)
+ if (title !== null && title.trim() && title !== asset.title)
+ void changeAsset(b, asset.id, { title })
+ }),
+ button(
+ 'Delete',
+ (b) =>
+ void (async () => {
+ if (
+ !confirm(
+ `Delete “${asset.title}” from the asset store? Worlds that use it keep their copy.`,
+ )
+ )
+ return
+ b.disabled = true
+ try {
+ await api(`/api/assets/${asset.id}`, { method: 'DELETE' })
+ await loadAssets(true)
+ } catch (err) {
+ b.disabled = false
+ alert(err instanceof Error ? err.message : 'That did not work. Try again.')
+ }
+ })(),
+ 'danger',
+ ),
+ )
+ else if (staff)
+ actions.append(
+ hidden
+ ? button('Put back', (b) => void changeAsset(b, asset.id, { status: 'published' }))
+ : button(
+ 'Take down',
+ (b) =>
+ void changeAsset(
+ b,
+ asset.id,
+ { status: 'hidden' },
+ 'Take this asset down for everyone?',
+ ),
+ 'danger',
+ ),
+ )
+ card.append(actions)
+ return card
+}
+
+async function changeAsset(
+ b: HTMLButtonElement,
+ id: string,
+ patch: Record<string, string>,
+ confirmText?: string,
+): Promise<void> {
+ if (confirmText && !confirm(confirmText)) return
+ b.disabled = true
+ try {
+ await api(`/api/assets/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
+ await loadAssets(true)
+ } catch (err) {
+ b.disabled = false
+ alert(err instanceof Error ? err.message : 'That did not work. Try again.')
+ }
+}
+
+/** Which of your worlds an asset goes into: a small list to pick from. */
+function pickWorld(asset: Asset): Promise<World | null> {
+ return new Promise((resolve) => {
+ const dialog = el('dialog', { className: 'scripts' })
+ let chosen: World | null = null
+ const list = el('div')
+ for (const world of myWorlds)
+ list.append(
+ el(
+ 'div',
+ { className: 'row pick' },
+ el('span', { text: world.role === 'editor' ? `${world.title} (team)` : world.title }),
+ button(
+ asset.kind === 'prefab' ? 'Open in the editor' : 'Add here',
+ () => {
+ chosen = world
+ dialog.close()
+ },
+ 'primary',
+ ),
+ ),
+ )
+ dialog.append(
+ el('h2', {
+ text: asset.kind === 'prefab' ? `Use “${asset.title}” in…` : `Add “${asset.title}” to…`,
+ }),
+ myWorlds.length
+ ? list
+ : el('p', { className: 'status', text: 'Make a world first (above), then come back.' }),
+ el(
+ 'div',
+ { className: 'row' },
+ button('Cancel', () => dialog.close()),
+ ),
+ )
+ dialog.addEventListener('close', () => {
+ dialog.remove()
+ resolve(chosen)
+ })
+ document.body.append(dialog)
+ dialog.showModal()
+ })
+}
+
+/** A build opens in the editor of the world you pick (it lands where you look); a script joins that world's scripts. */
+async function useAsset(asset: Asset): Promise<void> {
+ const world = await pickWorld(asset)
+ if (!world) return
+ if (asset.kind === 'prefab') {
+ location.href = `/editor?world=${world.id}&asset=${asset.id}`
+ return
+ }
+ assetsStatus.textContent = `Adding “${asset.title}” to ${world.title}…`
+ try {
+ const current = await api<{ scripts: WorldScript[]; limits: { mods: number } }>(
+ `/api/worlds/${world.id}/scripts`,
+ )
+ if (current.scripts.length >= current.limits.mods) {
+ assetsStatus.textContent = `${world.title} already has ${current.limits.mods} scripts, the most a world takes. Remove one in its Scripts first.`
+ return
+ }
+ const used = await api<{ body: WorldScript }>(`/api/assets/${asset.id}/use`, { method: 'POST' })
+ // Two scripts in a world never share a name: a second "welcome" becomes "welcome-2".
+ const taken = new Set(current.scripts.map((s) => s.id))
+ let id = used.body.id
+ for (let n = 2; taken.has(id); n++) id = `${used.body.id.slice(0, 36)}-${n}`
+ const script = { ...used.body, id, entry: `${id}.js` }
+ await api(`/api/worlds/${world.id}/scripts`, {
+ method: 'PUT',
+ body: JSON.stringify({ scripts: [...current.scripts, script] }),
+ })
+ await loadAssets(true)
+ assetsStatus.textContent = `Added “${asset.title}” to ${world.title} as the script “${id}”. Change it in that world's Scripts.`
+ } catch (err) {
+ assetsStatus.textContent = err instanceof Error ? err.message : 'That did not work.'
+ }
+}
+
+/** A world's script shared in the asset store, credited to the world. */
+async function shareScript(
+ world: World,
+ script: WorldScript,
+ b: HTMLButtonElement,
+ status: HTMLElement,
+): Promise<void> {
+ const title = prompt('Its title in the asset store (up to 60 characters)', script.id)
+ if (title === null || !title.trim()) return
+ b.disabled = true
+ status.textContent = 'Sharing…'
+ try {
+ await api('/api/assets', {
+ method: 'POST',
+ body: JSON.stringify({
+ kind: 'script',
+ title,
+ body: { ...script, entry: `${script.id || 'main'}.js` },
+ from_world: world.id,
+ }),
+ })
+ status.textContent = `Shared “${title.trim()}” in the asset store.`
+ void loadAssets(true)
+ } catch (err) {
+ status.textContent = err instanceof Error ? err.message : 'That did not work.'
+ } finally {
+ b.disabled = false
+ }
+}
+
+async function loadAssets(reset = false): Promise<void> {
+ if (reset) assetPage = 1
+ try {
+ if (assetKind === 'mine') {
+ const data = await api<{ assets: Asset[]; max: number }>('/api/assets/mine')
+ assetsGrid.replaceChildren(...data.assets.map((a) => assetCard(a, true)))
+ assetsMoreRow.hidden = true
+ assetsStatus.textContent = data.assets.length
+ ? ''
+ : 'You have not shared anything yet. Select pieces in the editor and open Store, or share a script from a world.'
+ return
+ }
+ const kind = assetKind === 'all' ? '' : `&kind=${assetKind}`
+ const data = await api<{ assets: Asset[]; more: boolean }>(
+ `/api/assets?page=${assetPage}${kind}`,
+ )
+ const cards = data.assets.map((a) => assetCard(a, false))
+ if (assetPage === 1) assetsGrid.replaceChildren(...cards)
+ else assetsGrid.append(...cards)
+ assetsMoreRow.hidden = !data.more
+ assetsStatus.textContent =
+ assetPage === 1 && data.assets.length === 0
+ ? 'Nothing shared here yet. Yours could be the first.'
+ : ''
+ } catch {
+ assetsStatus.textContent = 'The asset store could not be loaded. Refresh to try again.'
+ }
+}
+
+for (const b of kindButtons)
+ b.addEventListener('click', () => {
+ assetKind = b.dataset['kind'] ?? 'all'
+ for (const other of kindButtons) other.setAttribute('aria-pressed', String(other === b))
+ void loadAssets(true)
+ })
+assetsMore.addEventListener('click', () => {
+ assetPage++
+ void loadAssets()
+})
+
createForm.addEventListener('submit', (event) => {
createForm.addEventListener('submit', (event) => {
event.preventDefault()
event.preventDefault()
void (async () => {
void (async () => {
@@ -708,5 +994,5 @@ void loadMine().then(() => {
@@ -708,5 +994,5 @@ void loadMine().then(() => {
'Sign in with your OpenVibe account to remix that world into one of your own.'
'Sign in with your OpenVibe account to remix that world into one of your own.'
}
}
}
}
- return loadPublished(true)
+ return Promise.all([loadPublished(true), loadAssets(true)])
})
})
@@ -42,7 +42,9 @@
@@ -42,7 +42,9 @@
}
}
/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a light
/* Light themes (theme-loader sets data-theme-mode): the cyan is text on tags and achievements, and #22d3ee on a light
page read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */
page read at 1.7:1; this deeper cyan reads at 5:1 (axe, 2026-10-22). */
- :root[data-theme-mode="light"] { --secondary: #0e7490; }
+ :root[data-theme-mode='light'] {
+ --secondary: #0e7490;
+ }
* {
* {
box-sizing: border-box;
box-sizing: border-box;
}
}
@@ -355,6 +357,30 @@
@@ -355,6 +357,30 @@
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-size: 13px;
}
}
+ /* The asset store (worlds.ts loadAssets). */
+ .badge.prefab {
+ color: var(--good);
+ border-color: rgba(52, 211, 153, 0.45);
+ }
+ .badge.script {
+ color: var(--accent-light);
+ border-color: var(--accent-light);
+ }
+ .kinds button[aria-pressed='true'] {
+ border-color: var(--accent-light);
+ color: var(--accent-light);
+ }
+ #assets {
+ margin-top: 14px;
+ }
+ #assets-more-row {
+ margin-top: 14px;
+ }
+ .row.pick {
+ justify-content: space-between;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+ }
</style>
</style>
<script type="module" src="/src/pages/worlds.ts"></script>
<script type="module" src="/src/pages/worlds.ts"></script>
</head>
</head>
@@ -412,6 +438,24 @@
@@ -412,6 +438,24 @@
<button type="button" id="more">More worlds</button>
<button type="button" id="more">More worlds</button>
</div>
</div>
+ <h2 id="assets-title">Asset store</h2>
+ <p class="lede">
+ Builds and scripts other creators share, free. Use one in a world you build: a build lands
+ where you look in the editor, a script joins the world's scripts. Share your own from the
+ editor (select pieces, then Store) or from a world's Scripts.
+ </p>
+ <div class="row kinds" role="group" aria-label="Show">
+ <button type="button" data-kind="all" aria-pressed="true">Everything</button>
+ <button type="button" data-kind="prefab" aria-pressed="false">Builds</button>
+ <button type="button" data-kind="script" aria-pressed="false">Scripts</button>
+ <button type="button" data-kind="mine" aria-pressed="false" hidden>Yours</button>
+ </div>
+ <div class="grid" id="assets" aria-labelledby="assets-title"></div>
+ <p class="status" id="assets-status" role="status">Loading assets…</p>
+ <div class="row" id="assets-more-row" hidden>
+ <button type="button" id="assets-more">More assets</button>
+ </div>
+
<p class="note">
<p class="note">
Creator worlds use the editor's built-in materials for now; uploaded textures and models
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
stay with staff maps. Characters are not saved inside someone's world, so nothing earned
@@ -75,6 +75,8 @@ export interface ServerConfig {
@@ -75,6 +75,8 @@ export interface ServerConfig {
creatorWorldsMax: number
creatorWorldsMax: number
/** GAMES_CREATOR_WORLDS_PER_PERSON: worlds one person can keep. */
/** GAMES_CREATOR_WORLDS_PER_PERSON: worlds one person can keep. */
creatorWorldsPerPerson: number
creatorWorldsPerPerson: number
+ /** GAMES_CREATOR_ASSETS_PER_PERSON: builds and scripts one person keeps in the asset store. */
+ creatorAssetsPerPerson: number
/** GAMES_CREATOR_WORLD_IDLE_SECONDS: an empty creator world stops after this long. */
/** GAMES_CREATOR_WORLD_IDLE_SECONDS: an empty creator world stops after this long. */
creatorWorldIdleSeconds: number
creatorWorldIdleSeconds: number
/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */
/** GAMES_CREATOR_WORLD_PLAYERS: players per creator world. */
@@ -232,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -232,6 +234,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
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),
creatorWorldsMax: intEnv(env, 'GAMES_CREATOR_WORLDS_MAX', 2),
creatorWorldsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_WORLDS_PER_PERSON', 5),
creatorWorldsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_WORLDS_PER_PERSON', 5),
+ creatorAssetsPerPerson: positiveIntEnv(env, 'GAMES_CREATOR_ASSETS_PER_PERSON', 50),
creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),
creatorWorldIdleSeconds: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_IDLE_SECONDS', 180),
creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),
creatorWorldPlayers: positiveIntEnv(env, 'GAMES_CREATOR_WORLD_PLAYERS', 16),
creatorScripts: env.GAMES_CREATOR_SCRIPTS === '1' || env.GAMES_CREATOR_SCRIPTS === 'true',
creatorScripts: env.GAMES_CREATOR_SCRIPTS === '1' || env.GAMES_CREATOR_SCRIPTS === 'true',
@@ -17,6 +17,7 @@ import {
@@ -17,6 +17,7 @@ import {
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
import {
import {
InstanceRegistry,
InstanceRegistry,
+ createCreatorAssetStore,
createCreatorWorldStore,
createCreatorWorldStore,
openPgStore,
openPgStore,
type InstanceLease,
type InstanceLease,
@@ -89,6 +90,7 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
@@ -89,6 +90,7 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'
import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
+import { createAssetRoutes } from './worlds/assetRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamRoutes } from './steam/steamPage.js'
import { createSteamRoutes } from './steam/steamPage.js'
@@ -843,6 +845,20 @@ async function main(): Promise<void> {
@@ -843,6 +845,20 @@ async function main(): Promise<void> {
events: { db, sink, siteUrl: gamesSiteUrl },
events: { db, sink, siteUrl: gamesSiteUrl },
...(worldSearch ? { search: worldSearch } : {}),
...(worldSearch ? { search: worldSearch } : {}),
})
})
+ // The asset store's JSON API (worlds/assetRoutes.ts): builds and scripts creators publish for others' worlds.
+ const serveAssets = createAssetRoutes({
+ store: createCreatorAssetStore(db),
+ worlds: creatorWorlds,
+ resolveUser: (token) =>
+ config.networkAuthUrl
+ ? resolveNetworkUser(config.networkAuthUrl, token)
+ : Promise.resolve(null),
+ limits: actorLimits,
+ address: clientAddress,
+ log: log.child({ system: 'assets' }),
+ perPerson: config.creatorAssetsPerPerson,
+ events: { db, sink },
+ })
// The home page's live sections (net/portalHome.ts): players online, Play's boards and achievements, Vibe Heroes'
// The home page's live sections (net/portalHome.ts): players online, Play's boards and achievements, Vibe Heroes'
// furthest teams, creator worlds and Steam's lists, built at most once a minute.
// furthest teams, creator worlds and Steam's lists, built at most once a minute.
const playTitlesUrl = config.platform.playUrl
const playTitlesUrl = config.platform.playUrl
@@ -986,6 +1002,7 @@ async function main(): Promise<void> {
@@ -986,6 +1002,7 @@ async function main(): Promise<void> {
}
}
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorlds(req, res)) return true
+ if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveSteam(req, res)) return true
if (serveSteam(req, res)) return true
if (
if (
@@ -119,6 +119,7 @@ describe('account export and deletion (ADR-033)', () => {
@@ -119,6 +119,7 @@ describe('account export and deletion (ADR-033)', () => {
identity_rows: 0,
identity_rows: 0,
casual_saves: 0,
casual_saves: 0,
creator_worlds: 0,
creator_worlds: 0,
+ creator_assets: 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')
new file mode 100644
new file mode 100644
@@ -0,0 +1,268 @@
@@ -0,0 +1,268 @@
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { createRequire } from 'node:module'
+import { emptyMapV2 } from '@openvibe/content'
+import { createCreatorAssetStore, createCreatorWorldStore } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { createConsoleLogger } from '@openvibe/shared'
+import type { EventInput } from 'openvibe-sdk/events'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createGamesActorLimits } from '../net/actorLimits.js'
+import type { NetworkUser } from '../net/networkAuth.js'
+import { createAssetRoutes } from './assetRoutes.js'
+
+const { validate } = createRequire(import.meta.url)('openvibe-contracts') as {
+ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }
+}
+
+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 },
+}
+
+const BUILD = {
+ statics: [
+ {
+ id: 'st-1',
+ shape: { type: 'box', size: [2, 2, 2] },
+ pos: [-1, 0, 0],
+ yaw: 0,
+ color: '#8a8d90',
+ },
+ {
+ id: 'st-2',
+ shape: { type: 'box', size: [2, 2, 2] },
+ pos: [1, 0, 0],
+ yaw: 0,
+ color: '#9a9187',
+ },
+ ],
+ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [0, 0, 3] }],
+ props: [],
+ lights: [{ id: 'li-1', type: 'point', pos: [0, 4, 0] }],
+}
+const SCRIPT = {
+ format: 'games-quickjs@1',
+ id: 'welcome',
+ version: '1.0.0',
+ entry: 'welcome.js',
+ source: "function onPlayerJoin(e) { game.message(e.player.id, 'Welcome!') }",
+ hooks: ['onPlayerJoin'],
+ capabilities: ['players.read', 'player.message'],
+}
+
+let server: Server | null = null
+afterEach(async () => {
+ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()))
+ server = null
+})
+
+async function setup(perPerson = 3) {
+ const db = await openTestDb()
+ const worlds = createCreatorWorldStore(db)
+ const events: EventInput[] = []
+ const handler = createAssetRoutes({
+ store: createCreatorAssetStore(db),
+ worlds,
+ resolveUser: async (token) => USERS[token] ?? null,
+ limits: createGamesActorLimits({ env: {} }),
+ address: () => '127.0.0.1',
+ log: createConsoleLogger({ app: 'asset-routes-test' }, 'error'),
+ perPerson,
+ events: {
+ db,
+ sink: {
+ enabled: true,
+ enqueue: async (_t, event) => {
+ expect(db.inTransaction()).toBe(true)
+ events.push(event)
+ },
+ },
+ },
+ })
+ 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) => {
+ const resp = await fetch(`${base}${path}`, {
+ method,
+ headers: {
+ ...(as ? { authorization: `Bearer ${as}` } : {}),
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
+ },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ })
+ const text = await resp.text()
+ return {
+ status: resp.status,
+ body: (text ? JSON.parse(text) : null) as Record<string, unknown> & {
+ assets?: Record<string, unknown>[]
+ issues?: string[]
+ },
+ }
+ }
+ return { db, worlds, events, call }
+}
+
+describe('asset store routes', () => {
+ it('publishes a build and a script, lists them by kind, and keeps bodies out of the list', async () => {
+ const { worlds, call } = await setup()
+ const world = await worlds.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Moon base',
+ summary: '',
+ map: emptyMapV2(),
+ now: 1,
+ })
+ expect(
+ (await call('POST', '/api/assets', undefined, { kind: 'prefab', title: 'x', body: BUILD }))
+ .status,
+ ).toBe(401)
+ expect(
+ (await call('POST', '/api/assets', 'guest', { kind: 'prefab', title: 'x', body: BUILD }))
+ .status,
+ ).toBe(401)
+ const build = await call('POST', '/api/assets', 'kim', {
+ kind: 'prefab',
+ title: ' Two crates and a tree ',
+ summary: 'A camp corner',
+ body: BUILD,
+ from_world: world.id,
+ })
+ expect(build.status).toBe(201)
+ expect(build.body).toMatchObject({
+ kind: 'prefab',
+ title: 'Two crates and a tree',
+ owner: 'kim',
+ status: 'published',
+ uses: 0,
+ contents: { statics: 2, nodes: 1, props: 0, lights: 1, pieces: 4 },
+ from_world: { id: world.id, title: 'Moon base', url: `/worlds/${world.id}` },
+ })
+ expect(build.body.id).toMatch(/^a[0-9a-z]{10}$/)
+ // A credit names a world you build: someone else's is refused.
+ expect(
+ (
+ await call('POST', '/api/assets', 'lee', {
+ kind: 'script',
+ title: 'Hi',
+ body: SCRIPT,
+ from_world: world.id,
+ })
+ ).body.error,
+ ).toBe('invalid_world')
+ const script = await call('POST', '/api/assets', 'lee', {
+ kind: 'script',
+ title: 'Welcome',
+ body: SCRIPT,
+ })
+ expect(script.status).toBe(201)
+ expect(script.body.contents).toEqual({
+ hooks: ['onPlayerJoin'],
+ capabilities: ['players.read', 'player.message'],
+ })
+
+ const all = await call('GET', '/api/assets')
+ expect(all.body.assets?.map((a) => a.title)).toEqual(['Welcome', 'Two crates and a tree'])
+ expect(all.body.assets?.[0]).not.toHaveProperty('body')
+ expect(all.body.assets?.[0]).not.toHaveProperty('status')
+ expect((await call('GET', '/api/assets?kind=prefab')).body.assets?.map((a) => a.kind)).toEqual([
+ 'prefab',
+ ])
+ expect((await call('GET', '/api/assets?kind=sound')).status).toBe(400)
+ const one = await call('GET', `/api/assets/${String(build.body.id)}`)
+ expect((one.body.body as { statics: unknown[] }).statics).toHaveLength(2)
+ const mine = await call('GET', '/api/assets/mine', 'kim')
+ expect(mine.body).toMatchObject({ max: 3, staff: false })
+ expect(mine.body.assets?.map((a) => a.status)).toEqual(['published'])
+ })
+
+ it('refuses what a creator world would refuse, and past the per-person limit', async () => {
+ const { call } = await setup(1)
+ const model = await call('POST', '/api/assets', 'kim', {
+ kind: 'prefab',
+ title: 'Statue',
+ body: { statics: [{ ...BUILD.statics[0], model: 'm1' }] },
+ })
+ expect(model.status).toBe(422)
+ expect(model.body.error).toBe('asset_invalid')
+ const badScript = await call('POST', '/api/assets', 'kim', {
+ kind: 'script',
+ title: 'Bad',
+ body: { ...SCRIPT, capabilities: ['network.fetch'] },
+ })
+ expect(badScript.status).toBe(422)
+ expect(
+ (await call('POST', '/api/assets', 'kim', { kind: 'sound', title: 'Boom', body: {} })).body
+ .issues,
+ ).toEqual(['kind: prefab or script'])
+ expect(
+ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: '', body: BUILD })).body
+ .error,
+ ).toBe('invalid_title')
+ expect(
+ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: 'One', body: BUILD }))
+ .status,
+ ).toBe(201)
+ const full = await call('POST', '/api/assets', 'kim', {
+ kind: 'prefab',
+ title: 'Two',
+ body: BUILD,
+ })
+ expect(full.status).toBe(409)
+ expect(full.body.error).toBe('asset_limit')
+ })
+
+ it('counts a use once per person and never its creator; staff hide and restore with an event; owner deletes', async () => {
+ const { call, events } = await setup()
+ const id = String(
+ (await call('POST', '/api/assets', 'kim', { kind: 'prefab', title: 'Camp', body: BUILD }))
+ .body.id,
+ )
+ expect((await call('POST', `/api/assets/${id}/use`)).status).toBe(401)
+ const used = await call('POST', `/api/assets/${id}/use`, 'lee')
+ expect(used.status).toBe(200)
+ expect(used.body.uses).toBe(1)
+ expect((used.body.body as { nodes: unknown[] }).nodes).toHaveLength(1)
+ expect((await call('POST', `/api/assets/${id}/use`, 'lee')).body.uses).toBe(1)
+ expect((await call('POST', `/api/assets/${id}/use`, 'kim')).body.uses).toBe(1)
+
+ // The creator renames it; nobody else may, and the creator cannot hide or unhide it.
+ expect(
+ (await call('PATCH', `/api/assets/${id}`, 'kim', { title: 'Camp corner' })).body.title,
+ ).toBe('Camp corner')
+ expect((await call('PATCH', `/api/assets/${id}`, 'lee', { title: 'Mine now' })).status).toBe(
+ 404,
+ )
+ expect((await call('PATCH', `/api/assets/${id}`, 'kim', { status: 'hidden' })).status).toBe(403)
+
+ const hidden = await call('PATCH', `/api/assets/${id}`, 'staff', { status: 'hidden' })
+ expect(hidden.body.status).toBe('hidden')
+ expect(events.map((e) => (e.payload as { action: string }).action)).toEqual(['asset.hidden'])
+ expect(validate('games.moderation.action@1', events[0]?.payload).valid).toBe(true)
+ expect((await call('GET', '/api/assets')).body.assets).toEqual([])
+ expect((await call('GET', `/api/assets/${id}`)).status).toBe(404)
+ expect((await call('POST', `/api/assets/${id}/use`, 'lee')).status).toBe(404)
+ expect((await call('GET', `/api/assets/${id}`, 'kim')).body.status).toBe('hidden')
+ await call('PATCH', `/api/assets/${id}`, 'staff', { status: 'published' })
+ expect(events.map((e) => (e.payload as { action: string }).action)).toEqual([
+ 'asset.hidden',
+ 'asset.restored',
+ ])
+
+ expect((await call('DELETE', `/api/assets/${id}`, 'lee')).status).toBe(404)
+ expect((await call('DELETE', `/api/assets/${id}`, 'kim')).status).toBe(204)
+ expect((await call('GET', `/api/assets/${id}`)).status).toBe(404)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,353 @@
@@ -0,0 +1,353 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import {
+ CREATOR_ASSET_ID,
+ CREATOR_ASSET_LIMITS,
+ CREATOR_SCRIPT_LIMITS,
+ CREATOR_WORLD_ID,
+ creatorAssetOf,
+ prefabCounts,
+ type PrefabBody,
+ type ScriptMod,
+} from '@openvibe/content'
+import type {
+ CreatorAsset,
+ CreatorAssetKind,
+ CreatorAssetStatus,
+ CreatorAssetStore,
+ CreatorWorldStore,
+} from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { Db } from 'openvibe-sdk/db'
+import { actorFor, moderationEvent, type EventSink } from '../platform/gameEvents.js'
+import type { GamesActorLimits } from '../net/actorLimits.js'
+import type { NetworkUser } from '../net/networkAuth.js'
+import {
+ RouteError,
+ cleanText,
+ fail,
+ isGamesStaff,
+ isStaff,
+ parseJson,
+ readBody,
+} from './worldRoutes.js'
+
+/**
+ * /api/assets: the asset store (migration 0016; docs/platform-direction.md step 5, free first). Builds (pieces placed
+ * in the editor) and scripts (one games-quickjs@1 mod) that a signed-in creator publishes for anyone to use in their
+ * own worlds. Anyone reads the store; using an asset counts once per person; staff hide one.
+ *
+ * GET /api/assets[?kind=prefab|script&page=] published assets, newest first (no bodies)
+ * GET /api/assets/mine the caller's assets
+ * POST /api/assets { kind, title, summary?, body, from_world? } publish (from_world: a world you build, credited)
+ * GET /api/assets/:id one asset with its body (published, or yours, or staff)
+ * POST /api/assets/:id/use its body, and the caller's use counted once
+ * PATCH /api/assets/:id { title?, summary?, status? } owner: title, summary; staff: hidden|published
+ * DELETE /api/assets/:id the owner or games staff
+ */
+
+export interface AssetRouteOptions {
+ store: CreatorAssetStore
+ /** To credit the world a build or script came from (one the caller builds). */
+ worlds: CreatorWorldStore
+ resolveUser: (token: string) => Promise<NetworkUser | null>
+ limits: GamesActorLimits
+ address: (req: IncomingMessage) => string
+ log: Logger
+ perPerson: number
+ /** games.moderation.action when staff hide or restore someone else's asset, in the same transaction. */
+ events?: { db: Db; sink: EventSink }
+ now?: () => number
+}
+
+const PAGE_SIZE = 24
+const META_BYTES = 16 * 1024
+const TITLE_MAX = 60
+const SUMMARY_MAX = 280
+
+/** What a card shows: a build's pieces, or a script's hooks and what it may do. */
+function contents(asset: CreatorAsset) {
+ if (asset.kind === 'prefab') return prefabCounts(asset.body as PrefabBody)
+ const mod = asset.body as ScriptMod
+ return { hooks: mod.hooks ?? [], capabilities: mod.capabilities ?? [] }
+}
+
+function publicView(asset: CreatorAsset) {
+ return {
+ id: asset.id,
+ kind: asset.kind,
+ title: asset.title,
+ summary: asset.summary,
+ owner: asset.ownerName,
+ uses: asset.uses,
+ contents: contents(asset),
+ from_world: asset.fromWorld
+ ? {
+ id: asset.fromWorld.id,
+ title: asset.fromWorld.title,
+ url: `/worlds/${asset.fromWorld.id}`,
+ }
+ : null,
+ created_at: new Date(asset.createdAt).toISOString(),
+ updated_at: new Date(asset.updatedAt).toISOString(),
+ }
+}
+
+function ownerView(asset: CreatorAsset) {
+ return { ...publicView(asset), status: asset.status }
+}
+
+export function createAssetRoutes(opts: AssetRouteOptions) {
+ const now = opts.now ?? Date.now
+
+ const send = (res: ServerResponse, status: number, body: unknown) => {
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end(JSON.stringify(body))
+ }
+
+ const caller = async (req: IncomingMessage): Promise<NetworkUser | null> => {
+ const token = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null
+ 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 }
+ }
+
+ /** An asset the caller may see: published, or theirs, or staff. Anything else is not found. */
+ const visible = async (id: string, user: NetworkUser | null): Promise<CreatorAsset> => {
+ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null
+ if (!asset) return fail(404, 'asset_not_found')
+ if (asset.status === 'published' || isStaff(user) || asset.ownerSubject === user?.subjectId)
+ return asset
+ return fail(404, 'asset_not_found')
+ }
+
+ const publicRead = async (req: IncomingMessage, res: ServerResponse): Promise<boolean> =>
+ opts.limits.publicRead(req, res, `ip:${opts.address(req)}`)
+
+ async function route(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
+ const path = url.pathname
+ const method = req.method ?? 'GET'
+
+ if (path === '/api/assets' && method === 'GET') {
+ if (!(await publicRead(req, res))) return
+ const page = Number(url.searchParams.get('page') ?? '1')
+ const kind = url.searchParams.get('kind')
+ if (!Number.isSafeInteger(page) || page < 1 || page > 1000) fail(400, 'invalid_query')
+ if (kind !== null && kind !== 'prefab' && kind !== 'script') fail(400, 'invalid_query')
+ const rows = await opts.store.listPublished(
+ kind as CreatorAssetKind | null,
+ PAGE_SIZE + 1,
+ (page - 1) * PAGE_SIZE,
+ )
+ send(res, 200, {
+ assets: rows.slice(0, PAGE_SIZE).map(publicView),
+ page,
+ more: rows.length > PAGE_SIZE,
+ })
+ return
+ }
+
+ if (path === '/api/assets/mine' && method === 'GET') {
+ const user = await signedIn(req)
+ const assets = await opts.store.listByOwner(user.subjectId)
+ send(res, 200, {
+ assets: assets.map(ownerView),
+ max: opts.perPerson,
+ limits: { build: CREATOR_ASSET_LIMITS, script: CREATOR_SCRIPT_LIMITS },
+ staff: isStaff(user),
+ })
+ return
+ }
+
+ if (path === '/api/assets' && method === 'POST') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const raw = parseJson(
+ (await readBody(req, CREATOR_ASSET_LIMITS.bytes + CREATOR_SCRIPT_LIMITS.bytes + META_BYTES))
+ .text,
+ ) as Record<string, unknown> | null
+ const title = cleanText(raw?.title, TITLE_MAX)
+ const summary = raw?.summary === undefined ? '' : cleanText(raw.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.` })
+ const checked = creatorAssetOf(raw?.kind, raw?.body)
+ if (!checked.kind || !checked.body)
+ fail(422, 'asset_invalid', { issues: checked.issues.slice(0, 40) })
+ // A credit names a world the caller builds (theirs or their team's), as it is now.
+ let fromWorld: { id: string; title: string } | null = null
+ if (raw?.from_world !== undefined && raw.from_world !== null) {
+ const wid = String(raw.from_world)
+ const world = CREATOR_WORLD_ID.test(wid) ? await opts.worlds.get(wid) : null
+ const builds =
+ !!world &&
+ (world.ownerSubject === user.subjectId ||
+ (await opts.worlds.isMember(world.id, user.subjectId)))
+ if (!world || !builds)
+ fail(422, 'invalid_world', { detail: 'Name one of the worlds you build.' })
+ fromWorld = { id: (world as { id: string }).id, title: (world as { title: string }).title }
+ }
+ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)
+ fail(409, 'asset_limit', {
+ detail: `You can keep ${opts.perPerson} assets in the store. Delete one to publish another.`,
+ })
+ const asset = await opts.store.create({
+ ownerSubject: user.subjectId,
+ ownerName: user.name,
+ kind: checked.kind as CreatorAssetKind,
+ title: title as string,
+ summary: summary as string,
+ body: checked.body,
+ fromWorld,
+ now: now(),
+ })
+ void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
+ opts.log.info('creator asset published', { asset: asset.id, kind: asset.kind })
+ send(res, 201, { ...ownerView(asset), body: asset.body })
+ return
+ }
+
+ const match = /^\/api\/assets\/([^/]+)(\/use)?$/.exec(path)
+ if (!match) fail(404, 'not_found')
+ const id = match?.[1] ?? ''
+ const isUse = match?.[2] === '/use'
+
+ if (isUse && method === 'POST') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const asset = await visible(id, user)
+ // Its creator using it is not a use; anyone else counts once.
+ if (asset.ownerSubject !== user.subjectId)
+ await opts.store.countUse(asset.id, user.subjectId, now())
+ const fresh = (await opts.store.get(asset.id)) ?? asset
+ send(res, 200, { ...publicView(fresh), body: fresh.body })
+ return
+ }
+
+ if (!isUse && method === 'GET') {
+ const user = await caller(req)
+ if (!user && !(await publicRead(req, res))) return
+ const asset = await visible(id, user)
+ const mine = user?.subjectId === asset.ownerSubject
+ send(res, 200, {
+ ...(mine || isStaff(user) ? ownerView(asset) : publicView(asset)),
+ body: asset.body,
+ })
+ return
+ }
+
+ if (!isUse && method === 'PATCH') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const raw = parseJson((await readBody(req, META_BYTES)).text) as Record<
+ string,
+ unknown
+ > | null
+ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null
+ const mine = !!asset && asset.ownerSubject === user.subjectId
+ if (!asset || (!mine && !isStaff(user))) return fail(404, 'asset_not_found')
+ const patch: { title?: string; summary?: string; status?: CreatorAssetStatus } = {}
+ if (raw?.title !== undefined) {
+ if (!mine) fail(403, 'owner_only')
+ const title = cleanText(raw.title, TITLE_MAX)
+ if (!title)
+ fail(422, 'invalid_title', { detail: `A title of 1 to ${TITLE_MAX} characters.` })
+ patch.title = title as string
+ }
+ if (raw?.summary !== undefined) {
+ if (!mine) fail(403, 'owner_only')
+ const summary = cleanText(raw.summary, SUMMARY_MAX)
+ if (summary === null)
+ fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
+ patch.summary = summary as string
+ }
+ if (raw?.status !== undefined) {
+ // Staff take an asset down or put it back; its creator cannot bring back one staff took down.
+ if (raw.status !== 'published' && raw.status !== 'hidden') fail(422, 'invalid_status')
+ if (!isStaff(user)) fail(403, 'staff_only')
+ patch.status = raw.status as CreatorAssetStatus
+ }
+ const events = opts.events?.sink.enabled ? opts.events : null
+ const moderated = !mine && patch.status !== undefined && patch.status !== asset.status
+ const apply = () => opts.store.update(id, patch, now())
+ const updated =
+ events && moderated
+ ? await events.db.tx(async (t) => {
+ const next = await apply()
+ await events.sink.enqueue(
+ t,
+ moderationEvent(
+ patch.status === 'hidden' ? 'asset.hidden' : 'asset.restored',
+ { type: 'asset', id, ownerSubject: asset.ownerSubject },
+ actorFor(user.subjectId),
+ { details: { from: asset.status, to: patch.status, kind: asset.kind } },
+ ),
+ )
+ return next
+ })
+ : await apply()
+ if (!updated) fail(404, 'asset_not_found')
+ if (moderated) opts.log.info('creator asset status', { asset: id, to: patch.status })
+ send(res, 200, ownerView(updated as CreatorAsset))
+ return
+ }
+
+ if (!isUse && method === 'DELETE') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const asset = CREATOR_ASSET_ID.test(id) ? await opts.store.get(id) : null
+ if (!asset || (asset.ownerSubject !== user.subjectId && !isGamesStaff(user)))
+ return fail(404, 'asset_not_found')
+ const byStaff = asset.ownerSubject !== user.subjectId
+ const events = opts.events?.sink.enabled ? opts.events : null
+ if (events && byStaff)
+ await events.db.tx(async (t) => {
+ await opts.store.remove(id)
+ await events.sink.enqueue(
+ t,
+ moderationEvent(
+ 'asset.deleted',
+ { type: 'asset', id, ownerSubject: asset.ownerSubject },
+ actorFor(user.subjectId),
+ { details: { kind: asset.kind } },
+ ),
+ )
+ })
+ else await opts.store.remove(id)
+ opts.log.info('creator asset deleted', { asset: id, staff: byStaff })
+ res.writeHead(204, { 'cache-control': 'no-store' })
+ res.end()
+ return
+ }
+
+ fail(405, 'method_not_allowed')
+ }
+
+ /** true when the request was /api/assets… (answered, or being answered). */
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const url = new URL(req.url ?? '/', 'http://games.local')
+ if (url.pathname !== '/api/assets' && !url.pathname.startsWith('/api/assets/')) 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('assets request failed', {
+ error: String((err as Error | undefined)?.message ?? err),
+ })
+ send(res, 500, { error: 'internal' })
+ })
+ return true
+ }
+}
@@ -91,13 +91,13 @@ type Problem = {
@@ -91,13 +91,13 @@ type Problem = {
revision?: string
revision?: string
}
}
-class RouteError extends Error {
+export class RouteError extends Error {
constructor(readonly problem: Problem) {
constructor(readonly problem: Problem) {
super(problem.error)
super(problem.error)
}
}
}
}
-const fail = (
+export const fail = (
status: number,
status: number,
error: string,
error: string,
extra: Omit<Problem, 'status' | 'error'> = {},
extra: Omit<Problem, 'status' | 'error'> = {},
@@ -116,11 +116,11 @@ export function cleanText(value: unknown, max: number): string | null {
@@ -116,11 +116,11 @@ export function cleanText(value: unknown, max: number): string | null {
return text.length > max ? null : text
return text.length > max ? null : text
}
}
-function isStaff(user: NetworkUser | null): boolean {
+export function isStaff(user: NetworkUser | null): boolean {
return user?.rank === 'owner' || user?.rank === 'admin' || user?.rank === 'moderator'
return user?.rank === 'owner' || user?.rank === 'admin' || user?.rank === 'moderator'
}
}
-function isGamesStaff(user: NetworkUser | null): boolean {
+export function isGamesStaff(user: NetworkUser | null): boolean {
return user?.rank === 'owner' || user?.rank === 'admin'
return user?.rank === 'owner' || user?.rank === 'admin'
}
}
@@ -168,7 +168,10 @@ export function parseIfMatch(header: string | string[] | undefined): string | nu
@@ -168,7 +168,10 @@ export function parseIfMatch(header: string | string[] | undefined): string | nu
return value || null
return value || null
}
}
-function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {
+export function readBody(
+ req: IncomingMessage,
+ max: number,
+): Promise<{ text: string; bytes: number }> {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const chunks: Buffer[] = []
let size = 0
let size = 0
@@ -188,7 +191,7 @@ function readBody(req: IncomingMessage, max: number): Promise<{ text: string; by
@@ -188,7 +191,7 @@ function readBody(req: IncomingMessage, max: number): Promise<{ text: string; by
})
})
}
}
-function parseJson(text: string): unknown {
+export function parseJson(text: string): unknown {
try {
try {
return JSON.parse(text) as unknown
return JSON.parse(text) as unknown
} catch {
} catch {
@@ -103,6 +103,19 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -103,6 +103,19 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
team (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).
team (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).
Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge and
Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge and
deletion. `/api/worlds/mine` lists them as `team`.
deletion. `/api/worlds/mine` lists them as `team`.
+- **Asset store:** builds and scripts creators share, free, for anyone's worlds (migration 0016; `/api/assets`,
+ the **Asset store** section of `/worlds` and the editor's **Store** tab; up to `GAMES_CREATOR_ASSETS_PER_PERSON`
+ (50) each). A **build** is pieces chosen in the editor (boxes and other statics, resource nodes, props and lights)
+ with positions relative to the middle of their footprint at its lowest point (`prefabFromObjects`). It passes the
+ same checks as a creator map, with smaller limits (`CREATOR_ASSET_LIMITS`: 256 KB, 400 statics, 100 nodes and
+ props, 16 lights, within 256 m of its origin). A **script** is one `games-quickjs@1` mod checked as a world's own.
+ Sharing credits the world it came from (one you build). Using a build opens the editor of a world you build at
+ `/editor?world=<id>&asset=<id>`, which places it where the camera looks, with new ids, as one undo step. Using a
+ script adds it to the world's scripts, renamed `…-2` when the name is taken, within the world's four. A use counts
+ once per person (`POST /api/assets/:id/use`; never the creator's own). The creator renames or deletes an asset
+ (worlds keep their copy); staff take one down (`asset.hidden`, `asset.restored`, `asset.deleted` as
+ `games.moderation.action`). Assets and uses follow account export, merge and deletion. Uploaded models, textures
+ and sounds wait for the Pics safety pipeline, and paid assets for the owner's prices.
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
@@ -65,6 +65,8 @@ Already here:
@@ -65,6 +65,8 @@ Already here:
- Moderation: staff can switch a world's scripts off.
- Moderation: staff can switch a world's scripts off.
4. **Teams.** A world gets editors besides its owner. The collaboration socket (`/editor-ws`, staff-only today) opens to a world's team.
4. **Teams.** A world gets editors besides its owner. The collaboration socket (`/editor-ws`, staff-only today) opens to a world's team.
5. **The asset store.** Creators publish models, materials, sounds and scripts for others to use and remix, free first.
5. **The asset store.** Creators publish models, materials, sounds and scripts for others to use and remix, free first.
+ - Done for builds and scripts (migration 0016): pieces placed in the editor and `games-quickjs@1` mods, shared from
+ the editor's Store tab or a world's Scripts, used in any world you build (see `games.md` "Asset store").
- Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.
- Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.
- Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.
- Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.
6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.
6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.
@@ -23,6 +23,7 @@ export * from './defs/skills.js'
@@ -23,6 +23,7 @@ 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'
export * from './packs/creatorWorld.js'
+export * from './packs/creatorAsset.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,107 @@
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest'
+import {
+ CREATOR_ASSET_ID,
+ CREATOR_ASSET_LIMITS,
+ creatorAssetOf,
+ creatorPrefabOf,
+ creatorScriptAssetOf,
+ placePrefab,
+ prefabCounts,
+ prefabFromObjects,
+ type PrefabBody,
+} from './creatorAsset.js'
+
+const box = (id: string, pos: [number, number, number]) => ({
+ id,
+ shape: { type: 'box' as const, size: [2, 2, 2] as [number, number, number] },
+ pos,
+ yaw: 0,
+ color: '#8a8d90',
+})
+
+const script = {
+ format: 'games-quickjs@1',
+ id: 'welcome',
+ version: '1.0.0',
+ entry: 'welcome.js',
+ source: "function onPlayerJoin(e) { game.message(e.player.id, 'hi') }",
+ hooks: ['onPlayerJoin'],
+ capabilities: ['players.read', 'player.message'],
+}
+
+describe('creator assets', () => {
+ it('names assets like worlds, with an a', () => {
+ expect(CREATOR_ASSET_ID.test('a0123456789')).toBe(true)
+ expect(CREATOR_ASSET_ID.test('w0123456789')).toBe(false)
+ expect(CREATOR_ASSET_ID.test('A0123456789')).toBe(false)
+ })
+
+ it('makes a build from chosen pieces around the middle of their footprint, on its lowest point', () => {
+ const body = prefabFromObjects({
+ statics: [box('st-1', [10, 2, 20]), box('st-2', [14, 5, 24])],
+ lights: [{ id: 'li-1', type: 'point', pos: [12, 8, 22] }],
+ })
+ expect(body.statics.map((s) => s.pos)).toEqual([
+ [-2, 0, -2],
+ [2, 3, 2],
+ ])
+ expect(body.lights[0]?.pos).toEqual([0, 6, 0])
+ expect(body.statics[0]?.id).toBe('st-1')
+ expect(prefabCounts(body)).toEqual({ statics: 2, nodes: 0, props: 0, lights: 1, pieces: 3 })
+ })
+
+ it('checks a build as a creator world map: built-in pieces pass, uploads, paint and NPC-free rules hold', () => {
+ const ok = creatorPrefabOf(prefabFromObjects({ statics: [box('st-1', [0, 0, 0])] }))
+ expect(ok.issues).toEqual([])
+ expect(ok.body?.v).toBe(1)
+ const model = creatorPrefabOf({ statics: [{ ...box('st-1', [0, 0, 0]), model: 'm1' }] })
+ expect(model.body).toBeNull()
+ const painted = creatorPrefabOf({
+ statics: [
+ {
+ ...box('st-1', [0, 0, 0]),
+ surface: { base: {}, paint: { layers: [{ id: 'l', tex: 'red_brick', channel: 'r' }] } },
+ },
+ ],
+ })
+ expect(painted.body).toBeNull()
+ expect(painted.issues.join(' ')).toMatch(/paint/)
+ expect(creatorPrefabOf({}).issues).toEqual(['body: a build has at least one piece'])
+ expect(creatorPrefabOf([]).issues[0]).toMatch(/an object/)
+ })
+
+ it('refuses a build past its limits or reaching too far from its origin', () => {
+ const many = Array.from({ length: CREATOR_ASSET_LIMITS.statics + 1 }, (_, i) =>
+ box(`st-${i}`, [0, 0, 0]),
+ )
+ expect(creatorPrefabOf({ statics: many }).issues.join(' ')).toMatch(/statics: 401 is more/)
+ const far = creatorPrefabOf({
+ statics: [box('st-far', [CREATOR_ASSET_LIMITS.reach + 1, 0, 0])],
+ })
+ expect(far.issues.join(' ')).toMatch(/st-far: more than 256 m/)
+ })
+
+ it('checks a script asset as a world script, and keeps it as written', () => {
+ const ok = creatorScriptAssetOf(script)
+ expect(ok.issues).toEqual([])
+ expect(ok.body).toMatchObject({ id: 'welcome', hooks: ['onPlayerJoin'] })
+ expect(ok.body && 'granted' in ok.body).toBe(false)
+ expect(creatorScriptAssetOf({ ...script, hooks: ['onEverything'] }).body).toBeNull()
+ expect(creatorAssetOf('script', script).kind).toBe('script')
+ expect(creatorAssetOf('sound', {}).issues).toEqual(['kind: prefab or script'])
+ })
+
+ it('places a build with new ids where it is put down', () => {
+ const body: PrefabBody = prefabFromObjects({
+ statics: [box('st-1', [0, 0, 0])],
+ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [3, 0, 0] }],
+ props: [{ id: 'pr-1', item: 'wooden_crate', pos: [0, 0, 3] }],
+ })
+ let n = 0
+ const placed = placePrefab(body, [100, 1, -50], (prefix) => `${prefix}-new-${n++}`)
+ expect(placed.map((p) => p.kind)).toEqual(['static', 'node', 'prop'])
+ expect(placed.map((p) => p.object.id)).toEqual(['st-new-0', 'no-new-1', 'pr-new-2'])
+ expect(placed[1]?.object.pos).toEqual([101.5, 1, -51.5])
+ expect(body.statics[0]?.id).toBe('st-1')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+import { emptyMapV2, parseMapFile, type MapFileV2 } from '../mapFileV2.js'
+import { creatorMapIssues, creatorScriptsOf } from './creatorWorld.js'
+import type { ScriptMod } from '../schema/scriptMod.js'
+
+/**
+ * The asset store (docs/platform-direction.md step 5, free first): what creators publish for others to use in their
+ * own worlds. Uploaded models, textures and sounds wait on the Pics safety pipeline, so an asset is made of what a
+ * creator world already takes:
+ *
+ * - a **build** (`prefab`): pieces placed in the editor (statics, resource nodes, props and lights) with their
+ * positions relative to the build's own origin, the centre of its footprint at ground level. It passes the same
+ * checks as a creator world's map (built-in materials only, no models, textures or paint), with smaller limits.
+ * - a **script** (`script`): one `games-quickjs@1` mod, checked exactly as a world's own scripts are.
+ *
+ * The server and the editor both use these: the server checks what it stores, the editor places a build with new
+ * ids where the person points (placePrefab).
+ */
+
+export type CreatorAssetKind = 'prefab' | 'script'
+export const CREATOR_ASSET_KINDS: readonly CreatorAssetKind[] = ['prefab', 'script']
+
+/** A creator asset id: `a` and ten lower-case base-36 characters. */
+export const CREATOR_ASSET_ID = /^a[0-9a-z]{10}$/
+
+export const CREATOR_ASSET_LIMITS = {
+ /** A build's JSON. */
+ bytes: 256 * 1024,
+ statics: 400,
+ nodes: 100,
+ props: 100,
+ lights: 16,
+ /** How far a piece may sit from the build's origin, in metres. */
+ reach: 256,
+} as const
+
+/** The pieces of a build, positions relative to its origin. */
+export interface PrefabBody {
+ v: 1
+ statics: MapFileV2['statics']
+ nodes: MapFileV2['nodes']
+ props: MapFileV2['props']
+ lights: MapFileV2['lights']
+}
+
+export type CreatorAssetBody = PrefabBody | ScriptMod
+
+/** What a build is made of, for lists and cards. */
+export function prefabCounts(body: PrefabBody): {
+ statics: number
+ nodes: number
+ props: number
+ lights: number
+ pieces: number
+} {
+ const statics = body.statics.length
+ const nodes = body.nodes.length
+ const props = body.props.length
+ const lights = body.lights.length
+ return { statics, nodes, props, lights, pieces: statics + nodes + props + lights }
+}
+
+const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
+
+/** A build, checked: the body to store (ids and positions as given), or the reasons it cannot be stored. */
+export function creatorPrefabOf(raw: unknown): { body: PrefabBody | null; issues: string[] } {
+ const r = (raw ?? {}) as Record<string, unknown>
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
+ return {
+ body: null,
+ issues: ['body: a build is an object with statics, nodes, props and lights'],
+ }
+ const bytes = new TextEncoder().encode(JSON.stringify(raw)).length
+ if (bytes > CREATOR_ASSET_LIMITS.bytes)
+ return {
+ body: null,
+ issues: [`body: ${bytes} bytes is more than the ${CREATOR_ASSET_LIMITS.bytes} a build takes`],
+ }
+ // The same checks as a creator world's map: the pieces go into an empty one.
+ const parsed = parseMapFile({
+ ...emptyMapV2(),
+ statics: list(r['statics']),
+ nodes: list(r['nodes']),
+ props: list(r['props']),
+ lights: list(r['lights']),
+ })
+ if (!parsed.ok) return { body: null, issues: parsed.issues.slice(0, 40) }
+ const map = parsed.map
+ const issues = creatorMapIssues(map, bytes)
+ const limit = (what: 'statics' | 'nodes' | 'props' | 'lights', count: number): void => {
+ if (count > CREATOR_ASSET_LIMITS[what])
+ issues.push(`${what}: ${count} is more than the ${CREATOR_ASSET_LIMITS[what]} a build takes`)
+ }
+ limit('statics', map.statics.length)
+ limit('nodes', map.nodes.length)
+ limit('props', map.props.length)
+ limit('lights', map.lights.length)
+ const body: PrefabBody = {
+ v: 1,
+ statics: map.statics,
+ nodes: map.nodes,
+ props: map.props,
+ lights: map.lights,
+ }
+ if (prefabCounts(body).pieces === 0) issues.push('body: a build has at least one piece')
+ const far = [...body.statics, ...body.nodes, ...body.props, ...body.lights].find((o) =>
+ o.pos.some((v) => Math.abs(v) > CREATOR_ASSET_LIMITS.reach),
+ )
+ if (far)
+ issues.push(
+ `${far.id}: more than ${CREATOR_ASSET_LIMITS.reach} m from the build's origin (positions are relative to it)`,
+ )
+ return issues.length ? { body: null, issues } : { body, issues }
+}
+
+/** A script asset, checked as a world's own script is: the mod to store, or the reasons it cannot be stored. */
+export function creatorScriptAssetOf(raw: unknown): { body: ScriptMod | null; issues: string[] } {
+ const checked = creatorScriptsOf([raw])
+ if (checked.issues.length) return { body: null, issues: checked.issues }
+ // Stored as the creator wrote it; a world that adds it sets `granted` when it loads.
+ const { granted: _granted, ...mod } = checked.scripts[0] as ScriptMod
+ return { body: mod as ScriptMod, issues: [] }
+}
+
+/** Either kind, by name. */
+export function creatorAssetOf(
+ kind: unknown,
+ raw: unknown,
+): { kind: CreatorAssetKind | null; body: CreatorAssetBody | null; issues: string[] } {
+ if (kind === 'prefab') return { kind, ...creatorPrefabOf(raw) }
+ if (kind === 'script') return { kind, ...creatorScriptAssetOf(raw) }
+ return { kind: null, body: null, issues: ['kind: prefab or script'] }
+}
+
+type Vec3 = [number, number, number]
+type Placed = { pos: Vec3; id: string }
+
+/**
+ * Pieces chosen in the editor as a build: their positions made relative to the centre of their footprint at its
+ * lowest point, so the build lands on the ground where it is placed. Ids are kept (placing gives new ones).
+ */
+export function prefabFromObjects(objects: {
+ statics?: readonly MapFileV2['statics'][number][]
+ nodes?: readonly MapFileV2['nodes'][number][]
+ props?: readonly MapFileV2['props'][number][]
+ lights?: readonly MapFileV2['lights'][number][]
+}): PrefabBody {
+ const all: Placed[] = [
+ ...(objects.statics ?? []),
+ ...(objects.nodes ?? []),
+ ...(objects.props ?? []),
+ ...(objects.lights ?? []),
+ ] as Placed[]
+ const axis = (i: 0 | 1 | 2) => all.map((o) => o.pos[i])
+ const mid = (values: number[]) =>
+ values.length ? (Math.min(...values) + Math.max(...values)) / 2 : 0
+ const origin: Vec3 = [mid(axis(0)), all.length ? Math.min(...axis(1)) : 0, mid(axis(2))]
+ const round = (v: number) => Math.round(v * 1000) / 1000
+ const shift = <T extends Placed>(o: T): T => ({
+ ...o,
+ pos: [round(o.pos[0] - origin[0]), round(o.pos[1] - origin[1]), round(o.pos[2] - origin[2])],
+ })
+ return {
+ v: 1,
+ statics: (objects.statics ?? []).map((o) => shift(o as never)),
+ nodes: (objects.nodes ?? []).map((o) => shift(o as never)),
+ props: (objects.props ?? []).map((o) => shift(o as never)),
+ lights: (objects.lights ?? []).map((o) => shift(o as never)),
+ }
+}
+
+/**
+ * A build placed at `at`: every piece with a new id (from `newId`, given the kind's short prefix) and its position
+ * moved by `at`. Answers the pieces by editor kind, ready to add to a document.
+ */
+export function placePrefab(
+ body: PrefabBody,
+ at: Vec3,
+ newId: (prefix: string) => string,
+): { kind: 'static' | 'node' | 'prop' | 'light'; object: Placed & Record<string, unknown> }[] {
+ const move = <T extends Placed>(o: T, prefix: string): T => ({
+ ...o,
+ id: newId(prefix),
+ pos: [o.pos[0] + at[0], o.pos[1] + at[1], o.pos[2] + at[2]],
+ })
+ return [
+ ...body.statics.map((o) => ({ kind: 'static' as const, object: move(o as never, 'st') })),
+ ...body.nodes.map((o) => ({ kind: 'node' as const, object: move(o as never, 'no') })),
+ ...body.props.map((o) => ({ kind: 'prop' as const, object: move(o as never, 'pr') })),
+ ...body.lights.map((o) => ({ kind: 'light' as const, object: move(o as never, 'li') })),
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- The asset store (docs/platform-direction.md step 5, free first): builds (pieces placed in the editor, positions
+-- relative to the build's origin) and scripts (one games-quickjs@1 mod) that creators publish for anyone to use in
+-- their own worlds. `body` is checked before it is stored (the content package's creatorAssetOf). Published at once;
+-- `hidden` is staff taking one down. `from_world` and `from_world_title` credit the world a build or script came
+-- from, as it was then. A use is counted once per person (creator_asset_uses), never by the asset's own creator.
+CREATE TABLE IF NOT EXISTS creator_assets (
+ id text COLLATE "C" PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL,
+ owner_name text NOT NULL,
+ kind text NOT NULL CHECK (kind IN ('prefab', 'script')),
+ title text NOT NULL,
+ summary text NOT NULL DEFAULT '',
+ body jsonb NOT NULL,
+ status text NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'hidden')),
+ uses integer NOT NULL DEFAULT 0,
+ from_world text COLLATE "C",
+ from_world_title text,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS creator_assets_owner ON creator_assets (owner_subject, created_at DESC);
+CREATE INDEX IF NOT EXISTS creator_assets_listed ON creator_assets (created_at DESC, id) WHERE status = 'published';
+CREATE TABLE IF NOT EXISTS creator_asset_uses (
+ asset_id text COLLATE "C" NOT NULL REFERENCES creator_assets(id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL,
+ used_at bigint NOT NULL,
+ PRIMARY KEY (asset_id, subject)
+);
+CREATE INDEX IF NOT EXISTS creator_asset_uses_subject ON creator_asset_uses (subject);
@@ -16,3 +16,13 @@ export {
@@ -16,3 +16,13 @@ export {
type CreatorWorldStatus,
type CreatorWorldStatus,
type CreatorWorldStore,
type CreatorWorldStore,
} from './pg/creatorWorlds.js'
} from './pg/creatorWorlds.js'
+export {
+ createCreatorAssetStore,
+ exportCreatorAssets,
+ eraseCreatorAssets,
+ moveCreatorAssets,
+ type CreatorAsset,
+ type CreatorAssetKind,
+ type CreatorAssetStatus,
+ type CreatorAssetStore,
+} from './pg/creatorAssets.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import { sql } from 'openvibe-sdk/db'
+import { openTestDb } from '../testing.js'
+import { createIdentityRepository } from './platformRepositories.js'
+import { createCreatorAssetStore } from './creatorAssets.js'
+
+const KIM = 'usr_01J0000000000000000000000K'
+const LEE = 'usr_01J0000000000000000000000L'
+const ZED = 'usr_01J0000000000000000000000Z'
+const BUILD = {
+ v: 1,
+ statics: [],
+ nodes: [{ id: 'no-1', node: 'oak_tree', pos: [0, 0, 0] }],
+ props: [],
+ lights: [],
+}
+const SCRIPT = {
+ format: 'games-quickjs@1',
+ id: 'welcome',
+ version: '1.0.0',
+ entry: 'welcome.js',
+ source: '',
+ hooks: [],
+}
+
+describe('creator assets store', () => {
+ it('publishes, lists by kind and owner, counts a use once per person, hides and removes', async () => {
+ const db = await openTestDb()
+ const store = createCreatorAssetStore(db)
+ const build = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ kind: 'prefab',
+ title: 'Oak',
+ summary: 'One tree',
+ body: BUILD,
+ fromWorld: { id: 'w0000000001', title: 'Moon base' },
+ now: 1000,
+ })
+ expect(build.id).toMatch(/^a[0-9a-z]{10}$/)
+ expect(build).toMatchObject({
+ status: 'published',
+ uses: 0,
+ fromWorld: { id: 'w0000000001', title: 'Moon base' },
+ })
+ expect(build.body).toEqual(BUILD)
+ const script = await store.create({
+ ownerSubject: LEE,
+ ownerName: 'lee',
+ kind: 'script',
+ title: 'Welcome',
+ summary: '',
+ body: SCRIPT,
+ now: 2000,
+ })
+ expect(script.fromWorld).toBeNull()
+
+ expect((await store.listPublished(null, 10, 0)).map((a) => a.id)).toEqual([script.id, build.id])
+ expect((await store.listPublished('prefab', 10, 0)).map((a) => a.id)).toEqual([build.id])
+ expect((await store.listPublished(null, 1, 1)).map((a) => a.id)).toEqual([build.id])
+ expect(await store.countByOwner(KIM)).toBe(1)
+ expect((await store.listByOwner(LEE)).map((a) => a.id)).toEqual([script.id])
+
+ expect(await store.countUse(build.id, LEE, 3000)).toBe(true)
+ expect(await store.countUse(build.id, LEE, 3001)).toBe(false)
+ expect((await store.get(build.id))?.uses).toBe(1)
+
+ const hidden = await store.update(build.id, { status: 'hidden' }, 4000)
+ expect(hidden?.status).toBe('hidden')
+ expect((await store.listPublished(null, 10, 0)).map((a) => a.id)).toEqual([script.id])
+ expect(
+ (await store.update(build.id, { title: 'Big oak', status: 'published' }, 4001))?.title,
+ ).toBe('Big oak')
+
+ await store.rename(KIM, 'kimberly')
+ expect((await store.get(build.id))?.ownerName).toBe('kimberly')
+ expect(await store.remove(build.id)).toBe(true)
+ expect(await store.get(build.id)).toBeNull()
+ expect(await db.many(sql`SELECT * FROM creator_asset_uses`)).toEqual([])
+ })
+
+ it('follows the account: export, merge into the survivor (uses recounted) and erasure', async () => {
+ const db = await openTestDb()
+ const store = createCreatorAssetStore(db)
+ const identity = createIdentityRepository(db)
+ const mk = (owner: string, title: string) =>
+ store.create({
+ ownerSubject: owner,
+ ownerName: owner.slice(-1),
+ kind: 'prefab',
+ title,
+ summary: '',
+ body: BUILD,
+ now: 1,
+ })
+ const kims = await mk(KIM, 'Kim build')
+ const lees = await mk(LEE, 'Lee build')
+ const zeds = await mk(ZED, 'Zed build')
+ // KIM used LEE's and ZED's builds; LEE used ZED's too.
+ await store.countUse(lees.id, KIM, 2)
+ await store.countUse(zeds.id, KIM, 3)
+ await store.countUse(zeds.id, LEE, 4)
+
+ const out = await identity.exportSubject(KIM)
+ expect(out.creatorAssets.map((a) => a.id)).toEqual([kims.id])
+ expect(out.creatorAssetUses.map((u) => u.asset_id).sort()).toEqual([lees.id, zeds.id].sort())
+
+ // KIM folds into LEE: KIM's build is LEE's; KIM's use of LEE's own build no longer counts, and ZED's build
+ // counts one use by LEE, not two.
+ await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
+ expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
+ expect((await store.get(lees.id))?.uses).toBe(0)
+ expect((await store.get(zeds.id))?.uses).toBe(1)
+
+ expect(await identity.eraseSubjects([LEE])).toMatchObject({ creator_assets: 2 })
+ expect((await store.listByOwner(LEE)).length).toBe(0)
+ expect(await db.many(sql`SELECT subject FROM creator_asset_uses`)).toEqual([])
+ expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
+import { randomBytes } from 'node:crypto'
+import { sql, type Db } from 'openvibe-sdk/db'
+import { idList } from './sqlUtils.js'
+
+/**
+ * The asset store (migration 0016): builds and scripts creators publish for anyone to use in their own worlds. Callers
+ * check a body first (the content package's creatorAssetOf); this module stores, lists, counts uses and removes.
+ */
+
+export type CreatorAssetKind = 'prefab' | 'script'
+export type CreatorAssetStatus = 'published' | 'hidden'
+
+export interface CreatorAsset {
+ id: string
+ ownerSubject: string
+ ownerName: string
+ kind: CreatorAssetKind
+ title: string
+ summary: string
+ status: CreatorAssetStatus
+ /** How many people (besides its creator) used it. */
+ uses: number
+ /** The world it came from, as it was then; null when none was named. */
+ fromWorld: { id: string; title: string } | null
+ createdAt: number
+ updatedAt: number
+ body: unknown
+}
+
+interface Row {
+ id: string
+ owner_subject: string
+ owner_name: string
+ kind: CreatorAssetKind
+ title: string
+ summary: string
+ status: CreatorAssetStatus
+ uses: number | string
+ from_world: string | null
+ from_world_title: string | null
+ created_at: number | string
+ updated_at: number | string
+ body: unknown
+}
+
+const COLUMNS = sql`id, owner_subject, owner_name, kind, title, summary, status, uses, from_world, from_world_title, created_at, updated_at, body`
+
+function fromRow(row: Row): CreatorAsset {
+ return {
+ id: row.id,
+ ownerSubject: row.owner_subject,
+ ownerName: row.owner_name,
+ kind: row.kind,
+ title: row.title,
+ summary: row.summary,
+ status: row.status,
+ uses: Number(row.uses),
+ fromWorld: row.from_world ? { id: row.from_world, title: row.from_world_title ?? '' } : null,
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ body: row.body,
+ }
+}
+
+/** `a` + ten base-36 characters (content's CREATOR_ASSET_ID). */
+export function newCreatorAssetId(): string {
+ const bytes = randomBytes(10)
+ let id = 'a'
+ for (const b of bytes) id += (b % 36).toString(36)
+ return id
+}
+
+export interface CreatorAssetStore {
+ create(input: {
+ ownerSubject: string
+ ownerName: string
+ kind: CreatorAssetKind
+ title: string
+ summary: string
+ body: unknown
+ fromWorld?: { id: string; title: string } | null
+ now: number
+ }): Promise<CreatorAsset>
+ get(id: string): Promise<CreatorAsset | null>
+ countByOwner(ownerSubject: string): Promise<number>
+ listByOwner(ownerSubject: string): Promise<CreatorAsset[]>
+ /** Published assets, newest first, of one kind or both. */
+ listPublished(
+ kind: CreatorAssetKind | null,
+ limit: number,
+ offset: number,
+ ): Promise<CreatorAsset[]>
+ update(
+ id: string,
+ patch: { title?: string; summary?: string; status?: CreatorAssetStatus },
+ now: number,
+ ): Promise<CreatorAsset | null>
+ /** The creator's display name follows the Network's (set at each signed-in write). */
+ rename(ownerSubject: string, ownerName: string): Promise<void>
+ /** Counts this person's use of the asset once; true when it was their first. */
+ countUse(id: string, subject: string, now: number): Promise<boolean>
+ remove(id: string): Promise<boolean>
+}
+
+export function createCreatorAssetStore(db: Db): CreatorAssetStore {
+ return {
+ async create(input) {
+ const id = newCreatorAssetId()
+ const from = input.fromWorld ?? null
+ const row = await db.one<Row>(sql`
+ INSERT INTO creator_assets (id, owner_subject, owner_name, kind, title, summary, body, from_world, from_world_title,
+ created_at, updated_at)
+ VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.kind}, ${input.title}, ${input.summary},
+ ${sql.json(input.body)}, ${from ? from.id : null}, ${from ? from.title : null}, ${input.now}, ${input.now})
+ RETURNING ${COLUMNS}
+ `)
+ return fromRow(row)
+ },
+ async get(id) {
+ const row = await db.maybe<Row>(sql`SELECT ${COLUMNS} FROM creator_assets WHERE id = ${id}`)
+ return row ? fromRow(row) : null
+ },
+ async countByOwner(ownerSubject) {
+ const row = await db.one<{ n: number | string }>(
+ sql`SELECT count(*)::int AS n FROM creator_assets WHERE owner_subject = ${ownerSubject}`,
+ )
+ return Number(row.n)
+ },
+ async listByOwner(ownerSubject) {
+ const rows = await db.many<Row>(
+ sql`SELECT ${COLUMNS} FROM creator_assets WHERE owner_subject = ${ownerSubject} ORDER BY created_at DESC, id`,
+ )
+ return rows.map(fromRow)
+ },
+ async listPublished(kind, limit, offset) {
+ const rows = await db.many<Row>(sql`
+ SELECT ${COLUMNS} FROM creator_assets
+ WHERE status = 'published' ${kind ? sql`AND kind = ${kind}` : sql``}
+ ORDER BY created_at DESC, id LIMIT ${limit} OFFSET ${offset}
+ `)
+ return rows.map(fromRow)
+ },
+ 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}`)
+ const row = await db.maybe<Row>(
+ sql`UPDATE creator_assets SET ${sql.join(sets)} WHERE id = ${id} RETURNING ${COLUMNS}`,
+ )
+ return row ? fromRow(row) : null
+ },
+ async rename(ownerSubject, ownerName) {
+ await db.exec(
+ sql`UPDATE creator_assets SET owner_name = ${ownerName} WHERE owner_subject = ${ownerSubject} AND owner_name <> ${ownerName}`,
+ )
+ },
+ async countUse(id, subject, now) {
+ return db.tx(async () => {
+ const added = await db.exec(sql`
+ INSERT INTO creator_asset_uses (asset_id, subject, used_at) VALUES (${id}, ${subject}, ${now})
+ ON CONFLICT (asset_id, subject) DO NOTHING
+ `)
+ if (added > 0)
+ await db.exec(sql`UPDATE creator_assets SET uses = uses + 1 WHERE id = ${id}`)
+ return added > 0
+ })
+ },
+ async remove(id) {
+ return (await db.exec(sql`DELETE FROM creator_assets WHERE id = ${id}`)) > 0
+ },
+ }
+}
+
+/** A person's assets and the ones they used, for their data export (ADR-033). */
+export async function exportCreatorAssets(
+ db: Db,
+ subject: string,
+): Promise<{ assets: Record<string, unknown>[]; uses: Record<string, unknown>[] }> {
+ const assets = await db.many<Record<string, unknown>>(sql`
+ SELECT id, owner_name, kind, title, summary, status, uses, from_world, from_world_title, created_at, updated_at, body
+ FROM creator_assets WHERE owner_subject = ${subject} ORDER BY created_at
+ `)
+ const uses = await db.many<Record<string, unknown>>(sql`
+ SELECT u.asset_id, a.title, u.used_at FROM creator_asset_uses u
+ JOIN creator_assets a ON a.id = u.asset_id WHERE u.subject = ${subject} ORDER BY u.used_at
+ `)
+ return { assets, uses }
+}
+
+/** Erasure (ADR-033): the person's assets go, and so do the records of what they used. Answers how many assets. */
+export async function eraseCreatorAssets(db: Db, subjects: readonly string[]): Promise<number> {
+ if (!subjects.length) return 0
+ await db.exec(sql`DELETE FROM creator_asset_uses WHERE subject IN (${idList(subjects)})`)
+ return db.exec(sql`DELETE FROM creator_assets WHERE owner_subject IN (${idList(subjects)})`)
+}
+
+/**
+ * Account merge (ADR-029): the folded-in account's assets belong to the survivor, and so do its uses (one per asset;
+ * none on the survivor's own assets, which never count their creator).
+ */
+export async function moveCreatorAssets(db: Db, from: string, into: string): Promise<number> {
+ if (from === into) return 0
+ const moved = await db.exec(
+ sql`UPDATE creator_assets SET owner_subject = ${into} WHERE owner_subject = ${from}`,
+ )
+ const touched = (
+ await db.many<{ asset_id: string }>(
+ sql`SELECT asset_id FROM creator_asset_uses WHERE subject = ${from}`,
+ )
+ ).map((r) => r.asset_id)
+ await db.exec(sql`
+ UPDATE creator_asset_uses u SET subject = ${into}
+ WHERE u.subject = ${from}
+ AND NOT EXISTS (SELECT 1 FROM creator_asset_uses o WHERE o.asset_id = u.asset_id AND o.subject = ${into})
+ `)
+ await db.exec(sql`DELETE FROM creator_asset_uses WHERE subject = ${from}`)
+ await db.exec(sql`
+ DELETE FROM creator_asset_uses u USING creator_assets a
+ WHERE u.asset_id = a.id AND u.subject = a.owner_subject
+ `)
+ // Two uses that became one, or a use of what is now one's own, no longer count.
+ await db.exec(sql`
+ UPDATE creator_assets a SET uses = (SELECT count(*)::int FROM creator_asset_uses u WHERE u.asset_id = a.id)
+ WHERE a.owner_subject = ${into}${touched.length ? sql` OR a.id IN (${idList(touched)})` : sql``}
+ `)
+ return moved
+}
@@ -178,6 +178,7 @@ describe('account export and deletion (ADR-033)', () => {
@@ -178,6 +178,7 @@ describe('account export and deletion (ADR-033)', () => {
identity_rows: 1,
identity_rows: 1,
casual_saves: 2,
casual_saves: 2,
creator_worlds: 0,
creator_worlds: 0,
+ creator_assets: 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`),
@@ -199,6 +200,7 @@ describe('account export and deletion (ADR-033)', () => {
@@ -199,6 +200,7 @@ describe('account export and deletion (ADR-033)', () => {
identity_rows: 0,
identity_rows: 0,
casual_saves: 0,
casual_saves: 0,
creator_worlds: 0,
creator_worlds: 0,
+ creator_assets: 0,
})
})
})
})
})
})
@@ -19,6 +19,7 @@ import {
@@ -19,6 +19,7 @@ import {
exportWorldTeams,
exportWorldTeams,
moveCreatorWorlds,
moveCreatorWorlds,
} from './creatorWorlds.js'
} from './creatorWorlds.js'
+import { eraseCreatorAssets, exportCreatorAssets, moveCreatorAssets } from './creatorAssets.js'
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
@@ -174,6 +175,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -174,6 +175,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
}
}
await moveProgress(from, into)
await moveProgress(from, into)
await moveCreatorWorlds(db, from, into)
await moveCreatorWorlds(db, from, into)
+ await moveCreatorAssets(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 }
})
})
@@ -204,6 +206,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -204,6 +206,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
const casual = await exportCasual(db, subject)
const casual = await exportCasual(db, subject)
const creatorWorlds = await exportCreatorWorlds(db, subject)
const creatorWorlds = await exportCreatorWorlds(db, subject)
const creatorWorldTeams = await exportWorldTeams(db, subject)
const creatorWorldTeams = await exportWorldTeams(db, subject)
+ const creatorAssets = await exportCreatorAssets(db, subject)
return {
return {
characters: characters.map(withoutAccountKey),
characters: characters.map(withoutAccountKey),
structures,
structures,
@@ -215,6 +218,8 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -215,6 +218,8 @@ export function createIdentityRepository(db: Db): IdentityRepository {
casualLedger: casual.ledger,
casualLedger: casual.ledger,
creatorWorlds,
creatorWorlds,
creatorWorldTeams,
creatorWorldTeams,
+ creatorAssets: creatorAssets.assets,
+ creatorAssetUses: creatorAssets.uses,
}
}
},
},
@@ -226,6 +231,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -226,6 +231,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
identity_rows: 0,
identity_rows: 0,
casual_saves: 0,
casual_saves: 0,
creator_worlds: 0,
creator_worlds: 0,
+ creator_assets: 0,
}
}
if (!subjects.length) return out
if (!subjects.length) return out
const ids = (
const ids = (
@@ -251,6 +257,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -251,6 +257,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)
out.creator_worlds = await eraseCreatorWorlds(db, subjects)
+ out.creator_assets = await eraseCreatorAssets(db, subjects)
return out
return out
})
})
},
},
@@ -95,6 +95,10 @@ export interface IdentityRepository {
@@ -95,6 +95,10 @@ export interface IdentityRepository {
creatorWorlds: Record<string, unknown>[]
creatorWorlds: Record<string, unknown>[]
/** Worlds this person edits on someone else's team (migration 0015). */
/** Worlds this person edits on someone else's team (migration 0015). */
creatorWorldTeams: Record<string, unknown>[]
creatorWorldTeams: Record<string, unknown>[]
+ /** The builds and scripts this person published in the asset store (migration 0016), bodies included. */
+ creatorAssets: Record<string, unknown>[]
+ /** The assets this person used, once each. */
+ creatorAssetUses: 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
@@ -107,6 +111,7 @@ export interface IdentityRepository {
@@ -107,6 +111,7 @@ export interface IdentityRepository {
identity_rows: number
identity_rows: number
casual_saves: number
casual_saves: number
creator_worlds: number
creator_worlds: number
+ creator_assets: number
}>
}>
}
}
@@ -22,6 +22,7 @@ const TABLES = [
@@ -22,6 +22,7 @@ const TABLES = [
'casual_actions',
'casual_actions',
'play_sync',
'play_sync',
'creator_worlds',
'creator_worlds',
+ 'creator_assets',
'games_index_revisions',
'games_index_revisions',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/worlds/assetRoutes.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/creatorAsset.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/creatorAssets.test.ts'
+// covers: 'packages/persistence/src/pg'
+// Generated by node test/run.js --index; do not edit.