openvibers/openvibe.games Commits
Games: scores, unlocks and guest adoptions go to OpenVibe.Play (GAMES_PLAY_URL) (#64)
Changes
@@@ -238,6 +238,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) || `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) || `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |++| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local || `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) || `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |@@@ -98,6 +98,11 @@ export interface PlatformConfig {/** OpenVibe.Inventory base and item reward switch. */inventoryUrl: stringitems: boolean++ /**++ * OpenVibe.Play base (GAMES_PLAY_URL, host-internal in production); null = titles, scores and unlocks stay local.++ * Needs the client secret too (ADR-0007 decision 10).++ */++ playUrl: string | null/** Media namespace (tenant app id) Games writes objects into. */mediaNamespace: string/** At most one `games.world.saved` checkpoint event per this many minutes. */@@@ -136,6 +141,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {mediaUrl: env.MEDIA_URL && env.MEDIA_MIRROR !== 'off' ? trimSlash(env.MEDIA_URL) : null,inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),items: env.GAMES_ITEMS !== 'off' && !!secret,++ playUrl: env.GAMES_PLAY_URL ? trimSlash(env.GAMES_PLAY_URL) : null,mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')@@@ -60,6 +60,7 @@ import { createAccountData, networkSender, type AccountEvent } from './platform/import { MediaMirror } from './platform/mediaMirror.js'import { createPlatformClient } from './platform/serviceClient.js'import { InventoryRewards } from './platform/inventoryRewards.js'++import { PlaySync } from './platform/playSync.js'import {createRevocationEvents,ensureRevocationSubscription,@@@ -181,6 +182,14 @@ async function main(): Promise<void> {warn: (message) => log.warn(message),})inventoryRewards.start()++ // OpenVibe.Play (ADR-0007 decision 10): both titles' scores, unlocks and guest adoptions, posted from the local tables.++ const playSync = new PlaySync({++ db,++ url: platform ? config.platform.playUrl : null,++ tokens: platform?.tokens ?? null,++ warn: (message) => log.warn(message),++ })++ playSync.start()const platformLog = log.child({ system: 'platform' })const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })let outbox: PgOutbox | null = null@@@ -1021,6 +1030,7 @@ async function main(): Promise<void> {await mods.flushWrites()await progressWriter.stop()inventoryRewards?.stop()++ playSync.stop()await store.close()await valkey?.close()log.info('stopped', {nnew file mode 100644@@@ -0,0 +1,175 @@++import { describe, expect, it } from 'vitest'++import { sql } from 'openvibe-sdk/db'++import { openTestStore } from '@openvibe/persistence/testing'++import { loadPlatformConfig } from '../config.js'++import { recordHaul, recordJoined, recordRound } from '../game/progression.js'++import { createPlatformClient } from './serviceClient.js'++import { PLAY_TITLES, PlaySync, toPlayer } from './playSync.js'++++const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'++const guest = `guest:${'a'.repeat(64)}`++++function fakePlay() {++ const posts: { method: string; path: string; body: Record<string, unknown> }[] = []++ let failures = 0++ const fetchImpl = async (++ input: string | URL | Request,++ init?: RequestInit,++ ): Promise<Response> => {++ const url = new URL(++ typeof input === 'string' ? input : input instanceof URL ? input : input.url,++ )++ const reply = (status: number, body: unknown) =>++ new Response(JSON.stringify(body), {++ status,++ headers: { 'content-type': 'application/json' },++ })++ if (url.pathname === '/oauth/token') {++ const form = new URLSearchParams(String(init?.body))++ expect(form.get('audience')).toBe('openvibe.play')++ expect(form.get('scope')).toBe('play.title.manage play.result.write')++ return reply(200, { access_token: 'fake-token', token_type: 'Bearer', expires_in: 3600 })++ }++ expect(url.origin).toBe('http://play.test')++ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer fake-token')++ if (failures > 0 && url.pathname.endsWith('/scores')) {++ failures--++ return reply(503, { code: 'play.unavailable' })++ }++ posts.push({++ method: init?.method ?? 'GET',++ path: url.pathname.replace(/^\/api\/v1/, ''),++ body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>,++ })++ return reply(200, {})++ }++ return {++ fetchImpl,++ posts,++ failNextScores: () => {++ failures = 1++ },++ }++}++++async function setup(env: Record<string, string> = { GAMES_PLAY_URL: 'http://play.test/' }) {++ const store = await openTestStore()++ const fake = fakePlay()++ const config = loadPlatformConfig({++ OV_OAUTH_CLIENT_SECRET: 'secret',++ OV_NETWORK_INTERNAL_URL: 'http://network.test',++ ...env,++ })++ const platform = createPlatformClient(config, fake.fetchImpl as never)!++ const warnings: string[] = []++ const sync = new PlaySync({++ db: store.db,++ url: config.playUrl,++ tokens: platform.tokens,++ fetchImpl: fake.fetchImpl,++ warn: (m) => warnings.push(m),++ })++ return { store, fake, sync, warnings }++}++++describe('PlaySync (OpenVibe.Play, ADR-0007 decision 10)', () => {++ it('is off without GAMES_PLAY_URL', async () => {++ const { sync } = await setup({})++ expect(sync.enabled).toBe(false)++ })++++ it('maps account keys to Play players and skips what Play would refuse', () => {++ expect(toPlayer(subject)).toBe(subject)++ expect(toPlayer(guest)).toBe(guest)++ expect(toPlayer('gst_01JABCDEFGHJKMNPQRSTVWXYZ0')).toBe('guest:gst_01JABCDEFGHJKMNPQRSTVWXYZ0')++ expect(toPlayer('usr_lowercase')).toBeNull()++ expect(toPlayer('guest:short')).toBeNull()++ })++++ it('registers both titles, then posts rounds, hauls and unlocks once each', async () => {++ const { store, fake, sync } = await setup()++ await recordJoined(store.db, subject, 500)++ await recordHaul(store.db, subject, 'haul-1', 'Scrapper', 100, 1000)++ await recordRound(++ store.db,++ 'salvage-rush',++ 'round-1',++ [++ { key: subject, name: 'Scrapper', score: 12, rank: 1 },++ { key: guest, name: 'Guest', score: 4, rank: 2 },++ ],++ 2000,++ )++ const first = await sync.syncOnce()++ expect(first.rounds).toBe(2)++ expect(first.hauls).toBe(1)++ expect(first.unlocks).toBeGreaterThan(0)++++ const defined = fake.posts.filter((p) => p.method === 'PUT').map((p) => p.path)++ expect(defined).toEqual(Object.keys(PLAY_TITLES).map((id) => `/titles/${id}`))++ const sr = fake.posts.find((p) => p.method === 'PUT' && p.path === '/titles/salvage-rush')!.body++ expect(sr.boards).toEqual([{ id: 'best', name: 'Best round', kind: 'best' }])++ expect((sr.achievements as unknown[]).length).toBeGreaterThan(0)++++ const rounds = fake.posts.filter((p) => p.path === '/titles/salvage-rush/scores')++ expect(rounds).toHaveLength(1)++ expect(rounds[0]!.body).toMatchObject({ board: 'best', ref: 'round-1', at: 2000 })++ expect(rounds[0]!.body.entries).toEqual([++ { player: guest, name: 'Guest', score: 4 },++ { player: subject, name: 'Scrapper', score: 12 },++ ])++ const hauls = fake.posts.filter((p) => p.path === '/titles/scraplandia/scores')++ expect(hauls.map((p) => p.body)).toEqual([++ {++ board: 'haul',++ ref: 'haul-1',++ at: 1000,++ entries: [{ player: subject, name: 'Scrapper', score: 100 }],++ },++ ])++ const unlocks = fake.posts.filter((p) => p.path.endsWith('/unlocks'))++ expect(unlocks.length).toBe(first.unlocks)++ for (const u of unlocks) {++ const title = u.path.split('/')[2]! as keyof typeof PLAY_TITLES++ const known = PLAY_TITLES[title].achievements.map((a) => a.id)++ expect(known).toContain((u.body.achievements as string[])[0])++ }++++ // Nothing new: the second pass posts nothing and does not register the titles again.++ const before = fake.posts.length++ expect(await sync.syncOnce()).toEqual({ rounds: 0, hauls: 0, unlocks: 0, adoptions: 0 })++ expect(fake.posts.length).toBe(before)++ })++++ it('keeps its place when Play fails and resumes there', async () => {++ const { store, fake, sync } = await setup()++ await recordHaul(store.db, subject, 'haul-1', 'Scrapper', 100, 1000)++ await recordHaul(store.db, subject, 'haul-2', 'Scrapper', 50, 1100)++ await sync.defineTitles()++ fake.failNextScores()++ await expect(sync.syncOnce()).rejects.toThrow(/answered 503/)++ expect(fake.posts.filter((p) => p.path === '/titles/scraplandia/scores')).toHaveLength(0)++ const again = await sync.syncOnce()++ expect(again.hauls).toBe(2)++ expect(++ fake.posts.filter((p) => p.path === '/titles/scraplandia/scores').map((p) => p.body.ref),++ ).toEqual(['haul-1', 'haul-2'])++ })++++ it('adopts a converted guest in both titles, once; merges are left to Play', async () => {++ const { store, fake, sync } = await setup()++ await store.db.exec(sql`++ INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)++ VALUES (${guest}, ${subject}, 'guest', 1, 0, 3000), ('merge:m1', ${subject}, 'merge', 1, 0, 3100)++ `)++ expect((await sync.syncOnce()).adoptions).toBe(1)++ const adopts = fake.posts.filter((p) => p.path.endsWith('/adopt'))++ expect(adopts.map((p) => p.path)).toEqual(++ Object.keys(PLAY_TITLES).map(++ (id) => `/titles/${id}/players/${encodeURIComponent(guest)}/adopt`,++ ),++ )++ expect(adopts.every((p) => p.body.person === subject)).toBe(true)++ expect((await sync.syncOnce()).adoptions).toBe(0)++ })++})nnew file mode 100644@@@ -0,0 +1,303 @@++/**++ * OpenVibe.Play (ADR-0007 decision 10): Games' titles, scores and unlocks on the platform's game services.++ *++ * At start Games registers its two titles (Scraplandia and Salvage Rush: their achievements from the packs, and one++ * board each). Every SYNC_MS it posts what the local tables recorded after its last position, stream by stream:++ *++ * rounds game_round_results → salvage-rush / best (a round's id is the ref)++ * hauls game_hauls → scraplandia / haul (an extraction's id is the ref; the board sums them)++ * unlocks game_achievements → the game's unlocks (only achievements the title defines)++ * adoptions identity_adoptions (guest) → the guest's rows in Play become the person's, in both titles++ *++ * Every post is idempotent in Play, so a restart or a retry never counts twice, and the first run imports the history.++ * The position (play_sync) moves only past what Play accepted: with Play down nothing is lost. Adoptions come last in++ * a pass: a local adoption re-keys the guest's rows in the same transaction it is recorded in, so whatever Play got++ * as the guest's arrived before the adoption is read, and what is read after it already carries the person.++ *++ * Play names a player usr_… or guest:<opaque>; a Network guest (gst_…) is sent as guest:gst_…, and a key Play would++ * refuse is skipped rather than stopping the stream.++ *++ * Off unless GAMES_PLAY_URL and the client secret are set (config.platform.playUrl).++ */++import { sql, type Db } from 'openvibe-sdk/db'++import { SCRAPLANDIA_PACK } from '@openvibe/content'++import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'++++const AUDIENCE = 'openvibe.play'++const SCOPE = 'play.title.manage play.result.write'++const SYNC_MS = 30_000++const BATCH = 500++const TIMEOUT_MS = 10_000++++/** What Games registers in Play. Rounds rank each player's best; hauls sum through the week. */++export const PLAY_TITLES = {++ scraplandia: {++ name: 'Scraplandia',++ url: 'https://openvibe.games/play',++ summary:++ "Scrappy multiplayer survival-building in and around Scrap City: haul your salvage home and climb the week's haul board.",++ achievements: (SCRAPLANDIA_PACK.achievements ?? []).map((a) => ({++ id: a.id,++ name: a.name,++ description: a.description,++ })),++ boards: [{ id: 'haul', name: 'Haul points', kind: 'sum' as const }],++ },++ 'salvage-rush': {++ name: 'Salvage Rush',++ url: 'https://openvibe.games/play?game=salvage-rush',++ summary: 'Collect scrap around a compact arena in 90-second rounds, scored by the server.',++ achievements: (SALVAGE_RUSH_PACK.game?.achievements ?? []).map((a) => ({++ id: a.id,++ name: a.name,++ description: a.description,++ })),++ boards: [{ id: 'best', name: 'Best round', kind: 'best' as const }],++ },++}++++type Tokens = { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }++type Cursor = { at: number; key: string }++++const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/++/** Games' account key as Play's player, or null when Play would refuse it. */++export function toPlayer(accountKey: string): string | null {++ const key = accountKey.startsWith('gst_') ? `guest:${accountKey}` : accountKey++ return PLAYER_RE.test(key) ? key : null++}++++const KNOWN: Record<string, Set<string>> = Object.fromEntries(++ Object.entries(PLAY_TITLES).map(([id, t]) => [id, new Set(t.achievements.map((a) => a.id))]),++)++++export class PlaySync {++ private timer: ReturnType<typeof setInterval> | null = null++ private running = false++ private defined = false++++ constructor(++ private readonly opts: {++ db: Db++ url: string | null++ tokens: Tokens | null++ fetchImpl?: typeof fetch++ warn?: (message: string) => void++ },++ ) {}++++ get enabled(): boolean {++ return !!this.opts.url && !!this.opts.tokens++ }++++ private async call(method: string, path: string, body: unknown): Promise<unknown> {++ const token = await this.opts.tokens!.getToken({ audience: AUDIENCE, scope: SCOPE })++ const res = await (this.opts.fetchImpl ?? fetch)(++ `${this.opts.url!.replace(/\/+$/, '')}/api/v1${path}`,++ {++ method,++ headers: {++ authorization: `Bearer ${token}`,++ 'content-type': 'application/json',++ accept: 'application/json',++ },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ },++ )++ const text = await res.text()++ if (!res.ok)++ throw new Error(`Play ${method} ${path} answered ${res.status}: ${text.slice(0, 200)}`)++ return text ? JSON.parse(text) : null++ }++++ /** Register both titles (an unchanged document changes nothing in Play). */++ async defineTitles(): Promise<void> {++ for (const [id, doc] of Object.entries(PLAY_TITLES))++ await this.call('PUT', `/titles/${id}`, doc)++ this.defined = true++ }++++ private async cursor(stream: string): Promise<Cursor> {++ const row = await this.opts.db.maybe<{ at: number | string; key: string }>(++ sql`SELECT at, key FROM play_sync WHERE stream = ${stream}`,++ )++ return row ? { at: Number(row.at), key: row.key } : { at: -1, key: '' }++ }++++ private async advance(stream: string, to: Cursor): Promise<void> {++ await this.opts.db.exec(sql`++ INSERT INTO play_sync (stream, at, key) VALUES (${stream}, ${to.at}, ${to.key})++ ON CONFLICT (stream) DO UPDATE SET at = excluded.at, key = excluded.key++ `)++ }++++ /** Salvage Rush rounds → salvage-rush/best, one post per round (more of a round may follow in the next batch). */++ private async syncRounds(): Promise<number> {++ const c = await this.cursor('rounds')++ const rows = await this.opts.db.many<{++ game_id: string++ round_id: string++ account_key: string++ player_name: string++ score: number++ finished_at: number | string++ k: string++ }>(sql`++ SELECT game_id, round_id, account_key, player_name, score, finished_at, game_id || '|' || round_id || '|' || account_key AS k++ FROM game_round_results++ WHERE finished_at > ${c.at} OR (finished_at = ${c.at} AND game_id || '|' || round_id || '|' || account_key > ${c.key})++ ORDER BY finished_at, k LIMIT ${BATCH}++ `)++ let posted = 0++ for (let i = 0; i < rows.length;) {++ const first = rows[i]!++ let j = i++ while (++ j < rows.length &&++ rows[j]!.round_id === first.round_id &&++ rows[j]!.game_id === first.game_id++ )++ j++++ const group = rows.slice(i, j)++ const entries = group.flatMap((r) => {++ const player = toPlayer(r.account_key)++ return player++ ? [{ player, name: r.player_name.slice(0, 40) || 'Player', score: Number(r.score) }]++ : []++ })++ if (first.game_id === 'salvage-rush' && entries.length) {++ await this.call('POST', '/titles/salvage-rush/scores', {++ board: 'best',++ ref: first.round_id,++ at: Number(first.finished_at),++ entries,++ })++ }++ const last = group[group.length - 1]!++ await this.advance('rounds', { at: Number(last.finished_at), key: last.k })++ posted += group.length++ i = j++ }++ return posted++ }++++ /** Scraplandia extractions → scraplandia/haul, one post per extraction. */++ private async syncHauls(): Promise<number> {++ const c = await this.cursor('hauls')++ const rows = await this.opts.db.many<{++ account_key: string++ extraction_id: string++ player_name: string++ score: number++ finished_at: number | string++ k: string++ }>(sql`++ SELECT account_key, extraction_id, player_name, score, finished_at, extraction_id || '|' || account_key AS k++ FROM game_hauls++ WHERE finished_at > ${c.at} OR (finished_at = ${c.at} AND extraction_id || '|' || account_key > ${c.key})++ ORDER BY finished_at, k LIMIT ${BATCH}++ `)++ for (const r of rows) {++ const player = toPlayer(r.account_key)++ if (player) {++ await this.call('POST', '/titles/scraplandia/scores', {++ board: 'haul',++ ref: r.extraction_id,++ at: Number(r.finished_at),++ entries: [++ { player, name: r.player_name.slice(0, 40) || 'Player', score: Number(r.score) },++ ],++ })++ }++ await this.advance('hauls', { at: Number(r.finished_at), key: r.k })++ }++ return rows.length++ }++++ /** Unlocked achievements of both games. */++ private async syncUnlocks(): Promise<number> {++ const c = await this.cursor('unlocks')++ const rows = await this.opts.db.many<{++ game_id: string++ achievement_id: string++ account_key: string++ unlocked_at: number | string++ k: string++ }>(sql`++ SELECT game_id, achievement_id, account_key, unlocked_at, game_id || '|' || achievement_id || '|' || account_key AS k++ FROM game_achievements++ WHERE unlocked_at > ${c.at} OR (unlocked_at = ${c.at} AND game_id || '|' || achievement_id || '|' || account_key > ${c.key})++ ORDER BY unlocked_at, k LIMIT ${BATCH}++ `)++ for (const r of rows) {++ const player = toPlayer(r.account_key)++ if (player && KNOWN[r.game_id]?.has(r.achievement_id)) {++ await this.call('POST', `/titles/${r.game_id}/unlocks`, {++ player,++ achievements: [r.achievement_id],++ at: Number(r.unlocked_at),++ })++ }++ await this.advance('unlocks', { at: Number(r.unlocked_at), key: r.k })++ }++ return rows.length++ }++++ /** Guests adopted by a person (local guest conversion; merges reach Play from Network itself). */++ private async syncAdoptions(): Promise<number> {++ const c = await this.cursor('adoptions')++ const rows = await this.opts.db.many<{++ adoption_key: string++ subject_id: string++ adopted_at: number | string++ }>(sql`++ SELECT adoption_key, subject_id, adopted_at FROM identity_adoptions++ WHERE source = 'guest' AND (adopted_at > ${c.at} OR (adopted_at = ${c.at} AND adoption_key > ${c.key}))++ ORDER BY adopted_at, adoption_key LIMIT ${BATCH}++ `)++ for (const r of rows) {++ const guest = toPlayer(r.adoption_key)++ if (guest?.startsWith('guest:') && toPlayer(r.subject_id)?.startsWith('usr_')) {++ for (const id of Object.keys(PLAY_TITLES)) {++ await this.call('POST', `/titles/${id}/players/${encodeURIComponent(guest)}/adopt`, {++ person: r.subject_id,++ })++ }++ }++ await this.advance('adoptions', { at: Number(r.adopted_at), key: r.adoption_key })++ }++ return rows.length++ }++++ /** One pass over the four streams; a failure stops the pass where it was (the next tick resumes there). */++ async syncOnce(): Promise<{ rounds: number; hauls: number; unlocks: number; adoptions: number }> {++ if (!this.defined) await this.defineTitles()++ const rounds = await this.syncRounds()++ const hauls = await this.syncHauls()++ const unlocks = await this.syncUnlocks()++ return { rounds, hauls, unlocks, adoptions: await this.syncAdoptions() }++ }++++ start(): void {++ if (!this.enabled || this.timer) return++ const tick = async (): Promise<void> => {++ if (this.running) return++ this.running = true++ try {++ await this.syncOnce()++ } catch (err) {++ this.opts.warn?.(`[play] sync paused: ${String((err as Error).message ?? err)}`)++ } finally {++ this.running = false++ }++ }++ this.timer = setInterval(() => void tick(), SYNC_MS)++ this.timer.unref?.()++ void tick()++ }++++ stop(): void {++ if (this.timer) clearInterval(this.timer)++ this.timer = null++ }++}@@@ -136,9 +136,11 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacinstance registries, fenced leases and game systems. `hello.place` selects Salvage Rush on the sameWebSocket endpoint; missing or unknown places use `GAMES_PLACE_ID` as the default. Room transferstays within each place. Separate processes remain possible for scale.-- **Still to build:** OpenVibe.Play as a separate service, Games as its first consumer, score/achievement migration-- to Play. This local schema is the seed for Play's per-title results and unlocks;-- the title-scoped principal/grant design in decision 10 remains the target.++ **Built (2026-10-14):** OpenVibe.Play as a separate service (contracts 0.172.0, play.openvibe.network) with the++ title-scoped design of decision 10 (a title is owned by the principal that registered it; `play.title.manage`,++ `play.result.write`, public `play.player.read`), and Games as its first consumer: both titles registered, rounds,++ hauls, unlocks and guest adoptions posted from the local tables (`GAMES_PLAY_URL`). **Still to build:** the in-game++ boards read from Play, then the local result tables go (a contract migration).- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.*2026-10-14:* quality tiers and hardware scaling are in (`render/quality.ts`, the HUD's Graphics quality); initialJS is 2.35 MB of 2.5 (579 KB gzip). The browser build now loads Rapier from the deterministic non-compat package@@@ -49,5 +49,9 @@ Guest character adoption and Network subject merges move progression (including4. Add lifecycle, score integrity, criteria, persistence, join routing and API tests.More Games processes may later be used for scale.--OpenVibe.Play is the later home of these per-title results, achievements and leaderboards.--The local tables and APIs are the seed for that extraction; Play is not yet running.++OpenVibe.Play (play.openvibe.network, contracts 0.172.0) is the network's home for these per-title results,++achievements and leaderboards. With `GAMES_PLAY_URL` set, Games registers both titles there (Scraplandia's weekly++haul board sums extractions; Salvage Rush's board ranks each player's best round) and posts every round, haul, unlock++and guest adoption from the local tables (`apps/server/src/platform/playSync.ts`, position in `play_sync`). Every post++is idempotent, so the first run imports the history. The local tables still serve the in-game boards; reading them++from Play and dropping the local copies is the next step.nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- ADR-0007 decision 10 (OpenVibe.Play): how far Games has posted each local stream (rounds, hauls, unlocks, adoptions)++-- to Play. A keyset position: the last row's time and its key; the sync moves it only past what Play accepted.++CREATE TABLE play_sync (++ stream text COLLATE "C" PRIMARY KEY,++ at bigint NOT NULL,++ key text COLLATE "C" NOT NULL++);@@@ -20,6 +20,7 @@ const TABLES = ['casual_saves','casual_ledger','casual_actions',++ 'play_sync','game_instances','character_instance_positions','world_meta',@@@ -1,2 +1,3 @@// file: 'apps/server/src/game/systems/worldEvents.test.ts'// covers: 'apps/server/src/game/systems'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/platform/playSync.test.ts'++// covers: 'apps/server/src/platform'++// Generated by node test/run.js --index; do not edit.
@@ -238,6 +238,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)
@@ -238,6 +238,7 @@ Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`)
| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |
| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |
| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |
| `GAMES_INVENTORY_URL` | Inventory base (default `https://inventory.openvibe.network`; production can use loopback) |
| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |
| `GAMES_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |
+| `GAMES_PLAY_URL` | OpenVibe.Play base (e.g. `http://127.0.0.1:5040`); unset = scores and unlocks stay local |
| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
@@ -98,6 +98,11 @@ export interface PlatformConfig {
@@ -98,6 +98,11 @@ export interface PlatformConfig {
/** OpenVibe.Inventory base and item reward switch. */
/** OpenVibe.Inventory base and item reward switch. */
inventoryUrl: string
inventoryUrl: string
items: boolean
items: boolean
+ /**
+ * OpenVibe.Play base (GAMES_PLAY_URL, host-internal in production); null = titles, scores and unlocks stay local.
+ * Needs the client secret too (ADR-0007 decision 10).
+ */
+ playUrl: string | null
/** Media namespace (tenant app id) Games writes objects into. */
/** Media namespace (tenant app id) Games writes objects into. */
mediaNamespace: string
mediaNamespace: string
/** At most one `games.world.saved` checkpoint event per this many minutes. */
/** At most one `games.world.saved` checkpoint event per this many minutes. */
@@ -136,6 +141,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
@@ -136,6 +141,7 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
mediaUrl: env.MEDIA_URL && env.MEDIA_MIRROR !== 'off' ? trimSlash(env.MEDIA_URL) : null,
mediaUrl: env.MEDIA_URL && env.MEDIA_MIRROR !== 'off' ? trimSlash(env.MEDIA_URL) : null,
inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),
inventoryUrl: trimSlash(env.GAMES_INVENTORY_URL ?? 'https://inventory.openvibe.network'),
items: env.GAMES_ITEMS !== 'off' && !!secret,
items: env.GAMES_ITEMS !== 'off' && !!secret,
+ playUrl: env.GAMES_PLAY_URL ? trimSlash(env.GAMES_PLAY_URL) : null,
mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')
eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')
@@ -60,6 +60,7 @@ import { createAccountData, networkSender, type AccountEvent } from './platform/
@@ -60,6 +60,7 @@ import { createAccountData, networkSender, type AccountEvent } from './platform/
import { MediaMirror } from './platform/mediaMirror.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { createPlatformClient } from './platform/serviceClient.js'
import { createPlatformClient } from './platform/serviceClient.js'
import { InventoryRewards } from './platform/inventoryRewards.js'
import { InventoryRewards } from './platform/inventoryRewards.js'
+import { PlaySync } from './platform/playSync.js'
import {
import {
createRevocationEvents,
createRevocationEvents,
ensureRevocationSubscription,
ensureRevocationSubscription,
@@ -181,6 +182,14 @@ async function main(): Promise<void> {
@@ -181,6 +182,14 @@ async function main(): Promise<void> {
warn: (message) => log.warn(message),
warn: (message) => log.warn(message),
})
})
inventoryRewards.start()
inventoryRewards.start()
+ // OpenVibe.Play (ADR-0007 decision 10): both titles' scores, unlocks and guest adoptions, posted from the local tables.
+ const playSync = new PlaySync({
+ db,
+ url: platform ? config.platform.playUrl : null,
+ tokens: platform?.tokens ?? null,
+ warn: (message) => log.warn(message),
+ })
+ playSync.start()
const platformLog = log.child({ system: 'platform' })
const platformLog = log.child({ system: 'platform' })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
let outbox: PgOutbox | null = null
let outbox: PgOutbox | null = null
@@ -1021,6 +1030,7 @@ async function main(): Promise<void> {
@@ -1021,6 +1030,7 @@ async function main(): Promise<void> {
await mods.flushWrites()
await mods.flushWrites()
await progressWriter.stop()
await progressWriter.stop()
inventoryRewards?.stop()
inventoryRewards?.stop()
+ playSync.stop()
await store.close()
await store.close()
await valkey?.close()
await valkey?.close()
log.info('stopped', {
log.info('stopped', {
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+import { describe, expect, it } from 'vitest'
+import { sql } from 'openvibe-sdk/db'
+import { openTestStore } from '@openvibe/persistence/testing'
+import { loadPlatformConfig } from '../config.js'
+import { recordHaul, recordJoined, recordRound } from '../game/progression.js'
+import { createPlatformClient } from './serviceClient.js'
+import { PLAY_TITLES, PlaySync, toPlayer } from './playSync.js'
+
+const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
+const guest = `guest:${'a'.repeat(64)}`
+
+function fakePlay() {
+ const posts: { method: string; path: string; body: Record<string, unknown> }[] = []
+ let failures = 0
+ const fetchImpl = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = new URL(
+ typeof input === 'string' ? input : input instanceof URL ? input : input.url,
+ )
+ const reply = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+ if (url.pathname === '/oauth/token') {
+ const form = new URLSearchParams(String(init?.body))
+ expect(form.get('audience')).toBe('openvibe.play')
+ expect(form.get('scope')).toBe('play.title.manage play.result.write')
+ return reply(200, { access_token: 'fake-token', token_type: 'Bearer', expires_in: 3600 })
+ }
+ expect(url.origin).toBe('http://play.test')
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer fake-token')
+ if (failures > 0 && url.pathname.endsWith('/scores')) {
+ failures--
+ return reply(503, { code: 'play.unavailable' })
+ }
+ posts.push({
+ method: init?.method ?? 'GET',
+ path: url.pathname.replace(/^\/api\/v1/, ''),
+ body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>,
+ })
+ return reply(200, {})
+ }
+ return {
+ fetchImpl,
+ posts,
+ failNextScores: () => {
+ failures = 1
+ },
+ }
+}
+
+async function setup(env: Record<string, string> = { GAMES_PLAY_URL: 'http://play.test/' }) {
+ const store = await openTestStore()
+ const fake = fakePlay()
+ const config = loadPlatformConfig({
+ OV_OAUTH_CLIENT_SECRET: 'secret',
+ OV_NETWORK_INTERNAL_URL: 'http://network.test',
+ ...env,
+ })
+ const platform = createPlatformClient(config, fake.fetchImpl as never)!
+ const warnings: string[] = []
+ const sync = new PlaySync({
+ db: store.db,
+ url: config.playUrl,
+ tokens: platform.tokens,
+ fetchImpl: fake.fetchImpl,
+ warn: (m) => warnings.push(m),
+ })
+ return { store, fake, sync, warnings }
+}
+
+describe('PlaySync (OpenVibe.Play, ADR-0007 decision 10)', () => {
+ it('is off without GAMES_PLAY_URL', async () => {
+ const { sync } = await setup({})
+ expect(sync.enabled).toBe(false)
+ })
+
+ it('maps account keys to Play players and skips what Play would refuse', () => {
+ expect(toPlayer(subject)).toBe(subject)
+ expect(toPlayer(guest)).toBe(guest)
+ expect(toPlayer('gst_01JABCDEFGHJKMNPQRSTVWXYZ0')).toBe('guest:gst_01JABCDEFGHJKMNPQRSTVWXYZ0')
+ expect(toPlayer('usr_lowercase')).toBeNull()
+ expect(toPlayer('guest:short')).toBeNull()
+ })
+
+ it('registers both titles, then posts rounds, hauls and unlocks once each', async () => {
+ const { store, fake, sync } = await setup()
+ await recordJoined(store.db, subject, 500)
+ await recordHaul(store.db, subject, 'haul-1', 'Scrapper', 100, 1000)
+ await recordRound(
+ store.db,
+ 'salvage-rush',
+ 'round-1',
+ [
+ { key: subject, name: 'Scrapper', score: 12, rank: 1 },
+ { key: guest, name: 'Guest', score: 4, rank: 2 },
+ ],
+ 2000,
+ )
+ const first = await sync.syncOnce()
+ expect(first.rounds).toBe(2)
+ expect(first.hauls).toBe(1)
+ expect(first.unlocks).toBeGreaterThan(0)
+
+ const defined = fake.posts.filter((p) => p.method === 'PUT').map((p) => p.path)
+ expect(defined).toEqual(Object.keys(PLAY_TITLES).map((id) => `/titles/${id}`))
+ const sr = fake.posts.find((p) => p.method === 'PUT' && p.path === '/titles/salvage-rush')!.body
+ expect(sr.boards).toEqual([{ id: 'best', name: 'Best round', kind: 'best' }])
+ expect((sr.achievements as unknown[]).length).toBeGreaterThan(0)
+
+ const rounds = fake.posts.filter((p) => p.path === '/titles/salvage-rush/scores')
+ expect(rounds).toHaveLength(1)
+ expect(rounds[0]!.body).toMatchObject({ board: 'best', ref: 'round-1', at: 2000 })
+ expect(rounds[0]!.body.entries).toEqual([
+ { player: guest, name: 'Guest', score: 4 },
+ { player: subject, name: 'Scrapper', score: 12 },
+ ])
+ const hauls = fake.posts.filter((p) => p.path === '/titles/scraplandia/scores')
+ expect(hauls.map((p) => p.body)).toEqual([
+ {
+ board: 'haul',
+ ref: 'haul-1',
+ at: 1000,
+ entries: [{ player: subject, name: 'Scrapper', score: 100 }],
+ },
+ ])
+ const unlocks = fake.posts.filter((p) => p.path.endsWith('/unlocks'))
+ expect(unlocks.length).toBe(first.unlocks)
+ for (const u of unlocks) {
+ const title = u.path.split('/')[2]! as keyof typeof PLAY_TITLES
+ const known = PLAY_TITLES[title].achievements.map((a) => a.id)
+ expect(known).toContain((u.body.achievements as string[])[0])
+ }
+
+ // Nothing new: the second pass posts nothing and does not register the titles again.
+ const before = fake.posts.length
+ expect(await sync.syncOnce()).toEqual({ rounds: 0, hauls: 0, unlocks: 0, adoptions: 0 })
+ expect(fake.posts.length).toBe(before)
+ })
+
+ it('keeps its place when Play fails and resumes there', async () => {
+ const { store, fake, sync } = await setup()
+ await recordHaul(store.db, subject, 'haul-1', 'Scrapper', 100, 1000)
+ await recordHaul(store.db, subject, 'haul-2', 'Scrapper', 50, 1100)
+ await sync.defineTitles()
+ fake.failNextScores()
+ await expect(sync.syncOnce()).rejects.toThrow(/answered 503/)
+ expect(fake.posts.filter((p) => p.path === '/titles/scraplandia/scores')).toHaveLength(0)
+ const again = await sync.syncOnce()
+ expect(again.hauls).toBe(2)
+ expect(
+ fake.posts.filter((p) => p.path === '/titles/scraplandia/scores').map((p) => p.body.ref),
+ ).toEqual(['haul-1', 'haul-2'])
+ })
+
+ it('adopts a converted guest in both titles, once; merges are left to Play', async () => {
+ const { store, fake, sync } = await setup()
+ await store.db.exec(sql`
+ INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)
+ VALUES (${guest}, ${subject}, 'guest', 1, 0, 3000), ('merge:m1', ${subject}, 'merge', 1, 0, 3100)
+ `)
+ expect((await sync.syncOnce()).adoptions).toBe(1)
+ const adopts = fake.posts.filter((p) => p.path.endsWith('/adopt'))
+ expect(adopts.map((p) => p.path)).toEqual(
+ Object.keys(PLAY_TITLES).map(
+ (id) => `/titles/${id}/players/${encodeURIComponent(guest)}/adopt`,
+ ),
+ )
+ expect(adopts.every((p) => p.body.person === subject)).toBe(true)
+ expect((await sync.syncOnce()).adoptions).toBe(0)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,303 @@
@@ -0,0 +1,303 @@
+/**
+ * OpenVibe.Play (ADR-0007 decision 10): Games' titles, scores and unlocks on the platform's game services.
+ *
+ * At start Games registers its two titles (Scraplandia and Salvage Rush: their achievements from the packs, and one
+ * board each). Every SYNC_MS it posts what the local tables recorded after its last position, stream by stream:
+ *
+ * rounds game_round_results → salvage-rush / best (a round's id is the ref)
+ * hauls game_hauls → scraplandia / haul (an extraction's id is the ref; the board sums them)
+ * unlocks game_achievements → the game's unlocks (only achievements the title defines)
+ * adoptions identity_adoptions (guest) → the guest's rows in Play become the person's, in both titles
+ *
+ * Every post is idempotent in Play, so a restart or a retry never counts twice, and the first run imports the history.
+ * The position (play_sync) moves only past what Play accepted: with Play down nothing is lost. Adoptions come last in
+ * a pass: a local adoption re-keys the guest's rows in the same transaction it is recorded in, so whatever Play got
+ * as the guest's arrived before the adoption is read, and what is read after it already carries the person.
+ *
+ * Play names a player usr_… or guest:<opaque>; a Network guest (gst_…) is sent as guest:gst_…, and a key Play would
+ * refuse is skipped rather than stopping the stream.
+ *
+ * Off unless GAMES_PLAY_URL and the client secret are set (config.platform.playUrl).
+ */
+import { sql, type Db } from 'openvibe-sdk/db'
+import { SCRAPLANDIA_PACK } from '@openvibe/content'
+import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
+
+const AUDIENCE = 'openvibe.play'
+const SCOPE = 'play.title.manage play.result.write'
+const SYNC_MS = 30_000
+const BATCH = 500
+const TIMEOUT_MS = 10_000
+
+/** What Games registers in Play. Rounds rank each player's best; hauls sum through the week. */
+export const PLAY_TITLES = {
+ scraplandia: {
+ name: 'Scraplandia',
+ url: 'https://openvibe.games/play',
+ summary:
+ "Scrappy multiplayer survival-building in and around Scrap City: haul your salvage home and climb the week's haul board.",
+ achievements: (SCRAPLANDIA_PACK.achievements ?? []).map((a) => ({
+ id: a.id,
+ name: a.name,
+ description: a.description,
+ })),
+ boards: [{ id: 'haul', name: 'Haul points', kind: 'sum' as const }],
+ },
+ 'salvage-rush': {
+ name: 'Salvage Rush',
+ url: 'https://openvibe.games/play?game=salvage-rush',
+ summary: 'Collect scrap around a compact arena in 90-second rounds, scored by the server.',
+ achievements: (SALVAGE_RUSH_PACK.game?.achievements ?? []).map((a) => ({
+ id: a.id,
+ name: a.name,
+ description: a.description,
+ })),
+ boards: [{ id: 'best', name: 'Best round', kind: 'best' as const }],
+ },
+}
+
+type Tokens = { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
+type Cursor = { at: number; key: string }
+
+const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/
+/** Games' account key as Play's player, or null when Play would refuse it. */
+export function toPlayer(accountKey: string): string | null {
+ const key = accountKey.startsWith('gst_') ? `guest:${accountKey}` : accountKey
+ return PLAYER_RE.test(key) ? key : null
+}
+
+const KNOWN: Record<string, Set<string>> = Object.fromEntries(
+ Object.entries(PLAY_TITLES).map(([id, t]) => [id, new Set(t.achievements.map((a) => a.id))]),
+)
+
+export class PlaySync {
+ private timer: ReturnType<typeof setInterval> | null = null
+ private running = false
+ private defined = false
+
+ constructor(
+ private readonly opts: {
+ db: Db
+ url: string | null
+ tokens: Tokens | null
+ fetchImpl?: typeof fetch
+ warn?: (message: string) => void
+ },
+ ) {}
+
+ get enabled(): boolean {
+ return !!this.opts.url && !!this.opts.tokens
+ }
+
+ private async call(method: string, path: string, body: unknown): Promise<unknown> {
+ const token = await this.opts.tokens!.getToken({ audience: AUDIENCE, scope: SCOPE })
+ const res = await (this.opts.fetchImpl ?? fetch)(
+ `${this.opts.url!.replace(/\/+$/, '')}/api/v1${path}`,
+ {
+ method,
+ headers: {
+ authorization: `Bearer ${token}`,
+ 'content-type': 'application/json',
+ accept: 'application/json',
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ },
+ )
+ const text = await res.text()
+ if (!res.ok)
+ throw new Error(`Play ${method} ${path} answered ${res.status}: ${text.slice(0, 200)}`)
+ return text ? JSON.parse(text) : null
+ }
+
+ /** Register both titles (an unchanged document changes nothing in Play). */
+ async defineTitles(): Promise<void> {
+ for (const [id, doc] of Object.entries(PLAY_TITLES))
+ await this.call('PUT', `/titles/${id}`, doc)
+ this.defined = true
+ }
+
+ private async cursor(stream: string): Promise<Cursor> {
+ const row = await this.opts.db.maybe<{ at: number | string; key: string }>(
+ sql`SELECT at, key FROM play_sync WHERE stream = ${stream}`,
+ )
+ return row ? { at: Number(row.at), key: row.key } : { at: -1, key: '' }
+ }
+
+ private async advance(stream: string, to: Cursor): Promise<void> {
+ await this.opts.db.exec(sql`
+ INSERT INTO play_sync (stream, at, key) VALUES (${stream}, ${to.at}, ${to.key})
+ ON CONFLICT (stream) DO UPDATE SET at = excluded.at, key = excluded.key
+ `)
+ }
+
+ /** Salvage Rush rounds → salvage-rush/best, one post per round (more of a round may follow in the next batch). */
+ private async syncRounds(): Promise<number> {
+ const c = await this.cursor('rounds')
+ const rows = await this.opts.db.many<{
+ game_id: string
+ round_id: string
+ account_key: string
+ player_name: string
+ score: number
+ finished_at: number | string
+ k: string
+ }>(sql`
+ SELECT game_id, round_id, account_key, player_name, score, finished_at, game_id || '|' || round_id || '|' || account_key AS k
+ FROM game_round_results
+ WHERE finished_at > ${c.at} OR (finished_at = ${c.at} AND game_id || '|' || round_id || '|' || account_key > ${c.key})
+ ORDER BY finished_at, k LIMIT ${BATCH}
+ `)
+ let posted = 0
+ for (let i = 0; i < rows.length;) {
+ const first = rows[i]!
+ let j = i
+ while (
+ j < rows.length &&
+ rows[j]!.round_id === first.round_id &&
+ rows[j]!.game_id === first.game_id
+ )
+ j++
+ const group = rows.slice(i, j)
+ const entries = group.flatMap((r) => {
+ const player = toPlayer(r.account_key)
+ return player
+ ? [{ player, name: r.player_name.slice(0, 40) || 'Player', score: Number(r.score) }]
+ : []
+ })
+ if (first.game_id === 'salvage-rush' && entries.length) {
+ await this.call('POST', '/titles/salvage-rush/scores', {
+ board: 'best',
+ ref: first.round_id,
+ at: Number(first.finished_at),
+ entries,
+ })
+ }
+ const last = group[group.length - 1]!
+ await this.advance('rounds', { at: Number(last.finished_at), key: last.k })
+ posted += group.length
+ i = j
+ }
+ return posted
+ }
+
+ /** Scraplandia extractions → scraplandia/haul, one post per extraction. */
+ private async syncHauls(): Promise<number> {
+ const c = await this.cursor('hauls')
+ const rows = await this.opts.db.many<{
+ account_key: string
+ extraction_id: string
+ player_name: string
+ score: number
+ finished_at: number | string
+ k: string
+ }>(sql`
+ SELECT account_key, extraction_id, player_name, score, finished_at, extraction_id || '|' || account_key AS k
+ FROM game_hauls
+ WHERE finished_at > ${c.at} OR (finished_at = ${c.at} AND extraction_id || '|' || account_key > ${c.key})
+ ORDER BY finished_at, k LIMIT ${BATCH}
+ `)
+ for (const r of rows) {
+ const player = toPlayer(r.account_key)
+ if (player) {
+ await this.call('POST', '/titles/scraplandia/scores', {
+ board: 'haul',
+ ref: r.extraction_id,
+ at: Number(r.finished_at),
+ entries: [
+ { player, name: r.player_name.slice(0, 40) || 'Player', score: Number(r.score) },
+ ],
+ })
+ }
+ await this.advance('hauls', { at: Number(r.finished_at), key: r.k })
+ }
+ return rows.length
+ }
+
+ /** Unlocked achievements of both games. */
+ private async syncUnlocks(): Promise<number> {
+ const c = await this.cursor('unlocks')
+ const rows = await this.opts.db.many<{
+ game_id: string
+ achievement_id: string
+ account_key: string
+ unlocked_at: number | string
+ k: string
+ }>(sql`
+ SELECT game_id, achievement_id, account_key, unlocked_at, game_id || '|' || achievement_id || '|' || account_key AS k
+ FROM game_achievements
+ WHERE unlocked_at > ${c.at} OR (unlocked_at = ${c.at} AND game_id || '|' || achievement_id || '|' || account_key > ${c.key})
+ ORDER BY unlocked_at, k LIMIT ${BATCH}
+ `)
+ for (const r of rows) {
+ const player = toPlayer(r.account_key)
+ if (player && KNOWN[r.game_id]?.has(r.achievement_id)) {
+ await this.call('POST', `/titles/${r.game_id}/unlocks`, {
+ player,
+ achievements: [r.achievement_id],
+ at: Number(r.unlocked_at),
+ })
+ }
+ await this.advance('unlocks', { at: Number(r.unlocked_at), key: r.k })
+ }
+ return rows.length
+ }
+
+ /** Guests adopted by a person (local guest conversion; merges reach Play from Network itself). */
+ private async syncAdoptions(): Promise<number> {
+ const c = await this.cursor('adoptions')
+ const rows = await this.opts.db.many<{
+ adoption_key: string
+ subject_id: string
+ adopted_at: number | string
+ }>(sql`
+ SELECT adoption_key, subject_id, adopted_at FROM identity_adoptions
+ WHERE source = 'guest' AND (adopted_at > ${c.at} OR (adopted_at = ${c.at} AND adoption_key > ${c.key}))
+ ORDER BY adopted_at, adoption_key LIMIT ${BATCH}
+ `)
+ for (const r of rows) {
+ const guest = toPlayer(r.adoption_key)
+ if (guest?.startsWith('guest:') && toPlayer(r.subject_id)?.startsWith('usr_')) {
+ for (const id of Object.keys(PLAY_TITLES)) {
+ await this.call('POST', `/titles/${id}/players/${encodeURIComponent(guest)}/adopt`, {
+ person: r.subject_id,
+ })
+ }
+ }
+ await this.advance('adoptions', { at: Number(r.adopted_at), key: r.adoption_key })
+ }
+ return rows.length
+ }
+
+ /** One pass over the four streams; a failure stops the pass where it was (the next tick resumes there). */
+ async syncOnce(): Promise<{ rounds: number; hauls: number; unlocks: number; adoptions: number }> {
+ if (!this.defined) await this.defineTitles()
+ const rounds = await this.syncRounds()
+ const hauls = await this.syncHauls()
+ const unlocks = await this.syncUnlocks()
+ return { rounds, hauls, unlocks, adoptions: await this.syncAdoptions() }
+ }
+
+ start(): void {
+ if (!this.enabled || this.timer) return
+ const tick = async (): Promise<void> => {
+ if (this.running) return
+ this.running = true
+ try {
+ await this.syncOnce()
+ } catch (err) {
+ this.opts.warn?.(`[play] sync paused: ${String((err as Error).message ?? err)}`)
+ } finally {
+ this.running = false
+ }
+ }
+ this.timer = setInterval(() => void tick(), SYNC_MS)
+ this.timer.unref?.()
+ void tick()
+ }
+
+ stop(): void {
+ if (this.timer) clearInterval(this.timer)
+ this.timer = null
+ }
+}
@@ -136,9 +136,11 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -136,9 +136,11 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
instance registries, fenced leases and game systems. `hello.place` selects Salvage Rush on the same
instance registries, fenced leases and game systems. `hello.place` selects Salvage Rush on the same
WebSocket endpoint; missing or unknown places use `GAMES_PLACE_ID` as the default. Room transfer
WebSocket endpoint; missing or unknown places use `GAMES_PLACE_ID` as the default. Room transfer
stays within each place. Separate processes remain possible for scale.
stays within each place. Separate processes remain possible for scale.
- **Still to build:** OpenVibe.Play as a separate service, Games as its first consumer, score/achievement migration
- to Play. This local schema is the seed for Play's per-title results and unlocks;
- the title-scoped principal/grant design in decision 10 remains the target.
+ **Built (2026-10-14):** OpenVibe.Play as a separate service (contracts 0.172.0, play.openvibe.network) with the
+ title-scoped design of decision 10 (a title is owned by the principal that registered it; `play.title.manage`,
+ `play.result.write`, public `play.player.read`), and Games as its first consumer: both titles registered, rounds,
+ hauls, unlocks and guest adoptions posted from the local tables (`GAMES_PLAY_URL`). **Still to build:** the in-game
+ boards read from Play, then the local result tables go (a contract migration).
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
*2026-10-14:* quality tiers and hardware scaling are in (`render/quality.ts`, the HUD's Graphics quality); initial
*2026-10-14:* quality tiers and hardware scaling are in (`render/quality.ts`, the HUD's Graphics quality); initial
JS is 2.35 MB of 2.5 (579 KB gzip). The browser build now loads Rapier from the deterministic non-compat package
JS is 2.35 MB of 2.5 (579 KB gzip). The browser build now loads Rapier from the deterministic non-compat package
@@ -49,5 +49,9 @@ Guest character adoption and Network subject merges move progression (including
@@ -49,5 +49,9 @@ Guest character adoption and Network subject merges move progression (including
4. Add lifecycle, score integrity, criteria, persistence, join routing and API tests.
4. Add lifecycle, score integrity, criteria, persistence, join routing and API tests.
More Games processes may later be used for scale.
More Games processes may later be used for scale.
-OpenVibe.Play is the later home of these per-title results, achievements and leaderboards.
-The local tables and APIs are the seed for that extraction; Play is not yet running.
+OpenVibe.Play (play.openvibe.network, contracts 0.172.0) is the network's home for these per-title results,
+achievements and leaderboards. With `GAMES_PLAY_URL` set, Games registers both titles there (Scraplandia's weekly
+haul board sums extractions; Salvage Rush's board ranks each player's best round) and posts every round, haul, unlock
+and guest adoption from the local tables (`apps/server/src/platform/playSync.ts`, position in `play_sync`). Every post
+is idempotent, so the first run imports the history. The local tables still serve the in-game boards; reading them
+from Play and dropping the local copies is the next step.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- ADR-0007 decision 10 (OpenVibe.Play): how far Games has posted each local stream (rounds, hauls, unlocks, adoptions)
+-- to Play. A keyset position: the last row's time and its key; the sync moves it only past what Play accepted.
+CREATE TABLE play_sync (
+ stream text COLLATE "C" PRIMARY KEY,
+ at bigint NOT NULL,
+ key text COLLATE "C" NOT NULL
+);
@@ -20,6 +20,7 @@ const TABLES = [
@@ -20,6 +20,7 @@ const TABLES = [
'casual_saves',
'casual_saves',
'casual_ledger',
'casual_ledger',
'casual_actions',
'casual_actions',
+ 'play_sync',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
'world_meta',
'world_meta',
@@ -1,2 +1,3 @@
@@ -1,2 +1,3 @@
// file: 'apps/server/src/game/systems/worldEvents.test.ts'
// file: 'apps/server/src/game/systems/worldEvents.test.ts'
// covers: 'apps/server/src/game/systems'
// covers: 'apps/server/src/game/systems'
+// 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/platform/playSync.test.ts'
+// covers: 'apps/server/src/platform'
+// Generated by node test/run.js --index; do not edit.