openvibers/openvibe.games Commits
Games: two published example creator worlds (Beacon Hill, The Workshop) (#299)
Changes
@@@ -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.### Fixednnew file mode 100644@@@ -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)++})nnew file mode 100644@@@ -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)++ })++ }++})nnew file mode 100644@@@ -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(),++ },++]nnew file mode 100644@@@ -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++ }++})nnew file mode 100644@@@ -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++}@@@ -107,6 +107,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built inteam (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge anddeletion. `/api/worlds/mine` lists them as `team`.++- **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)nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.
@@ -8,6 +8,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -8,6 +8,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### 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.
- 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
### Fixed
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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)
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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)
+ })
+ }
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,326 @@
@@ -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(),
+ },
+]
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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
+ }
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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
+}
@@ -107,6 +107,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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`).
team (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).
Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge and
Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge and
deletion. `/api/worlds/mine` lists them as `team`.
deletion. `/api/worlds/mine` lists them as `team`.
+- **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`,
- **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`
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)
(50) each). A **build** is pieces chosen in the editor (boxes and other statics, resource nodes, props and lights)
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.