openvibers/openvibe.games Commits
Games: published creator worlds get their own page (/worlds/<id>) and are in OpenVibe.Search (#91)
Changes
@@@ -82,8 +82,8 @@ function el<K extends keyof HTMLElementTagNameMap>(return node}--function link(text: string, href: string, primary = false): HTMLAnchorElement {-- const a = el('a', { className: primary ? 'button primary' : 'button', text })++function link(text: string, href: string, primary = false, className?: string): HTMLAnchorElement {++ const a = el('a', { className: className ?? (primary ? 'button primary' : 'button'), text })a.href = hrefreturn a}@@@ -99,6 +99,15 @@ function button(return b}++/** A world's title: a link to its own page (/worlds/<id>) once it is published, plain text before. */++function titleOf(world: World): HTMLElement {++ const heading = el('h3')++ if ((world.status ?? 'published') === 'published')++ heading.append(link(world.title, `/worlds/${world.id}`, false, 'title'))++ else heading.textContent = world.title++ return heading++}++const count = (n: number, one: string, many: string): string =>`${n.toLocaleString()} ${n === 1 ? one : many}`@@@ -112,7 +121,7 @@ function publishedCard(world: World, staff: boolean): HTMLElement {const card = el('article',{ className: 'card' },-- el('h3', { text: world.title }),++ titleOf(world),el('p', { className: 'by', text: `by ${world.owner}` }),world.summary ? el('p', { className: 'summary', text: world.summary }) : null,el('p', { className: 'stats', text: stats(world) }),@@@ -137,7 +146,7 @@ function mineCard(world: World): HTMLElement {'article',{ className: 'card' },el('span', { className: `badge ${status}`, text: label }),-- el('h3', { text: world.title }),++ titleOf(world),world.summary ? el('p', { className: 'summary', text: world.summary }) : null,el('p', { className: 'stats', text: stats(world) }),)@@@ -107,6 +107,14 @@font-weight: 800;overflow-wrap: anywhere;}++ .card h3 a.title {++ color: inherit;++ text-decoration: none;++ }++ .card h3 a.title:hover,++ .card h3 a.title:focus-visible {++ text-decoration: underline;++ }.card .by,.card .stats {margin: 0;@@@ -22,6 +22,7 @@"ajv": "^8.17.1","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1","pg": "^8.23.0",@@@ -87,6 +87,8 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'import { CreatorWorldHost } from './worlds/worldHost.js'import { createWorldHubStarter } from './worlds/startWorldHub.js'import { createWorldRoutes } from './worlds/worldRoutes.js'++import { createWorldPage } from './worlds/worldPage.js'++import { createWorldSearchIndex } from './worlds/searchIndex.js'/*** Dedicated authoritative server entry point.@@@ -758,6 +760,19 @@ async function main(): Promise<void> {// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).const clientAddress = (req: IncomingMessage): string =>resolveClientAddress(req, config.trustProxy)++ // Published creator worlds in OpenVibe.Search (worlds/searchIndex.ts): through the outbox, so only with events on.++ const gamesSiteUrl = config.oauth?.selfUrl ?? 'https://openvibe.games'++ const worldSearch = outbox++ ? createWorldSearchIndex({++ db,++ outbox,++ baseUrl: gamesSiteUrl,++ log: log.child({ system: 'world-search' }),++ })++ : null++ worldSearch?.start()++ // GET /worlds/<id>: a published world's own page (the one Search links to).++ const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.const serveWorlds = createWorldRoutes({store: creatorWorlds,@@@ -784,7 +799,8 @@ async function main(): Promise<void> {address: clientAddress,log: log.child({ system: 'worlds' }),perPerson: config.creatorWorldsPerPerson,-- events: { db, sink, siteUrl: config.oauth?.selfUrl ?? 'https://openvibe.games' },++ events: { db, sink, siteUrl: gamesSiteUrl },++ ...(worldSearch ? { search: worldSearch } : {}),})// The casual games' JSON API (plan T22).const serveCasual = createCasualRoutes({@@@ -892,6 +908,7 @@ async function main(): Promise<void> {}if (serveCasual(req, res)) return trueif (serveWorlds(req, res)) return true++ if (serveWorldPage(req, res)) return trueif (handleGameRead(req, res, {db,@@@ -1121,6 +1138,7 @@ async function main(): Promise<void> {clearInterval(metricsTimer)clearInterval(heartbeatTimer)clearInterval(worldSweepTimer)++ worldSearch?.stop()clearInterval(pruneTimer)editors.stop()const mirrorDone = mirror?.stop()nnew file mode 100644@@@ -0,0 +1,142 @@++import { createRequire } from 'node:module'++import { emptyMapV2 } from '@openvibe/content'++import { createCreatorWorldStore } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import type { Tx } from 'openvibe-sdk/db'++import { describe, expect, it } from 'vitest'++import { createWorldSearchIndex, describeWorld } from './searchIndex.js'++import { worldPageHtml } from './worldPage.js'++++const req = createRequire(import.meta.url)++const { validate } = req('openvibe-contracts') as {++ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }++}++const KIM = 'usr_01J0000000000000000000000K'++const silent = { info: () => {}, warn: () => {} }++++interface Sent {++ event_type: string++ payload: Record<string, unknown>++}++++describe('creator worlds in OpenVibe.Search', () => {++ it('describes a world as its page does, and a draft as a tombstone', () => {++ const row = {++ id: 'wk3j9x0a2bq',++ owner_name: 'kim',++ title: 'Moon base',++ summary: '',++ status: 'published',++ published_at: 1_000,++ updated_at: 2_000,++ }++ expect(describeWorld(row)).toMatchObject({++ listed: true,++ title: 'Moon base',++ summary: 'A creator world by kim on OpenVibe.Games.',++ facets: { kind: 'creator_world', owner: 'kim' },++ })++ expect(describeWorld({ ...row, status: 'draft' }).listed).toBe(false)++ expect(describeWorld({ ...row, status: 'hidden' }).listed).toBe(false)++ })++++ it('sends a published world, a tombstone when it is unpublished or deleted, and nothing for a draft', async () => {++ const db = await openTestDb()++ const store = createCreatorWorldStore(db)++ const sent: Sent[] = []++ const search = createWorldSearchIndex({++ db,++ outbox: {++ enqueue: async (_t: Tx, event: unknown) => {++ sent.push(event as Sent)++ },++ },++ baseUrl: 'https://openvibe.games',++ log: silent,++ })++ const draft = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Draft',++ summary: '',++ map: emptyMapV2(),++ now: 1,++ })++ await search.sweep()++ expect(sent).toEqual([])++++ const world = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Moon base',++ summary: 'Low gravity.',++ map: emptyMapV2(),++ now: 2,++ })++ const published = await store.update(world.id, { status: 'published' }, 3)++ if (!published) throw new Error('no world')++ await db.tx((t) => search.sync(t, published))++ expect(sent.map((e) => e.event_type)).toEqual(['games.index_document.upserted'])++ const doc = sent[0]?.payload ?? {}++ expect(doc).toMatchObject({++ owner: 'games',++ type: 'world',++ id: world.id,++ deleted: false,++ canonical_url: `https://openvibe.games/worlds/${world.id}`,++ title: 'Moon base',++ summary: 'Low gravity.',++ })++ expect(validate('games.index_document.upserted@1', doc)).toMatchObject({ valid: true })++++ // Unchanged: the sweep sends nothing new. Unpublished: a tombstone. Deleted afterwards: nothing more.++ await search.sweep()++ expect(sent).toHaveLength(1)++ const unpublished = await store.update(world.id, { status: 'draft' }, 4)++ if (!unpublished) throw new Error('no world')++ await db.tx((t) => search.sync(t, unpublished))++ expect(sent.map((e) => e.event_type)).toEqual([++ 'games.index_document.upserted',++ 'games.index_document.deleted',++ ])++ expect(validate('games.index_document.deleted@1', sent[1]?.payload)).toMatchObject({++ valid: true,++ })++++ // A published world whose row disappears without a sync gets its tombstone from the sweep.++ const again = await store.update(world.id, { status: 'published' }, 5)++ if (!again) throw new Error('no world')++ await db.tx((t) => search.sync(t, again))++ await store.remove(world.id)++ await search.sweep()++ expect(sent.at(-1)?.event_type).toBe('games.index_document.deleted')++ expect(sent.some((e) => e.payload.id === draft.id)).toBe(false)++ })++++ it("renders a world's page with its text escaped", () => {++ const shell = req('openvibe-shared/shell') as { page(o: Record<string, unknown>): string }++ const html = worldPageHtml(++ {++ id: 'wk3j9x0a2bq',++ ownerSubject: KIM,++ ownerName: 'kim <b>',++ title: 'Moon <script>',++ summary: 'Low & slow',++ status: 'published',++ revision: 'r',++ plays: 1,++ createdAt: 1,++ updatedAt: 2,++ publishedAt: 2,++ },++ 'https://openvibe.games',++ shell,++ )++ expect(html).toContain('Moon <script>')++ expect(html).not.toContain('Moon <script>')++ expect(html).toContain('by kim <b>')++ expect(html).toContain('href="/play?world=wk3j9x0a2bq"')++ expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')++ expect(html).toContain('1 visit')++ })++})nnew file mode 100644@@@ -0,0 +1,156 @@++import { createRequire } from 'node:module'++import type { CreatorWorld } from '@openvibe/persistence'++import type { Db, Tx } from 'openvibe-sdk/db'++import { sql } from 'openvibe-sdk/db'++++/**++ * Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): one++ * search.index-document@1 per published world (/worlds/<id>), sent as games.index_document.upserted|deleted through++ * Games' event outbox in the transaction of the change. A world that is a draft, hidden or gone is a tombstone. The++ * document is what the world's page says: its title, who made it and their summary. The sequencer++ * (games_index_revisions) sends nothing for an unchanged document, so the sweep (a minute after boot, then every six++ * hours) is cheap and catches anything a write missed.++ */++++interface SearchFeed {++ sync(t: Tx, type: string, row: unknown): Promise<unknown>++ remove(t: Tx, type: string, id: string): Promise<unknown>++ sweep(): Promise<Record<string, { seen: number; sent: number; removed: number; failed: number }>>++}++interface SearchFeedModule {++ createSearchFeed(options: Record<string, unknown>): SearchFeed++}++++const START_DELAY_MS = 60_000++const INTERVAL_MS = 6 * 3_600_000++++interface Row {++ id: string++ owner_name: string++ title: string++ summary: string++ status: string++ published_at: number | string | null++ updated_at: number | string++}++++/** One world → what its /worlds/<id> page says. */++export function describeWorld(row: Row) {++ const summary = row.summary || `A creator world by ${row.owner_name} on OpenVibe.Games.`++ return {++ listed: row.status === 'published',++ title: row.title,++ summary,++ body: [++ row.title,++ row.summary,++ `Made by ${row.owner_name}.`,++ 'A creator world on OpenVibe.Games: join it from your browser, no download.',++ ]++ .filter(Boolean)++ .join('\n'),++ facets: { kind: 'creator_world', owner: row.owner_name },++ authorship: { mode: 'human' },++ publishedAt:++ row.published_at === null ? null : new Date(Number(row.published_at)).toISOString(),++ updatedAt: new Date(Number(row.updated_at)).toISOString(),++ }++}++++export interface WorldSearchIndex {++ /** Index (or tombstone) a world in the transaction that changed it. */++ sync(t: Tx, world: CreatorWorld): Promise<void>++ /** Tombstone a world in the transaction that deletes it. */++ remove(t: Tx, id: string): Promise<void>++ sweep(): Promise<void>++ start(): void++ stop(): void++}++++export function createWorldSearchIndex(opts: {++ db: Db++ outbox: { enqueue(t: Tx, event: unknown, opts?: unknown): Promise<unknown>; kick?(): unknown }++ baseUrl: string++ log: {++ info(msg: string, f?: Record<string, unknown>): void++ warn(msg: string, f?: Record<string, unknown>): void++ }++ require?: NodeRequire++}): WorldSearchIndex {++ const req = opts.require ?? createRequire(import.meta.url)++ const { createSearchFeed } = req('openvibe-publishing/search-feed') as SearchFeedModule++ const columns = sql`id, owner_name, title, summary, status, published_at, updated_at`++ const feed = createSearchFeed({++ owner: 'games',++ db: opts.db,++ outbox: opts.outbox,++ baseUrl: opts.baseUrl,++ log: { log: (m: string) => opts.log.info(m), warn: (m: string) => opts.log.warn(m) },++ types: {++ world: {++ page: (row: Row) => `/worlds/${row.id}`,++ document: describeWorld,++ rows: (after: string, limit: number) =>++ opts.db.many<Row>(++ sql`SELECT ${columns} FROM creator_worlds WHERE id > ${after} ORDER BY id LIMIT ${limit}`,++ ),++ exists: async (ids: string[]) =>++ ids.length++ ? (++ await opts.db.many<{ id: string }>(++ sql`SELECT id FROM creator_worlds WHERE id IN (${sql.join(ids.map((id) => sql`${id}`))})`,++ )++ ).map((r) => r.id)++ : [],++ },++ },++ })++ const timers: ReturnType<typeof setTimeout>[] = []++ const toRow = (w: CreatorWorld): Row => ({++ id: w.id,++ owner_name: w.ownerName,++ title: w.title,++ summary: w.summary,++ status: w.status,++ published_at: w.publishedAt,++ updated_at: w.updatedAt,++ })++ const sweep = async (): Promise<void> => {++ const res = await feed.sweep()++ const w = res.world++ if (w && (w.sent || w.removed || w.failed))++ opts.log.info('search sweep', {++ sent: w.sent,++ removed: w.removed,++ failed: w.failed,++ seen: w.seen,++ })++ void opts.outbox.kick?.()++ }++ const quietly = (): void => {++ sweep().catch((err: unknown) => opts.log.warn('search sweep failed', { error: String(err) }))++ }++ return {++ sync: async (t, world) => {++ await feed.sync(t, 'world', toRow(world))++ },++ remove: async (t, id) => {++ await feed.remove(t, 'world', id)++ },++ sweep,++ start: () => {++ if (timers.length) return++ const first = setTimeout(quietly, START_DELAY_MS)++ const every = setInterval(quietly, INTERVAL_MS)++ first.unref()++ every.unref()++ timers.push(first, every)++ },++ stop: () => {++ for (const t of timers.splice(0)) {++ clearTimeout(t)++ clearInterval(t)++ }++ },++ }++}nnew file mode 100644@@@ -0,0 +1,123 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import { createRequire } from 'node:module'++import { CREATOR_WORLD_ID } from '@openvibe/content'++import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'++import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'++++/**++ * GET /worlds/<id>: a published creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page++ * OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have++ * visited, and the button that opens it in /play. Anything not published is a real 404 (a draft is its owner's, and++ * the owner reaches it from /worlds).++ */++interface Shell {++ page(o: Record<string, unknown>): string++}++++const SITE_NAME = 'OpenVibe.Games'++const NETWORK_URL = 'https://openvibe.network'++const LINKS: NotFoundLink[] = [++ { href: '/worlds', label: 'Creator worlds' },++ { href: '/', label: 'OpenVibe.Games home' },++]++++const STYLE = `<style>++.world{max-width:760px;margin:0 auto;padding:28px 16px 64px}++.world .eyebrow{margin:0 0 6px;font-size:12px;font-weight:800;letter-spacing:.14em;color:var(--secondary,#22d3ee)}++.world h1{margin:0 0 6px;font-size:clamp(28px,5vw,40px);font-weight:900;overflow-wrap:anywhere}++.world .by,.world .stats{margin:0 0 10px;color:var(--text-dim,#96a7c2)}++.world .summary{margin:14px 0;font-size:18px;overflow-wrap:anywhere}++.world .row{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}++.world .play{display:inline-flex;align-items:center;min-height:44px;padding:10px 26px;border-radius:12px;background:var(--accent-strong,#2563eb);color:var(--on-accent-strong,#fff);font-weight:800;text-decoration:none}++.world .more{display:inline-flex;align-items:center;min-height:44px;padding:10px 18px;border-radius:12px;border:1px solid var(--border,#1f2d47);color:inherit;text-decoration:none}++.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}++</style>`++++export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell): string {++ const url = `${siteUrl}/worlds/${world.id}`++ const description =++ world.summary ||++ `A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`++ const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}`++ const links = [++ { label: 'Creator worlds', href: '/worlds' },++ { label: 'Play Scraplandia', href: '/play' },++ ]++ return shell.page({++ name: SITE_NAME,++ service: 'games',++ lang: 'en',++ title: `${world.title} · Creator world · ${SITE_NAME}`,++ siteName: SITE_NAME,++ description: description.slice(0, 300),++ canonical: url,++ robots: 'index, follow',++ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,++ navbar: {++ service: 'games',++ apiBase: NETWORK_URL,++ links,++ history: { type: 'page', title: world.title },++ },++ footer: {++ service: 'games',++ variant: 'full',++ mount: '#ov-footer',++ brandName: SITE_NAME,++ updates: '/updates',++ },++ home: '/',++ navLinks: links,++ body: `<div id="navbar-mount"></div>++<main id="main" class="world">++ <p class="eyebrow">CREATOR WORLD</p>++ <h1>${escapeHtml(world.title)}</h1>++ <p class="by">by ${escapeHtml(world.ownerName)}</p>++ ${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}++ <p class="stats">${escapeHtml(visits)}</p>++ <div class="row">++ <a class="play" href="/play?world=${world.id}">▶ Join this world</a>++ <a class="more" href="/worlds">More worlds</a>++ </div>++ <p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a++ sandbox: what you gather or build inside it stays in it.</p>++</main>`,++ })++}++++export function createWorldPage(opts: {++ store: CreatorWorldStore++ siteUrl: string++ require?: NodeRequire++}) {++ const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell++ const siteUrl = opts.siteUrl.replace(/\/+$/, '')++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const path = (req.url ?? '/').split('?')[0] ?? '/'++ const match = /^\/worlds\/([^/]+)$/.exec(path)++ if (!match || (req.method !== 'GET' && req.method !== 'HEAD')) return false++ const id = match[1] ?? ''++ void (async () => {++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null++ if (!world || world.status !== 'published') {++ sendNotFound(req, res, path, LINKS)++ return++ }++ const body = worldPageHtml(world, siteUrl, shell)++ res.writeHead(200, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'public, max-age=60',++ 'content-length': String(Buffer.byteLength(body)),++ })++ res.end(req.method === 'HEAD' ? undefined : body)++ })().catch(() => {++ if (res.headersSent) return++ res.writeHead(503, {++ 'content-type': 'text/plain; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end('world unavailable')++ })++ return true++ }++}@@@ -19,6 +19,7 @@ import {} from '../platform/gameEvents.js'import type { GamesActorLimits } from '../net/actorLimits.js'import type { NetworkUser } from '../net/networkAuth.js'++import type { WorldSearchIndex } from './searchIndex.js'/*** /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a@@@ -61,6 +62,8 @@ export interface WorldRouteOptions {* world's first publication, games.moderation.action when staff hide, restore or delete someone else's world.*/events?: { db: Db; sink: EventSink; siteUrl: string }++ /** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */++ search?: WorldSearchIndexnow?: () => number}@@@ -177,12 +180,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {const online = (id: string): number => opts.host.online().get(id) ?? 0/** Runs a change with its events in one transaction (or without events when the outbox is off). */const withEvents = <T>(-- fn: (enqueue: (event: EventInput) => Promise<void>) => Promise<T>,++ fn: (enqueue: (event: EventInput) => Promise<void>, t: Tx | null) => Promise<T>,): Promise<T> => {const events = opts.events?.sink.enabled ? opts.events : nullreturn events-- ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event)))-- : fn(async () => {})++ ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event), t))++ : fn(async () => {}, null)}const send = (@@@ -399,9 +402,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {}patch.status = status as CreatorWorldStatus}-- const updated = await withEvents(async (enqueue) => {++ const updated = await withEvents(async (enqueue, t) => {const next = await opts.store.update(id, patch, now())if (!next) return null++ // Search follows what the page shows: a published world's title and summary, or a tombstone.++ if (t && opts.search) await opts.search.sync(t, next)if (mine && patch.status === 'published' && world.publishedAt === null)await enqueue(worldPublishedEvent(next, opts.events?.siteUrl ?? ''))if (!mine && patch.status && patch.status !== world.status)@@@ -440,17 +445,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {const events = opts.events?.sink.enabled ? opts.events : nullawait opts.host.remove(id,-- byStaff && events-- ? (t) =>-- events.sink.enqueue(-- t,-- moderationEvent(-- 'world.deleted',-- { type: 'world', id, ownerSubject: world.ownerSubject },-- actorFor(user.subjectId),-- { details: { status: world.status } },-- ),-- )++ events++ ? async (t) => {++ await opts.search?.remove(t, id)++ if (byStaff)++ await events.sink.enqueue(++ t,++ moderationEvent(++ 'world.deleted',++ { type: 'world', id, ownerSubject: world.ownerSubject },++ actorFor(user.subjectId),++ { details: { status: world.status } },++ ),++ )++ }: undefined,)opts.log.info('creator world deleted', {@@@ -64,6 +64,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in`games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someoneelse's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow anaccount merge and go with an account deletion.++- **Pages and Search:** a published world has its own page at `/worlds/<id>` (server-rendered, the link to share)++ and is an OpenVibe.Search document (`games.index_document.upserted`, contracts 0.195.0, through++ `openvibe-publishing/search-feed` and the event outbox; sequencer `games_index_revisions`, migration 0011). A++ draft, hidden or deleted world is a tombstone; a sweep a minute after boot and every six hours catches up.## Adding another gamennew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): the index sequencer, the++-- last revision and content hash sent for each document (owner games, type world). The events go out through the++-- existing event_outbox, in the transaction of the change they describe.++CREATE TABLE games_index_revisions (++ owner text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ hash text NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (owner, type, id)++);@@@ -22,6 +22,7 @@ const TABLES = ['casual_actions','play_sync','creator_worlds',++ 'games_index_revisions','game_instances','character_instance_positions','world_meta',@@@ -126,6 +126,9 @@ importers:openvibe-contracts:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0++ openvibe-publishing:++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1)openvibe-sdk:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])@@@ -1347,6 +1350,17 @@ packages:engines: {node: '>=20'}hasBin: true++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0:++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0}++ version: 1.4.0++ engines: {node: '>=20'}++ peerDependencies:++ openvibe-sdk: '>=0.15.0'++ openvibe-shared: '>=1.5.0'++ peerDependenciesMeta:++ openvibe-shared:++ optional: true++openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0:resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0}version: 0.46.0@@@ -2771,6 +2785,12 @@ snapshots:ajv: 8.20.0ajv-formats: 3.0.1([email protected])++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1):++ dependencies:++ openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])++ optionalDependencies:++ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1++openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]):optionalDependencies:'@electric-sql/pglite': 0.5.8nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/worlds/searchIndex.test.ts'++// covers: 'apps/server/src/worlds'++// Generated by node test/run.js --index; do not edit.
@@ -82,8 +82,8 @@ function el<K extends keyof HTMLElementTagNameMap>(
@@ -82,8 +82,8 @@ function el<K extends keyof HTMLElementTagNameMap>(
return node
return node
}
}
-function link(text: string, href: string, primary = false): HTMLAnchorElement {
- const a = el('a', { className: primary ? 'button primary' : 'button', text })
+function link(text: string, href: string, primary = false, className?: string): HTMLAnchorElement {
+ const a = el('a', { className: className ?? (primary ? 'button primary' : 'button'), text })
a.href = href
a.href = href
return a
return a
}
}
@@ -99,6 +99,15 @@ function button(
@@ -99,6 +99,15 @@ function button(
return b
return b
}
}
+/** A world's title: a link to its own page (/worlds/<id>) once it is published, plain text before. */
+function titleOf(world: World): HTMLElement {
+ const heading = el('h3')
+ if ((world.status ?? 'published') === 'published')
+ heading.append(link(world.title, `/worlds/${world.id}`, false, 'title'))
+ else heading.textContent = world.title
+ return heading
+}
+
const count = (n: number, one: string, many: string): string =>
const count = (n: number, one: string, many: string): string =>
`${n.toLocaleString()} ${n === 1 ? one : many}`
`${n.toLocaleString()} ${n === 1 ? one : many}`
@@ -112,7 +121,7 @@ function publishedCard(world: World, staff: boolean): HTMLElement {
@@ -112,7 +121,7 @@ function publishedCard(world: World, staff: boolean): HTMLElement {
const card = el(
const card = el(
'article',
'article',
{ className: 'card' },
{ className: 'card' },
- el('h3', { text: world.title }),
+ titleOf(world),
el('p', { className: 'by', text: `by ${world.owner}` }),
el('p', { className: 'by', text: `by ${world.owner}` }),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
el('p', { className: 'stats', text: stats(world) }),
@@ -137,7 +146,7 @@ function mineCard(world: World): HTMLElement {
@@ -137,7 +146,7 @@ function mineCard(world: World): HTMLElement {
'article',
'article',
{ className: 'card' },
{ className: 'card' },
el('span', { className: `badge ${status}`, text: label }),
el('span', { className: `badge ${status}`, text: label }),
- el('h3', { text: world.title }),
+ titleOf(world),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
el('p', { className: 'stats', text: stats(world) }),
)
)
@@ -107,6 +107,14 @@
@@ -107,6 +107,14 @@
font-weight: 800;
font-weight: 800;
overflow-wrap: anywhere;
overflow-wrap: anywhere;
}
}
+ .card h3 a.title {
+ color: inherit;
+ text-decoration: none;
+ }
+ .card h3 a.title:hover,
+ .card h3 a.title:focus-visible {
+ text-decoration: underline;
+ }
.card .by,
.card .by,
.card .stats {
.card .stats {
margin: 0;
margin: 0;
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -87,6 +87,8 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
@@ -87,6 +87,8 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
+import { createWorldPage } from './worlds/worldPage.js'
+import { createWorldSearchIndex } from './worlds/searchIndex.js'
/**
/**
* Dedicated authoritative server entry point.
* Dedicated authoritative server entry point.
@@ -758,6 +760,19 @@ async function main(): Promise<void> {
@@ -758,6 +760,19 @@ async function main(): Promise<void> {
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
const clientAddress = (req: IncomingMessage): string =>
const clientAddress = (req: IncomingMessage): string =>
resolveClientAddress(req, config.trustProxy)
resolveClientAddress(req, config.trustProxy)
+ // Published creator worlds in OpenVibe.Search (worlds/searchIndex.ts): through the outbox, so only with events on.
+ const gamesSiteUrl = config.oauth?.selfUrl ?? 'https://openvibe.games'
+ const worldSearch = outbox
+ ? createWorldSearchIndex({
+ db,
+ outbox,
+ baseUrl: gamesSiteUrl,
+ log: log.child({ system: 'world-search' }),
+ })
+ : null
+ worldSearch?.start()
+ // GET /worlds/<id>: a published world's own page (the one Search links to).
+ const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })
// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
const serveWorlds = createWorldRoutes({
const serveWorlds = createWorldRoutes({
store: creatorWorlds,
store: creatorWorlds,
@@ -784,7 +799,8 @@ async function main(): Promise<void> {
@@ -784,7 +799,8 @@ async function main(): Promise<void> {
address: clientAddress,
address: clientAddress,
log: log.child({ system: 'worlds' }),
log: log.child({ system: 'worlds' }),
perPerson: config.creatorWorldsPerPerson,
perPerson: config.creatorWorldsPerPerson,
- events: { db, sink, siteUrl: config.oauth?.selfUrl ?? 'https://openvibe.games' },
+ events: { db, sink, siteUrl: gamesSiteUrl },
+ ...(worldSearch ? { search: worldSearch } : {}),
})
})
// The casual games' JSON API (plan T22).
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
const serveCasual = createCasualRoutes({
@@ -892,6 +908,7 @@ async function main(): Promise<void> {
@@ -892,6 +908,7 @@ async function main(): Promise<void> {
}
}
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorlds(req, res)) return true
+ if (serveWorldPage(req, res)) return true
if (
if (
handleGameRead(req, res, {
handleGameRead(req, res, {
db,
db,
@@ -1121,6 +1138,7 @@ async function main(): Promise<void> {
@@ -1121,6 +1138,7 @@ async function main(): Promise<void> {
clearInterval(metricsTimer)
clearInterval(metricsTimer)
clearInterval(heartbeatTimer)
clearInterval(heartbeatTimer)
clearInterval(worldSweepTimer)
clearInterval(worldSweepTimer)
+ worldSearch?.stop()
clearInterval(pruneTimer)
clearInterval(pruneTimer)
editors.stop()
editors.stop()
const mirrorDone = mirror?.stop()
const mirrorDone = mirror?.stop()
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+import { createRequire } from 'node:module'
+import { emptyMapV2 } from '@openvibe/content'
+import { createCreatorWorldStore } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import type { Tx } from 'openvibe-sdk/db'
+import { describe, expect, it } from 'vitest'
+import { createWorldSearchIndex, describeWorld } from './searchIndex.js'
+import { worldPageHtml } from './worldPage.js'
+
+const req = createRequire(import.meta.url)
+const { validate } = req('openvibe-contracts') as {
+ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }
+}
+const KIM = 'usr_01J0000000000000000000000K'
+const silent = { info: () => {}, warn: () => {} }
+
+interface Sent {
+ event_type: string
+ payload: Record<string, unknown>
+}
+
+describe('creator worlds in OpenVibe.Search', () => {
+ it('describes a world as its page does, and a draft as a tombstone', () => {
+ const row = {
+ id: 'wk3j9x0a2bq',
+ owner_name: 'kim',
+ title: 'Moon base',
+ summary: '',
+ status: 'published',
+ published_at: 1_000,
+ updated_at: 2_000,
+ }
+ expect(describeWorld(row)).toMatchObject({
+ listed: true,
+ title: 'Moon base',
+ summary: 'A creator world by kim on OpenVibe.Games.',
+ facets: { kind: 'creator_world', owner: 'kim' },
+ })
+ expect(describeWorld({ ...row, status: 'draft' }).listed).toBe(false)
+ expect(describeWorld({ ...row, status: 'hidden' }).listed).toBe(false)
+ })
+
+ it('sends a published world, a tombstone when it is unpublished or deleted, and nothing for a draft', async () => {
+ const db = await openTestDb()
+ const store = createCreatorWorldStore(db)
+ const sent: Sent[] = []
+ const search = createWorldSearchIndex({
+ db,
+ outbox: {
+ enqueue: async (_t: Tx, event: unknown) => {
+ sent.push(event as Sent)
+ },
+ },
+ baseUrl: 'https://openvibe.games',
+ log: silent,
+ })
+ const draft = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Draft',
+ summary: '',
+ map: emptyMapV2(),
+ now: 1,
+ })
+ await search.sweep()
+ expect(sent).toEqual([])
+
+ const world = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Moon base',
+ summary: 'Low gravity.',
+ map: emptyMapV2(),
+ now: 2,
+ })
+ const published = await store.update(world.id, { status: 'published' }, 3)
+ if (!published) throw new Error('no world')
+ await db.tx((t) => search.sync(t, published))
+ expect(sent.map((e) => e.event_type)).toEqual(['games.index_document.upserted'])
+ const doc = sent[0]?.payload ?? {}
+ expect(doc).toMatchObject({
+ owner: 'games',
+ type: 'world',
+ id: world.id,
+ deleted: false,
+ canonical_url: `https://openvibe.games/worlds/${world.id}`,
+ title: 'Moon base',
+ summary: 'Low gravity.',
+ })
+ expect(validate('games.index_document.upserted@1', doc)).toMatchObject({ valid: true })
+
+ // Unchanged: the sweep sends nothing new. Unpublished: a tombstone. Deleted afterwards: nothing more.
+ await search.sweep()
+ expect(sent).toHaveLength(1)
+ const unpublished = await store.update(world.id, { status: 'draft' }, 4)
+ if (!unpublished) throw new Error('no world')
+ await db.tx((t) => search.sync(t, unpublished))
+ expect(sent.map((e) => e.event_type)).toEqual([
+ 'games.index_document.upserted',
+ 'games.index_document.deleted',
+ ])
+ expect(validate('games.index_document.deleted@1', sent[1]?.payload)).toMatchObject({
+ valid: true,
+ })
+
+ // A published world whose row disappears without a sync gets its tombstone from the sweep.
+ const again = await store.update(world.id, { status: 'published' }, 5)
+ if (!again) throw new Error('no world')
+ await db.tx((t) => search.sync(t, again))
+ await store.remove(world.id)
+ await search.sweep()
+ expect(sent.at(-1)?.event_type).toBe('games.index_document.deleted')
+ expect(sent.some((e) => e.payload.id === draft.id)).toBe(false)
+ })
+
+ it("renders a world's page with its text escaped", () => {
+ const shell = req('openvibe-shared/shell') as { page(o: Record<string, unknown>): string }
+ const html = worldPageHtml(
+ {
+ id: 'wk3j9x0a2bq',
+ ownerSubject: KIM,
+ ownerName: 'kim <b>',
+ title: 'Moon <script>',
+ summary: 'Low & slow',
+ status: 'published',
+ revision: 'r',
+ plays: 1,
+ createdAt: 1,
+ updatedAt: 2,
+ publishedAt: 2,
+ },
+ 'https://openvibe.games',
+ shell,
+ )
+ expect(html).toContain('Moon <script>')
+ expect(html).not.toContain('Moon <script>')
+ expect(html).toContain('by kim <b>')
+ expect(html).toContain('href="/play?world=wk3j9x0a2bq"')
+ expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')
+ expect(html).toContain('1 visit')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+import { createRequire } from 'node:module'
+import type { CreatorWorld } from '@openvibe/persistence'
+import type { Db, Tx } from 'openvibe-sdk/db'
+import { sql } from 'openvibe-sdk/db'
+
+/**
+ * Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): one
+ * search.index-document@1 per published world (/worlds/<id>), sent as games.index_document.upserted|deleted through
+ * Games' event outbox in the transaction of the change. A world that is a draft, hidden or gone is a tombstone. The
+ * document is what the world's page says: its title, who made it and their summary. The sequencer
+ * (games_index_revisions) sends nothing for an unchanged document, so the sweep (a minute after boot, then every six
+ * hours) is cheap and catches anything a write missed.
+ */
+
+interface SearchFeed {
+ sync(t: Tx, type: string, row: unknown): Promise<unknown>
+ remove(t: Tx, type: string, id: string): Promise<unknown>
+ sweep(): Promise<Record<string, { seen: number; sent: number; removed: number; failed: number }>>
+}
+interface SearchFeedModule {
+ createSearchFeed(options: Record<string, unknown>): SearchFeed
+}
+
+const START_DELAY_MS = 60_000
+const INTERVAL_MS = 6 * 3_600_000
+
+interface Row {
+ id: string
+ owner_name: string
+ title: string
+ summary: string
+ status: string
+ published_at: number | string | null
+ updated_at: number | string
+}
+
+/** One world → what its /worlds/<id> page says. */
+export function describeWorld(row: Row) {
+ const summary = row.summary || `A creator world by ${row.owner_name} on OpenVibe.Games.`
+ return {
+ listed: row.status === 'published',
+ title: row.title,
+ summary,
+ body: [
+ row.title,
+ row.summary,
+ `Made by ${row.owner_name}.`,
+ 'A creator world on OpenVibe.Games: join it from your browser, no download.',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ facets: { kind: 'creator_world', owner: row.owner_name },
+ authorship: { mode: 'human' },
+ publishedAt:
+ row.published_at === null ? null : new Date(Number(row.published_at)).toISOString(),
+ updatedAt: new Date(Number(row.updated_at)).toISOString(),
+ }
+}
+
+export interface WorldSearchIndex {
+ /** Index (or tombstone) a world in the transaction that changed it. */
+ sync(t: Tx, world: CreatorWorld): Promise<void>
+ /** Tombstone a world in the transaction that deletes it. */
+ remove(t: Tx, id: string): Promise<void>
+ sweep(): Promise<void>
+ start(): void
+ stop(): void
+}
+
+export function createWorldSearchIndex(opts: {
+ db: Db
+ outbox: { enqueue(t: Tx, event: unknown, opts?: unknown): Promise<unknown>; kick?(): unknown }
+ baseUrl: string
+ log: {
+ info(msg: string, f?: Record<string, unknown>): void
+ warn(msg: string, f?: Record<string, unknown>): void
+ }
+ require?: NodeRequire
+}): WorldSearchIndex {
+ const req = opts.require ?? createRequire(import.meta.url)
+ const { createSearchFeed } = req('openvibe-publishing/search-feed') as SearchFeedModule
+ const columns = sql`id, owner_name, title, summary, status, published_at, updated_at`
+ const feed = createSearchFeed({
+ owner: 'games',
+ db: opts.db,
+ outbox: opts.outbox,
+ baseUrl: opts.baseUrl,
+ log: { log: (m: string) => opts.log.info(m), warn: (m: string) => opts.log.warn(m) },
+ types: {
+ world: {
+ page: (row: Row) => `/worlds/${row.id}`,
+ document: describeWorld,
+ rows: (after: string, limit: number) =>
+ opts.db.many<Row>(
+ sql`SELECT ${columns} FROM creator_worlds WHERE id > ${after} ORDER BY id LIMIT ${limit}`,
+ ),
+ exists: async (ids: string[]) =>
+ ids.length
+ ? (
+ await opts.db.many<{ id: string }>(
+ sql`SELECT id FROM creator_worlds WHERE id IN (${sql.join(ids.map((id) => sql`${id}`))})`,
+ )
+ ).map((r) => r.id)
+ : [],
+ },
+ },
+ })
+ const timers: ReturnType<typeof setTimeout>[] = []
+ const toRow = (w: CreatorWorld): Row => ({
+ id: w.id,
+ owner_name: w.ownerName,
+ title: w.title,
+ summary: w.summary,
+ status: w.status,
+ published_at: w.publishedAt,
+ updated_at: w.updatedAt,
+ })
+ const sweep = async (): Promise<void> => {
+ const res = await feed.sweep()
+ const w = res.world
+ if (w && (w.sent || w.removed || w.failed))
+ opts.log.info('search sweep', {
+ sent: w.sent,
+ removed: w.removed,
+ failed: w.failed,
+ seen: w.seen,
+ })
+ void opts.outbox.kick?.()
+ }
+ const quietly = (): void => {
+ sweep().catch((err: unknown) => opts.log.warn('search sweep failed', { error: String(err) }))
+ }
+ return {
+ sync: async (t, world) => {
+ await feed.sync(t, 'world', toRow(world))
+ },
+ remove: async (t, id) => {
+ await feed.remove(t, 'world', id)
+ },
+ sweep,
+ start: () => {
+ if (timers.length) return
+ const first = setTimeout(quietly, START_DELAY_MS)
+ const every = setInterval(quietly, INTERVAL_MS)
+ first.unref()
+ every.unref()
+ timers.push(first, every)
+ },
+ stop: () => {
+ for (const t of timers.splice(0)) {
+ clearTimeout(t)
+ clearInterval(t)
+ }
+ },
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+import { CREATOR_WORLD_ID } from '@openvibe/content'
+import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
+import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'
+
+/**
+ * GET /worlds/<id>: a published creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page
+ * OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have
+ * visited, and the button that opens it in /play. Anything not published is a real 404 (a draft is its owner's, and
+ * the owner reaches it from /worlds).
+ */
+interface Shell {
+ page(o: Record<string, unknown>): string
+}
+
+const SITE_NAME = 'OpenVibe.Games'
+const NETWORK_URL = 'https://openvibe.network'
+const LINKS: NotFoundLink[] = [
+ { href: '/worlds', label: 'Creator worlds' },
+ { href: '/', label: 'OpenVibe.Games home' },
+]
+
+const STYLE = `<style>
+.world{max-width:760px;margin:0 auto;padding:28px 16px 64px}
+.world .eyebrow{margin:0 0 6px;font-size:12px;font-weight:800;letter-spacing:.14em;color:var(--secondary,#22d3ee)}
+.world h1{margin:0 0 6px;font-size:clamp(28px,5vw,40px);font-weight:900;overflow-wrap:anywhere}
+.world .by,.world .stats{margin:0 0 10px;color:var(--text-dim,#96a7c2)}
+.world .summary{margin:14px 0;font-size:18px;overflow-wrap:anywhere}
+.world .row{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}
+.world .play{display:inline-flex;align-items:center;min-height:44px;padding:10px 26px;border-radius:12px;background:var(--accent-strong,#2563eb);color:var(--on-accent-strong,#fff);font-weight:800;text-decoration:none}
+.world .more{display:inline-flex;align-items:center;min-height:44px;padding:10px 18px;border-radius:12px;border:1px solid var(--border,#1f2d47);color:inherit;text-decoration:none}
+.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}
+</style>`
+
+export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell): string {
+ const url = `${siteUrl}/worlds/${world.id}`
+ const description =
+ world.summary ||
+ `A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`
+ const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}`
+ const links = [
+ { label: 'Creator worlds', href: '/worlds' },
+ { label: 'Play Scraplandia', href: '/play' },
+ ]
+ return shell.page({
+ name: SITE_NAME,
+ service: 'games',
+ lang: 'en',
+ title: `${world.title} · Creator world · ${SITE_NAME}`,
+ siteName: SITE_NAME,
+ description: description.slice(0, 300),
+ canonical: url,
+ robots: 'index, follow',
+ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,
+ navbar: {
+ service: 'games',
+ apiBase: NETWORK_URL,
+ links,
+ history: { type: 'page', title: world.title },
+ },
+ footer: {
+ service: 'games',
+ variant: 'full',
+ mount: '#ov-footer',
+ brandName: SITE_NAME,
+ updates: '/updates',
+ },
+ home: '/',
+ navLinks: links,
+ body: `<div id="navbar-mount"></div>
+<main id="main" class="world">
+ <p class="eyebrow">CREATOR WORLD</p>
+ <h1>${escapeHtml(world.title)}</h1>
+ <p class="by">by ${escapeHtml(world.ownerName)}</p>
+ ${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}
+ <p class="stats">${escapeHtml(visits)}</p>
+ <div class="row">
+ <a class="play" href="/play?world=${world.id}">▶ Join this world</a>
+ <a class="more" href="/worlds">More worlds</a>
+ </div>
+ <p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a
+ sandbox: what you gather or build inside it stays in it.</p>
+</main>`,
+ })
+}
+
+export function createWorldPage(opts: {
+ store: CreatorWorldStore
+ siteUrl: string
+ require?: NodeRequire
+}) {
+ const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell
+ const siteUrl = opts.siteUrl.replace(/\/+$/, '')
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const path = (req.url ?? '/').split('?')[0] ?? '/'
+ const match = /^\/worlds\/([^/]+)$/.exec(path)
+ if (!match || (req.method !== 'GET' && req.method !== 'HEAD')) return false
+ const id = match[1] ?? ''
+ void (async () => {
+ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
+ if (!world || world.status !== 'published') {
+ sendNotFound(req, res, path, LINKS)
+ return
+ }
+ const body = worldPageHtml(world, siteUrl, shell)
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'public, max-age=60',
+ 'content-length': String(Buffer.byteLength(body)),
+ })
+ res.end(req.method === 'HEAD' ? undefined : body)
+ })().catch(() => {
+ if (res.headersSent) return
+ res.writeHead(503, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end('world unavailable')
+ })
+ return true
+ }
+}
@@ -19,6 +19,7 @@ import {
@@ -19,6 +19,7 @@ import {
} from '../platform/gameEvents.js'
} from '../platform/gameEvents.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
import type { NetworkUser } from '../net/networkAuth.js'
+import type { WorldSearchIndex } from './searchIndex.js'
/**
/**
* /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
* /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
@@ -61,6 +62,8 @@ export interface WorldRouteOptions {
@@ -61,6 +62,8 @@ export interface WorldRouteOptions {
* world's first publication, games.moderation.action when staff hide, restore or delete someone else's world.
* world's first publication, games.moderation.action when staff hide, restore or delete someone else's world.
*/
*/
events?: { db: Db; sink: EventSink; siteUrl: string }
events?: { db: Db; sink: EventSink; siteUrl: string }
+ /** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
+ search?: WorldSearchIndex
now?: () => number
now?: () => number
}
}
@@ -177,12 +180,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -177,12 +180,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const online = (id: string): number => opts.host.online().get(id) ?? 0
const online = (id: string): number => opts.host.online().get(id) ?? 0
/** Runs a change with its events in one transaction (or without events when the outbox is off). */
/** Runs a change with its events in one transaction (or without events when the outbox is off). */
const withEvents = <T>(
const withEvents = <T>(
- fn: (enqueue: (event: EventInput) => Promise<void>) => Promise<T>,
+ fn: (enqueue: (event: EventInput) => Promise<void>, t: Tx | null) => Promise<T>,
): Promise<T> => {
): Promise<T> => {
const events = opts.events?.sink.enabled ? opts.events : null
const events = opts.events?.sink.enabled ? opts.events : null
return events
return events
- ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event)))
- : fn(async () => {})
+ ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event), t))
+ : fn(async () => {}, null)
}
}
const send = (
const send = (
@@ -399,9 +402,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -399,9 +402,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
}
}
patch.status = status as CreatorWorldStatus
patch.status = status as CreatorWorldStatus
}
}
- const updated = await withEvents(async (enqueue) => {
+ const updated = await withEvents(async (enqueue, t) => {
const next = await opts.store.update(id, patch, now())
const next = await opts.store.update(id, patch, now())
if (!next) return null
if (!next) return null
+ // Search follows what the page shows: a published world's title and summary, or a tombstone.
+ if (t && opts.search) await opts.search.sync(t, next)
if (mine && patch.status === 'published' && world.publishedAt === null)
if (mine && patch.status === 'published' && world.publishedAt === null)
await enqueue(worldPublishedEvent(next, opts.events?.siteUrl ?? ''))
await enqueue(worldPublishedEvent(next, opts.events?.siteUrl ?? ''))
if (!mine && patch.status && patch.status !== world.status)
if (!mine && patch.status && patch.status !== world.status)
@@ -440,17 +445,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -440,17 +445,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const events = opts.events?.sink.enabled ? opts.events : null
const events = opts.events?.sink.enabled ? opts.events : null
await opts.host.remove(
await opts.host.remove(
id,
id,
- byStaff && events
- ? (t) =>
- events.sink.enqueue(
- t,
- moderationEvent(
- 'world.deleted',
- { type: 'world', id, ownerSubject: world.ownerSubject },
- actorFor(user.subjectId),
- { details: { status: world.status } },
- ),
- )
+ events
+ ? async (t) => {
+ await opts.search?.remove(t, id)
+ if (byStaff)
+ await events.sink.enqueue(
+ t,
+ moderationEvent(
+ 'world.deleted',
+ { type: 'world', id, ownerSubject: world.ownerSubject },
+ actorFor(user.subjectId),
+ { details: { status: world.status } },
+ ),
+ )
+ }
: undefined,
: undefined,
)
)
opts.log.info('creator world deleted', {
opts.log.info('creator world deleted', {
@@ -64,6 +64,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -64,6 +64,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
`games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someone
`games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someone
else's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow an
else's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow an
account merge and go with an account deletion.
account merge and go with an account deletion.
+- **Pages and Search:** a published world has its own page at `/worlds/<id>` (server-rendered, the link to share)
+ and is an OpenVibe.Search document (`games.index_document.upserted`, contracts 0.195.0, through
+ `openvibe-publishing/search-feed` and the event outbox; sequencer `games_index_revisions`, migration 0011). A
+ draft, hidden or deleted world is a tombstone; a sweep a minute after boot and every six hours catches up.
## Adding another game
## Adding another game
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): the index sequencer, the
+-- last revision and content hash sent for each document (owner games, type world). The events go out through the
+-- existing event_outbox, in the transaction of the change they describe.
+CREATE TABLE games_index_revisions (
+ owner text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ hash text NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (owner, type, id)
+);
@@ -22,6 +22,7 @@ const TABLES = [
@@ -22,6 +22,7 @@ const TABLES = [
'casual_actions',
'casual_actions',
'play_sync',
'play_sync',
'creator_worlds',
'creator_worlds',
+ 'games_index_revisions',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
'world_meta',
'world_meta',
@@ -126,6 +126,9 @@ importers:
@@ -126,6 +126,9 @@ importers:
openvibe-contracts:
openvibe-contracts:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
+ openvibe-publishing:
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1)
openvibe-sdk:
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])
@@ -1347,6 +1350,17 @@ packages:
@@ -1347,6 +1350,17 @@ packages:
engines: {node: '>=20'}
engines: {node: '>=20'}
hasBin: true
hasBin: true
+ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0:
+ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0}
+ version: 1.4.0
+ engines: {node: '>=20'}
+ peerDependencies:
+ openvibe-sdk: '>=0.15.0'
+ openvibe-shared: '>=1.5.0'
+ peerDependenciesMeta:
+ openvibe-shared:
+ optional: true
+
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0:
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0:
resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0}
resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0}
version: 0.46.0
version: 0.46.0
@@ -2771,6 +2785,12 @@ snapshots:
@@ -2771,6 +2785,12 @@ snapshots:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])
+ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1):
+ dependencies:
+ openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])
+ optionalDependencies:
+ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1
+
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]):
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]):
optionalDependencies:
optionalDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite': 0.5.8
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/worlds/searchIndex.test.ts'
+// covers: 'apps/server/src/worlds'
+// Generated by node test/run.js --index; do not edit.