openvibers/openvibe.games Commits

The asset store: share builds and scripts for anyone's worlds (free first) (#163)

OP OpenVibers committed 17 days ago · 1db799558e · 29 files, +2039 −10

* The asset store: creators share builds (pieces placed in the editor) and scripts for anyone's worlds, free; Use in a world places a build where the camera looks, Add to a world joins a world's scripts; uses count once per person; staff take one down; account export, merge and deletion follow

* test/ pointers for the asset store's three test files

* main.ts keeps its own formatting (telemetryRoute.test reads the telemetry.init line as written): only the asset store's lines change

Changes

ddiff --git a/.env.example b/.env.example
iindex 647af28..13598a5 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=16
ddiff --git a/apps/client/src/editor/assets/storePanel.ts b/apps/client/src/editor/assets/storePanel.ts
nnew file mode 100644
iindex 0000000..1294ddd
---- /dev/null
++++ b/apps/client/src/editor/assets/storePanel.ts
@@@ -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,
++ )
++ }
++}
ddiff --git a/apps/client/src/editor/editor.css b/apps/client/src/editor/editor.css
iindex 3dcb8e0..5cd12ee 100644
---- a/apps/client/src/editor/editor.css
++++ b/apps/client/src/editor/editor.css
@@@ -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;
ddiff --git a/apps/client/src/editor/editorApp.ts b/apps/client/src/editor/editorApp.ts
iindex 5289b16..4f31bec 100644
---- a/apps/client/src/editor/editorApp.ts
++++ b/apps/client/src/editor/editorApp.ts
@@@ -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({
ddiff --git a/apps/client/src/editor/ui/editorShell.ts b/apps/client/src/editor/ui/editorShell.ts
iindex c862896..871fe7e 100644
---- a/apps/client/src/editor/ui/editorShell.ts
++++ b/apps/client/src/editor/ui/editorShell.ts
@@@ -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'],
ddiff --git a/apps/client/src/pages/worlds.ts b/apps/client/src/pages/worlds.ts
iindex f6d51a7..5e12b5d 100644
---- a/apps/client/src/pages/worlds.ts
++++ b/apps/client/src/pages/worlds.ts
@@@ -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 = true
signedIn.hidden = false
++ yoursButton.hidden = false
mineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))
const full = mine.worlds.length >= mine.max
createButton.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)])
})
ddiff --git a/apps/client/worlds.html b/apps/client/worlds.html
iindex 3e54455..c6c17bf 100644
---- a/apps/client/worlds.html
++++ b/apps/client/worlds.html
@@@ -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
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;
}
@@@ -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 models
stay with staff maps. Characters are not saved inside someone's world, so nothing earned
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 5570b51..edd3aa8 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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',
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 1a872a8..16c74dc 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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 true
if (serveWorlds(req, res)) return true
++ if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveSteam(req, res)) return true
if (
ddiff --git a/apps/server/src/platform/accountData.test.ts b/apps/server/src/platform/accountData.test.ts
iindex 1bb6272..fdc0b5a 100644
---- a/apps/server/src/platform/accountData.test.ts
++++ b/apps/server/src/platform/accountData.test.ts
@@@ -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')
ddiff --git a/apps/server/src/worlds/assetRoutes.test.ts b/apps/server/src/worlds/assetRoutes.test.ts
nnew file mode 100644
iindex 0000000..aa4460c
---- /dev/null
++++ b/apps/server/src/worlds/assetRoutes.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/worlds/assetRoutes.ts b/apps/server/src/worlds/assetRoutes.ts
nnew file mode 100644
iindex 0000000..c6f25dd
---- /dev/null
++++ b/apps/server/src/worlds/assetRoutes.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex b04371a..5a4c0d4 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -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 | nu
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) => {
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 {
ddiff --git a/docs/games.md b/docs/games.md
iindex 4e83066..94dfeca 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -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`).
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`.
++- **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 owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
ddiff --git a/docs/platform-direction.md b/docs/platform-direction.md
iindex 5da546a..31f583c 100644
---- a/docs/platform-direction.md
++++ b/docs/platform-direction.md
@@@ -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.
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex 199a283..b5c69d7 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -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'
ddiff --git a/packages/content/src/packs/creatorAsset.test.ts b/packages/content/src/packs/creatorAsset.test.ts
nnew file mode 100644
iindex 0000000..3d916a5
---- /dev/null
++++ b/packages/content/src/packs/creatorAsset.test.ts
@@@ -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')
++ })
++})
ddiff --git a/packages/content/src/packs/creatorAsset.ts b/packages/content/src/packs/creatorAsset.ts
nnew file mode 100644
iindex 0000000..447dd3e
---- /dev/null
++++ b/packages/content/src/packs/creatorAsset.ts
@@@ -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') })),
++ ]
++}
ddiff --git a/packages/persistence/migrations/0016_creator_assets.sql b/packages/persistence/migrations/0016_creator_assets.sql
nnew file mode 100644
iindex 0000000..e153d2b
---- /dev/null
++++ b/packages/persistence/migrations/0016_creator_assets.sql
@@@ -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);
ddiff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
iindex 7e5f7e9..36c6f43 100644
---- a/packages/persistence/src/index.ts
++++ b/packages/persistence/src/index.ts
@@@ -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'
ddiff --git a/packages/persistence/src/pg/creatorAssets.test.ts b/packages/persistence/src/pg/creatorAssets.test.ts
nnew file mode 100644
iindex 0000000..83c2b20
---- /dev/null
++++ b/packages/persistence/src/pg/creatorAssets.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/persistence/src/pg/creatorAssets.ts b/packages/persistence/src/pg/creatorAssets.ts
nnew file mode 100644
iindex 0000000..6b3faec
---- /dev/null
++++ b/packages/persistence/src/pg/creatorAssets.ts
@@@ -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
++}
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex dd61c4a..fed8a3b 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -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,
})
})
})
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex 21722d5..550595d 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -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 out
const 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
})
},
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 42141e3..c302bb9 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -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: number
casual_saves: number
creator_worlds: number
++ creator_assets: number
}>
}
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 634f3c1..8de8e74 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -22,6 +22,7 @@ const TABLES = [
'casual_actions',
'play_sync',
'creator_worlds',
++ 'creator_assets',
'games_index_revisions',
'game_instances',
'character_instance_positions',
ddiff --git a/test/apps__server__src__worlds__assetRoutes.test.js b/test/apps__server__src__worlds__assetRoutes.test.js
nnew file mode 100644
iindex 0000000..0f09cb8
---- /dev/null
++++ b/test/apps__server__src__worlds__assetRoutes.test.js
@@@ -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.
ddiff --git a/test/packages__content__src__packs__creatorAsset.test.js b/test/packages__content__src__packs__creatorAsset.test.js
nnew file mode 100644
iindex 0000000..22ea8da
---- /dev/null
++++ b/test/packages__content__src__packs__creatorAsset.test.js
@@@ -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.
ddiff --git a/test/packages__persistence__src__pg__creatorAssets.test.js b/test/packages__persistence__src__pg__creatorAssets.test.js
nnew file mode 100644
iindex 0000000..04e4f25
---- /dev/null
++++ b/test/packages__persistence__src__pg__creatorAssets.test.js
@@@ -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.