openvibers/openvibe.games Commits

Games: two published example creator worlds (Beacon Hill, The Workshop) (#299)

OP OpenVibers committed 6 days ago · 623d53c4ae · 9 files, +607 −0

The plan's §4 Games next step is that creator worlds are published (none were), and the
10-22 review asks for one or two staff-published worlds so /worlds and the home's
creator-worlds section have real entries.

Two ordinary v2 map documents, the file the editor saves, so staff can open either in
/editor and keep building:

- Beacon Hill: sculpted terrain with a beacon, a flagstone path, benches, a campfire,
  oaks and a stone deposit, sun/sky/beacon lights and three zones (a safe trailhead,
  the beacon, the wilds).
- The Workshop: a paved walled yard with a shed you can walk into, workbenches, crates,
  barrels, a scrap corner, a planter and a stall, lamps for the evening, and zones.

src/worlds/seedExampleWorlds.ts validates each with parseMapFile + creatorMapIssues
(the same gate the API uses) and writes it through the creator world store for the
official account; scripts/seedExampleWorlds.ts is the runnable entry. It is idempotent
(a second run says unchanged), --revise updates a map whose revision changed, and it
only ever reads the official account's worlds: a person's world with the same title is
never touched.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b208332..0b8dfa6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **Two official example creator worlds** (plan §4 Games: "creator worlds (none published yet)"; review next step 3): **Beacon Hill**, a sculpted hill with a beacon, a flagstone path, benches, a campfire and three zones, and **The Workshop**, a walled yard with a shed you can walk into, workbenches, crates, props, resource nodes and lamps. They are ordinary v2 map documents (`apps/server/src/worlds/exampleWorlds.ts`) that staff can open in `/editor`, published for the official OpenVibe account by `apps/server/src/worlds/seedExampleWorlds.ts` (`pnpm --filter @openvibe/server exec tsx scripts/seedExampleWorlds.ts`, idempotent, `--revise` updates a changed map) so `/worlds` and the home's creator-worlds section have real entries. A person's world is never touched. `exampleWorlds.test.ts`, `seedExampleWorlds.test.ts`.
++
- Share: a creator world's page, a round's result page, the leaderboard and the in-game round result now open the network's one Share sheet (`data-ov-share-sheet`, openvibe-shared 4.114.0) with the page's public address. The result page now starts the shared navbar itself, which is what loads the sheet on the first press; `navigator.share` is gone.
### Fixed
ddiff --git a/apps/server/scripts/seedExampleWorlds.ts b/apps/server/scripts/seedExampleWorlds.ts
nnew file mode 100644
iindex 0000000..e3b7c7c
---- /dev/null
++++ b/apps/server/scripts/seedExampleWorlds.ts
@@@ -0,0 +1,54 @@
++#!/usr/bin/env node
++/**
++ * Publish the two official example creator worlds (plan §4 Games; review next step 3: "Seed one or two
++ * staff-published creator worlds so /worlds and the home section have real entries").
++ *
++ * pnpm --filter @openvibe/server exec tsx scripts/seedExampleWorlds.ts
++ * pnpm --filter @openvibe/server exec tsx scripts/seedExampleWorlds.ts --revise # also update maps that differ
++ *
++ * The worlds are authored in src/worlds/exampleWorlds.ts as ordinary v2 map documents; src/worlds/seedExampleWorlds.ts
++ * validates them with the same parseMapFile + creatorMapIssues the API uses and writes them through the creator
++ * world store. They belong to the official OpenVibe account (the staff account the other official content uses);
++ * a person's world is never read, renamed or overwritten. Running it twice changes nothing.
++ *
++ * GAMES_OFFICIAL_SUBJECT the account's usr_… subject (default: the owner's account)
++ * GAMES_OFFICIAL_NAME the name its worlds carry (default: goosely)
++ */
++import { createRequire } from 'node:module'
++import { createCreatorWorldStore } from '@openvibe/persistence'
++import { createConsoleLogger } from '@openvibe/shared'
++import { loadConfig } from '../src/config.js'
++import { openDb } from '../src/db.js'
++import { seedExampleWorlds } from '../src/worlds/seedExampleWorlds.js'
++
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as { ids: { isSubjectId(kind: string, value: string): boolean } }
++const official = {
++ subject: process.env.GAMES_OFFICIAL_SUBJECT || 'usr_01KKT9AC60KM7CRTB3WN1Z8P56',
++ name: process.env.GAMES_OFFICIAL_NAME || 'goosely',
++}
++
++async function main(): Promise<void> {
++ if (!contracts.ids.isSubjectId('user', official.subject)) {
++ throw new Error(`GAMES_OFFICIAL_SUBJECT is not a usr_… subject: ${official.subject}`)
++ }
++ const log = createConsoleLogger({ app: 'seed-example-worlds' }, 'info')
++ const config = loadConfig(process.env)
++ const db = await openDb(config.db, log)
++ try {
++ const results = await seedExampleWorlds({
++ store: createCreatorWorldStore(db),
++ official,
++ log,
++ revise: process.argv.includes('--revise'),
++ })
++ for (const r of results) console.log(`[examples] ${r.action.padEnd(9)} ${r.title} (${r.id})`)
++ console.log(`[examples] ${results.length} world(s), official account ${official.subject}`)
++ } finally {
++ await db.close()
++ }
++}
++
++main().catch((err) => {
++ console.error('[examples] failed:', err instanceof Error ? err.stack || err.message : err)
++ process.exit(1)
++})
ddiff --git a/apps/server/src/worlds/exampleWorlds.test.ts b/apps/server/src/worlds/exampleWorlds.test.ts
nnew file mode 100644
iindex 0000000..24eb0aa
---- /dev/null
++++ b/apps/server/src/worlds/exampleWorlds.test.ts
@@@ -0,0 +1,53 @@
++import { creatorMapIssues, parseMapFile } from '@openvibe/content'
++import { describe, expect, it } from 'vitest'
++import { EXAMPLE_WORLDS } from './exampleWorlds.js'
++
++/**
++ * The two official example worlds must be documents the editor would save and the creator-world gate
++ * would accept: valid v2 maps, under the creator limits, using only the built-in materials, with a spawn
++ * on the map and the editor's own toolbox represented (terrain, statics, props, nodes, lights, zones).
++ */
++describe('the official example creator worlds', () => {
++ it('are two worlds with distinct titles and summaries', () => {
++ expect(EXAMPLE_WORLDS.map((w) => w.title)).toEqual(['Beacon Hill', 'The Workshop'])
++ expect(new Set(EXAMPLE_WORLDS.map((w) => w.title)).size).toBe(2)
++ for (const w of EXAMPLE_WORLDS) expect(w.summary.length).toBeGreaterThan(40)
++ })
++
++ for (const world of EXAMPLE_WORLDS) {
++ it(`"${world.title}" is a map the creator-world gate accepts`, () => {
++ const bytes = Buffer.byteLength(JSON.stringify(world.map))
++ const parsed = parseMapFile(world.map)
++ expect(parsed.ok, parsed.ok ? '' : (parsed as { issues: string[] }).issues.join('; ')).toBe(true)
++ if (!parsed.ok) return
++ expect(creatorMapIssues(parsed.map, bytes)).toEqual([])
++ expect(parsed.map.spawn).toBeTruthy()
++ expect(parsed.map.v).toBe(2)
++ })
++
++ it(`"${world.title}" shows the editor's toolbox`, () => {
++ const map = world.map
++ expect(map.terrains.length).toBeGreaterThan(0)
++ expect(map.statics.length).toBeGreaterThan(8)
++ expect(map.props.length).toBeGreaterThan(2)
++ expect(map.nodes.length).toBeGreaterThan(1)
++ expect(map.lights.length).toBeGreaterThan(1)
++ expect(map.zones.length).toBeGreaterThan(0)
++ expect(map.npcs).toEqual([])
++ expect(map.models).toEqual([])
++ expect(map.textures).toEqual([])
++ })
++
++ it(`"${world.title}" has an id for every editable object, and no duplicates`, () => {
++ const ids = [
++ ...world.map.terrains.map((t) => t.id),
++ ...world.map.statics.map((s) => s.id),
++ ...world.map.props.map((p) => p.id),
++ ...world.map.nodes.map((n) => n.id),
++ ...world.map.lights.map((l) => l.id),
++ ...world.map.zones.map((z) => z.id),
++ ]
++ expect(new Set(ids).size).toBe(ids.length)
++ })
++ }
++})
ddiff --git a/apps/server/src/worlds/exampleWorlds.ts b/apps/server/src/worlds/exampleWorlds.ts
nnew file mode 100644
iindex 0000000..c04250d
---- /dev/null
++++ b/apps/server/src/worlds/exampleWorlds.ts
@@@ -0,0 +1,326 @@
++import type { MapFileV2 } from '@openvibe/content'
++
++/**
++ * The two official example creator worlds (plan §4 Games, "Games' creator worlds (none published yet)"; review
++ * next step 3: "Seed one or two staff-published creator worlds so /worlds and the home section have real
++ * entries"). They are ordinary v2 map documents, the same file the editor saves, so staff can open either in
++ * /editor and keep building on it. Between them they use what the editor can author: sculpted terrain and a
++ * built-in texture, boxes, cylinders and spheres, props and resource nodes, point/hemi/directional lights,
++ * named zones with their own rules, and a spawn point.
++ *
++ * Deterministic: no randomness, no clock, so the seed's revision is stable.
++ */
++
++type Vec3 = [number, number, number]
++type Static = MapFileV2['statics'][number]
++
++const round = (v: number, places = 3): number => Math.round(v * 10 ** places) / 10 ** places
++const at = (v: Vec3): Vec3 => v.map((n) => round(n)) as Vec3
++
++/** Base64 Float32 heights, the codec mapFile.ts uses (the module is server-side). */
++function heightsOf(sub: number, f: (x: number, z: number) => number, halfExtent: number): string {
++ const out = new Float32Array((sub + 1) * (sub + 1))
++ for (let iz = 0; iz <= sub; iz++) {
++ for (let ix = 0; ix <= sub; ix++) {
++ const x = -halfExtent + (2 * halfExtent * ix) / sub
++ const z = -halfExtent + (2 * halfExtent * iz) / sub
++ out[iz * (sub + 1) + ix] = round(f(x, z), 4)
++ }
++ }
++ return Buffer.from(out.buffer).toString('base64')
++}
++
++const smooth = (t: number): number => {
++ const c = Math.min(1, Math.max(0, t))
++ return c * c * (3 - 2 * c)
++}
++
++/** ── Beacon Hill: one sculpted terrain, a beacon, a path and three zones ─────────────────────────────── */
++
++const BEACON = { x: 0, z: -8, radius: 11, height: 3.4 }
++
++function beaconGround(x: number, z: number): number {
++ const d = Math.hypot(x - BEACON.x, z - BEACON.z)
++ const hill = BEACON.height * smooth(1 - d / BEACON.radius)
++ // A gentle roll around the hill so the ground is not a perfect cone.
++ const roll = 0.22 * Math.sin(x / 4.6) * Math.cos(z / 5.2) * smooth(1 - d / 26)
++ return round(hill + roll, 3)
++}
++
++function beaconHill(): MapFileV2 {
++ let sid = 0
++ const statics: Static[] = []
++ const box = (x: number, z: number, size: Vec3, color: string, opts: Partial<Static> & { lift?: number; base?: number } = {}): Static => {
++ const { lift = 0, base, ...rest } = opts
++ const y = (base ?? beaconGround(x, z)) + lift + size[1] / 2
++ const s: Static = {
++ id: `s${++sid}`,
++ shape: { type: 'box', size: size.map((n) => round(n)) as Vec3 },
++ pos: at([x, y, z]),
++ yaw: 0,
++ color,
++ ...rest,
++ }
++ statics.push(s)
++ return s
++ }
++ const cylinder = (x: number, z: number, radius: number, height: number, color: string, opts: Partial<Static> & { lift?: number } = {}): Static => {
++ const { lift = 0, ...rest } = opts
++ const s: Static = {
++ id: `s${++sid}`,
++ shape: { type: 'cylinder', radius: round(radius), height: round(height) },
++ pos: at([x, beaconGround(x, z) + lift + height / 2, z]),
++ yaw: 0,
++ color,
++ ...rest,
++ }
++ statics.push(s)
++ return s
++ }
++ const sphere = (x: number, z: number, radius: number, color: string, opts: Partial<Static> & { lift?: number; base?: number } = {}): Static => {
++ const { lift = 0, base, ...rest } = opts
++ const s: Static = {
++ id: `s${++sid}`,
++ shape: { type: 'sphere', radius: round(radius) },
++ pos: at([x, (base ?? beaconGround(x, z)) + lift + radius, z]),
++ yaw: 0,
++ color,
++ ...rest,
++ }
++ statics.push(s)
++ return s
++ }
++
++ // The beacon: three stone stages and a brazier, on the crown of the hill.
++ const crown = beaconGround(BEACON.x, BEACON.z)
++ box(BEACON.x, BEACON.z, [3.4, 1.1, 3.4], '#8a8d90', { base: crown, tex: 'gray_rocks' })
++ box(BEACON.x, BEACON.z, [2.4, 1.1, 2.4], '#9a9187', { base: crown + 1.1, tex: 'gray_rocks' })
++ box(BEACON.x, BEACON.z, [1.4, 1.1, 1.4], '#a8a29e', { base: crown + 2.2, tex: 'gray_rocks' })
++ sphere(BEACON.x, BEACON.z, 0.62, '#f59e0b', { base: crown + 3.3 })
++
++ // The path down from the beacon: six flagstones, each a shallow box sunk into the hill.
++ for (let i = 0; i < 6; i++) {
++ const z = -1.5 + i * 2.6
++ box(-0.2, z, [2.6, 0.22, 1.7], '#b8b2a7', { lift: -0.08, tex: 'floor_pavement' })
++ }
++
++ // A timber bench either side of the path, and three marker stones where the path meets the flat.
++ box(-5.2, 9.6, [2.4, 0.42, 0.7], '#7a5c38', { lift: 0.5, tex: 'wood_planks' })
++ box(5.2, 9.6, [2.4, 0.42, 0.7], '#7a5c38', { lift: 0.5, tex: 'wood_planks' })
++ cylinder(-5.6, 12.6, 0.55, 1.2, '#8a8d90', { tex: 'gray_rocks' })
++ cylinder(5.6, 12.6, 0.55, 1.2, '#8a8d90', { tex: 'gray_rocks' })
++ cylinder(0, 15.4, 0.55, 1.1, '#8a8d90', { tex: 'gray_rocks' })
++
++ // The trailhead sign, facing the spawn.
++ box(3.4, 14.2, [3, 1.7, 0.22], '#efe6d4', { lift: 1.0, yaw: -0.18, tex: 'weathered_plank_siding' })
++ box(2.2, 14.2, [0.18, 2.1, 0.18], '#5b4630', { lift: 0, yaw: -0.18 })
++ box(4.6, 14.2, [0.18, 2.1, 0.18], '#5b4630', { lift: 0, yaw: -0.18 })
++
++ return {
++ v: 2,
++ terrains: [
++ {
++ id: 'terrain-0',
++ name: 'Beacon hill',
++ pos: [0, 0, 0],
++ halfExtent: 26,
++ sub: 32,
++ heights: heightsOf(32, beaconGround, 26),
++ surface: { base: { tex: 'leafy_grass', color: '#9db172' } },
++ },
++ ],
++ statics,
++ nodes: [
++ { id: 'n1', node: 'oak_tree', pos: at([-14, 0, 4]) },
++ { id: 'n2', node: 'oak_tree', pos: at([14, 0, -2]) },
++ { id: 'n3', node: 'berry_bush', pos: at([-9, 0, -14]) },
++ { id: 'n4', node: 'stone_deposit', pos: at([12, 0, -16]) },
++ { id: 'n5', node: 'loose_stones', pos: at([7, 0, 15]) },
++ ],
++ props: [
++ { id: 'p1', item: 'campfire', pos: at([-7, 0.05, 8]), yaw: 0 },
++ { id: 'p2', item: 'wooden_crate', pos: at([6.4, 0.7, 6.4]), yaw: 0.4 },
++ { id: 'p3', item: 'supply_crate', pos: at([8.2, 0.7, 7.8]), yaw: -0.3 },
++ { id: 'p4', item: 'metal_barrel', pos: at([-8.6, 0.6, 5.4]), yaw: 0 },
++ ],
++ npcs: [],
++ points: [],
++ logic: [],
++ lights: [
++ { id: 'light-sun', type: 'directional', pos: at([26, 34, 18]), dir: [-0.38, -1, -0.24], color: '#fff1d6', intensity: 1.15, shadows: true },
++ { id: 'light-sky', type: 'hemi', pos: at([0, 24, 0]), color: '#dbeafe', ground: '#5c6b47', intensity: 0.55 },
++ { id: 'light-beacon', type: 'point', pos: at([BEACON.x, crown + 4.6, BEACON.z]), color: '#ffb547', intensity: 0.9, range: 22 },
++ ],
++ zones: [
++ {
++ id: 'trailhead',
++ name: 'Trailhead',
++ min: [-11, -2, 8],
++ max: [11, 24, 20],
++ rules: { pvp: false, build: false, physgun: true },
++ },
++ {
++ id: 'beacon-crown',
++ name: 'The beacon',
++ min: [-6, -2, -14],
++ max: [6, 24, -2],
++ rules: { pvp: true, build: true, physgun: true },
++ },
++ {
++ id: 'wilds',
++ name: 'The wilds',
++ min: [-26, -2, -26],
++ max: [26, 24, 26],
++ rules: { pvp: true, build: true, physgun: true },
++ },
++ ],
++ models: [],
++ textures: [],
++ spawn: at([0, 1.2, 16]),
++ spawnYaw: 0,
++ }
++}
++
++/** ── The Workshop: a flat yard, a shed you can walk into, benches and a scrap corner ──────────────────── */
++
++function workshop(): MapFileV2 {
++ let sid = 0
++ const statics: Static[] = []
++ const box = (x: number, z: number, size: Vec3, color: string, opts: Partial<Static> & { lift?: number } = {}): Static => {
++ const { lift = 0, ...rest } = opts
++ const s: Static = {
++ id: `s${++sid}`,
++ shape: { type: 'box', size: size.map((n) => round(n)) as Vec3 },
++ pos: at([x, lift + size[1] / 2, z]),
++ yaw: 0,
++ color,
++ ...rest,
++ }
++ statics.push(s)
++ return s
++ }
++ const cylinder = (x: number, z: number, radius: number, height: number, color: string, opts: Partial<Static> = {}): Static => {
++ const { ...rest } = opts
++ const s: Static = {
++ id: `s${++sid}`,
++ shape: { type: 'cylinder', radius: round(radius), height: round(height) },
++ pos: at([x, height / 2, z]),
++ yaw: 0,
++ color,
++ ...rest,
++ }
++ statics.push(s)
++ return s
++ }
++
++ // The yard: a paved pad with a kerb, so the flat ground reads as a place rather than a void.
++ for (const [x, z, w, d] of [
++ [0, 0, 44, 0.35], [0, -30, 44, 0.35], [-22, -15, 0.35, 30], [22, -15, 0.35, 30],
++ ] as const) {
++ box(x, z, [w, 0.3, d], '#8b8f96', { lift: 0.15, tex: 'floor_pavement' })
++ }
++
++ // The shed: back and side walls, a roof slab and a door opening facing the spawn (+z).
++ box(0, -22, [16, 4.2, 0.4], '#9a9187', { tex: 'weathered_plank_siding' })
++ box(-7.8, -16.8, [0.4, 4.2, 10.4], '#9a9187', { tex: 'weathered_plank_siding' })
++ box(7.8, -16.8, [0.4, 4.2, 10.4], '#9a9187', { tex: 'weathered_plank_siding' })
++ box(0, -16.8, [16.4, 0.35, 11], '#6b4f33', { lift: 4.2, tex: 'clay_roof_tiles' })
++ box(-4.5, -11.8, [6, 4.2, 0.35], '#9a9187', { tex: 'weathered_plank_siding' })
++ box(4.5, -11.8, [6, 4.2, 0.35], '#9a9187', { tex: 'weathered_plank_siding' })
++ // A chimney and a stove, because a workshop needs one.
++ box(5.6, -21.4, [1.4, 2.2, 1.4], '#7d5a4f', { lift: 4.55, tex: 'red_brick' })
++ cylinder(-5.4, -19.4, 0.6, 1.6, '#4b5563', { tex: 'metal_plate' })
++
++ // Two long workbenches and a shelf.
++ box(-3.6, -14.4, [5.6, 0.9, 1.4], '#8a6a45', { tex: 'wood_planks' })
++ box(3.8, -14.4, [5.6, 0.9, 1.4], '#8a6a45', { tex: 'wood_planks' })
++ box(0, -21.3, [7, 0.35, 0.9], '#7a5c38', { lift: 2.6, tex: 'wood_planks' })
++
++ // A rail of posts along the north edge, and a sign by the gate.
++ for (let i = 0; i < 5; i++) box(-18 + i * 9, 20.5, [0.35, 2.2, 0.35], '#6b4f33', { tex: 'wood_planks' })
++ box(-14.5, 18.2, [3.4, 1.6, 0.24], '#efe6d4', { lift: 0.9, yaw: 0.12, tex: 'weathered_plank_siding' })
++ box(-15.6, 18.2, [0.2, 2.0, 0.2], '#5b4630', {})
++ box(-13.4, 18.2, [0.2, 2.0, 0.2], '#5b4630', {})
++
++ return {
++ v: 2,
++ terrains: [
++ {
++ id: 'terrain-0',
++ name: 'The yard',
++ pos: [0, 0, 0],
++ halfExtent: 32,
++ sub: 16,
++ heights: heightsOf(16, () => 0, 32),
++ surface: { base: { tex: 'floor_pavement', color: '#a9a49b' } },
++ },
++ ],
++ statics,
++ nodes: [
++ { id: 'n1', node: 'scrap_pile', pos: at([-16, 0, 10]) },
++ { id: 'n2', node: 'branch_pile', pos: at([15, 0, 8]) },
++ { id: 'n3', node: 'loose_stones', pos: at([12, 0, 14]) },
++ { id: 'n4', node: 'stone_deposit', pos: at([18, 0, 2]) },
++ ],
++ props: [
++ { id: 'p1', item: 'storage_box', pos: at([-5.4, 0.55, -13.2]), yaw: 0.2 },
++ { id: 'p2', item: 'wooden_crate', pos: at([5.2, 0.75, -10.4]), yaw: -0.35 },
++ { id: 'p3', item: 'wooden_crate', pos: at([6.4, 0.75, -9.1]), yaw: 0.5 },
++ { id: 'p4', item: 'metal_barrel', pos: at([10.6, 0.6, 16.2]), yaw: 0 },
++ { id: 'p5', item: 'metal_barrel', pos: at([11.9, 0.6, 15.1]), yaw: 0 },
++ { id: 'p6', item: 'campfire', pos: at([-16.6, 0.05, 11.4]), yaw: 0 },
++ { id: 'p7', item: 'planter_box', pos: at([-9.6, 0.5, 16.4]), yaw: 0.1 },
++ { id: 'p8', item: 'merchant_stall', pos: at([17, 0.15, 17]), yaw: -0.7 },
++ ],
++ npcs: [],
++ points: [],
++ logic: [],
++ lights: [
++ { id: 'light-sun', type: 'directional', pos: at([30, 40, 24]), dir: [-0.35, -1, -0.2], color: '#fff4e0', intensity: 1.05, shadows: true },
++ { id: 'light-sky', type: 'hemi', pos: at([0, 30, 0]), color: '#e0eaff', ground: '#6b6257', intensity: 0.5 },
++ { id: 'light-shed', type: 'point', pos: at([0, 3.6, -16.8]), color: '#ffe0a3', intensity: 0.85, range: 14 },
++ { id: 'light-gate', type: 'point', pos: at([-14.5, 3.2, 18.2]), color: '#ffd08a', intensity: 0.55, range: 12 },
++ ],
++ zones: [
++ {
++ id: 'yard',
++ name: 'The yard',
++ min: [-24, -2, -32],
++ max: [24, 30, 24],
++ rules: { pvp: false, build: true, physgun: true },
++ },
++ {
++ id: 'shed',
++ name: 'The workshop',
++ min: [-9, -2, -24],
++ max: [9, 8, -10],
++ rules: { pvp: false, build: false, physgun: true },
++ },
++ ],
++ models: [],
++ textures: [],
++ spawn: at([0, 1.2, 17]),
++ spawnYaw: 0,
++ }
++}
++
++export interface ExampleWorld {
++ title: string
++ summary: string
++ map: MapFileV2
++}
++
++export const EXAMPLE_WORLDS: readonly ExampleWorld[] = [
++ {
++ title: 'Beacon Hill',
++ summary:
++ 'A small hill to climb, a beacon to light and a trailhead to meet at. Sculpted ground, a flagstone path, benches, a campfire and three zones that decide where you can build and fight.',
++ map: beaconHill(),
++ },
++ {
++ title: 'The Workshop',
++ summary:
++ 'A walled yard to build in: a shed you can walk into, two workbenches, crates, barrels, a scrap corner and lamps for the evening. A quiet place to try the editor.',
++ map: workshop(),
++ },
++]
ddiff --git a/apps/server/src/worlds/seedExampleWorlds.test.ts b/apps/server/src/worlds/seedExampleWorlds.test.ts
nnew file mode 100644
iindex 0000000..9538ac1
---- /dev/null
++++ b/apps/server/src/worlds/seedExampleWorlds.test.ts
@@@ -0,0 +1,85 @@
++import { emptyMapV2, parseMapFile } from '@openvibe/content'
++import { createCreatorWorldStore } from '@openvibe/persistence'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { createConsoleLogger } from '@openvibe/shared'
++import { expect, it } from 'vitest'
++import { EXAMPLE_WORLDS } from './exampleWorlds.js'
++import { seedExampleWorlds } from './seedExampleWorlds.js'
++
++const OFFICIAL = { subject: 'usr_01JZ0000000000000000000AAA', name: 'goosely' }
++const PERSON = 'usr_01JZ0000000000000000000BBB'
++const quiet = createConsoleLogger({ app: 'example-worlds-test' }, 'error')
++
++it('publishes both example worlds for the official account, and a second run changes nothing', async () => {
++ const db = await openTestDb()
++ const store = createCreatorWorldStore(db)
++ const first = await seedExampleWorlds({ store, official: OFFICIAL, log: quiet })
++ expect(first.map((r) => r.action)).toEqual(['created', 'created'])
++ const published = await store.listPublished(10, 0)
++ expect(published.map((w) => w.title).sort()).toEqual(['Beacon Hill', 'The Workshop'])
++ for (const world of published) {
++ expect(world.ownerSubject).toBe(OFFICIAL.subject)
++ expect(world.status).toBe('published')
++ expect(world.publishedAt).toBeTruthy()
++ const stored = await store.map(world.id)
++ expect(parseMapFile(stored?.map).ok).toBe(true)
++ }
++
++ const again = await seedExampleWorlds({ store, official: OFFICIAL, log: quiet, now: Date.now() + 1000 })
++ expect(again.map((r) => r.action)).toEqual(['unchanged', 'unchanged'])
++ expect((await store.listPublished(10, 0)).length).toBe(2)
++})
++
++it('never touches a person\'s world, even one with the same title', async () => {
++ const db = await openTestDb()
++ const store = createCreatorWorldStore(db)
++ const theirs = await store.create({
++ ownerSubject: PERSON,
++ ownerName: 'alex',
++ title: EXAMPLE_WORLDS[0]!.title,
++ summary: 'mine',
++ map: emptyMapV2(),
++ now: 1000,
++ })
++ const before = await store.map(theirs.id)
++
++ await seedExampleWorlds({ store, official: OFFICIAL, log: quiet })
++
++ const after = await store.get(theirs.id)
++ expect(after).toMatchObject({ ownerSubject: PERSON, status: 'draft', title: EXAMPLE_WORLDS[0]!.title })
++ expect(await store.map(theirs.id)).toEqual(before)
++ expect((await store.listByOwner(PERSON)).length).toBe(1)
++})
++
++it('--revise puts back a map that drifted, and is unchanged afterwards', async () => {
++ const db = await openTestDb()
++ const store = createCreatorWorldStore(db)
++ const created = await seedExampleWorlds({ store, official: OFFICIAL, log: quiet })
++ const id = created[0]!.id
++ await store.saveMap(id, emptyMapV2(), null, 2000)
++
++ const revised = await seedExampleWorlds({ store, official: OFFICIAL, log: quiet, revise: true })
++ expect(revised.map((r) => r.action)).toEqual(['revised', 'unchanged'])
++ const stored = await store.map(id)
++ expect(parseMapFile(stored?.map).ok).toBe(true)
++ expect(JSON.stringify(stored?.map)).toContain('terrain-0')
++
++ const again = await seedExampleWorlds({ store, official: OFFICIAL, log: quiet, revise: true })
++ expect(again.map((r) => r.action)).toEqual(['unchanged', 'unchanged'])
++})
++
++it('refuses a seed the creator-world gate would refuse, before writing anything', async () => {
++ const db = await openTestDb()
++ const store = createCreatorWorldStore(db)
++ // A creator world may not carry NPCs; the seed check must stop before a row exists.
++ const original = EXAMPLE_WORLDS[0]!.map
++ try {
++ ;(EXAMPLE_WORLDS[0]!.map as unknown as { npcs: unknown[] }).npcs = [
++ { id: 'npc-1', npc: 'townsfolk', pos: [0, 0, 0] },
++ ]
++ await expect(seedExampleWorlds({ store, official: OFFICIAL, log: quiet })).rejects.toThrow(/npcs/)
++ expect(await store.listPublished(10, 0)).toEqual([])
++ } finally {
++ ;(EXAMPLE_WORLDS[0] as unknown as { map: typeof original }).map = original
++ }
++})
ddiff --git a/apps/server/src/worlds/seedExampleWorlds.ts b/apps/server/src/worlds/seedExampleWorlds.ts
nnew file mode 100644
iindex 0000000..62b03fa
---- /dev/null
++++ b/apps/server/src/worlds/seedExampleWorlds.ts
@@@ -0,0 +1,75 @@
++import { creatorMapIssues, parseMapFile } from '@openvibe/content'
++import { mapRevision, type CreatorWorld, type CreatorWorldStore } from '@openvibe/persistence'
++import type { Logger } from '@openvibe/shared'
++import { EXAMPLE_WORLDS } from './exampleWorlds.js'
++
++/**
++ * Publish the official example creator worlds (src/worlds/exampleWorlds.ts) through the same store the API
++ * writes with. Idempotent: a world the official account already has under the same title is left alone (and
++ * published if it is still a draft); { revise: true } updates its map when the seed's revision changed. Only
++ * the official account's own worlds are read; a person's world is never touched, even with the same title.
++ */
++export interface OfficialWorldAccount {
++ /** The account's usr_… subject. */
++ subject: string
++ /** The name the worlds carry (the Network's display name for the account). */
++ name: string
++}
++
++export interface SeedExampleWorldResult {
++ title: string
++ id: string
++ action: 'created' | 'published' | 'revised' | 'unchanged'
++}
++
++export async function seedExampleWorlds(opts: {
++ store: CreatorWorldStore
++ official: OfficialWorldAccount
++ log: Logger
++ revise?: boolean
++ now?: number
++}): Promise<SeedExampleWorldResult[]> {
++ const { store, official, log, revise = false } = opts
++ const now = opts.now ?? Date.now()
++ const out: SeedExampleWorldResult[] = []
++ for (const def of EXAMPLE_WORLDS) {
++ const parsed = parseMapFile(def.map)
++ if (!parsed.ok) {
++ throw new Error(`${def.title}: the map is not valid: ${(parsed as { issues: string[] }).issues.join('; ')}`)
++ }
++ const issues = creatorMapIssues(parsed.map, Buffer.byteLength(JSON.stringify(parsed.map)))
++ if (issues.length) throw new Error(`${def.title}: the creator-world gate refuses it: ${issues.join('; ')}`)
++
++ const owned = await store.listByOwner(official.subject)
++ let world: CreatorWorld | null = owned.find((w) => w.title === def.title) ?? null
++ let action: SeedExampleWorldResult['action'] = 'unchanged'
++ if (!world) {
++ world = await store.create({
++ ownerSubject: official.subject,
++ ownerName: official.name,
++ title: def.title,
++ summary: def.summary,
++ map: parsed.map,
++ now,
++ })
++ action = 'created'
++ log.info('example world created', { world: world.id, title: def.title })
++ } else if (revise) {
++ const stored = await store.map(world.id)
++ if (!stored || stored.revision !== mapRevision(parsed.map)) {
++ const saved = await store.saveMap(world.id, parsed.map, null, now)
++ if (saved && saved !== 'conflict') world = saved
++ action = 'revised'
++ log.info('example world revised', { world: world.id, title: def.title })
++ }
++ }
++ if (world.status !== 'published') {
++ const published = await store.update(world.id, { status: 'published' }, now)
++ if (published) world = published
++ if (action === 'unchanged') action = 'published'
++ log.info('example world published', { world: world.id, title: def.title })
++ }
++ out.push({ title: def.title, id: world.id, action })
++ }
++ return out
++}
ddiff --git a/docs/games.md b/docs/games.md
iindex adb41ea..2ed2c8d 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -107,6 +107,12 @@ 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`.
++- **Official examples:** two published worlds show what the editor can do — **Beacon Hill** (sculpted terrain, a
++ beacon, a path, lights and three zones) and **The Workshop** (a walk-in shed, benches, props, resource nodes and
++ lamps). They are ordinary v2 map documents in `apps/server/src/worlds/exampleWorlds.ts`; staff open either in
++ `/editor`. `apps/server/src/worlds/seedExampleWorlds.ts` publishes them for the official account:
++ `pnpm --filter @openvibe/server exec tsx scripts/seedExampleWorlds.ts` (idempotent; `--revise` updates a changed
++ map; `GAMES_OFFICIAL_SUBJECT`/`GAMES_OFFICIAL_NAME` choose the account). A person's world is never touched.
- **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)
ddiff --git a/test/apps__server__src__worlds__exampleWorlds.test.js b/test/apps__server__src__worlds__exampleWorlds.test.js
nnew file mode 100644
iindex 0000000..e2e0a61
---- /dev/null
++++ b/test/apps__server__src__worlds__exampleWorlds.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/worlds/exampleWorlds.test.ts'
++// covers: 'apps/server/src/worlds'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__worlds__seedExampleWorlds.test.js b/test/apps__server__src__worlds__seedExampleWorlds.test.js
nnew file mode 100644
iindex 0000000..1bbc8dd
---- /dev/null
++++ b/test/apps__server__src__worlds__seedExampleWorlds.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/worlds/seedExampleWorlds.test.ts'
++// covers: 'apps/server/src/worlds'
++// Generated by node test/run.js --index; do not edit.