openvibers/openvibe.games Commits
Games: six achievements give OpenVibe.Inventory items (granted to signed-in players once, retried with backoff) (#53)
Changes
@@@ -182,6 +182,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsfirst-visit achievement is stored locally in Games; achievement events are not yet published.Progression events come from what a save actually wrote, in the sametransaction.++- **Inventory.** Six built-in achievements give Live items: Scraplandia First Haul (Toxic Name), Big Haul (Golden Name), Haul Week (Cowboy Hat); Salvage Rush First Haul (Neon Name), Big Haul (Flames), Arena Winner (Stars). Games grants them after unlock or guest adoption using its own OAuth client; failed grants retry. Inventory items are free, never sold or traded.- **Media.** Map-editor assets (textures, paint masks, glb models; the onlyuploaded files Games has) are still served from local disk and are alsocopied into Media objects (`kind: asset`, namespace `MEDIA_NAMESPACE`), with@@@ -221,17 +222,19 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsprops, the world clock and the mod registry survive the restart;`sliceTest.ts` covers the gameplay state end to end.--Platform environment (all optional; unset = off):++Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`):--| Variable | Purpose |--| --------------------------- | ------------------------------------------------------------------------------------- |--| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |--| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |--| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |--| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |--| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |--| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |--| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |++| Variable | Purpose |++| --------------------------- | ------------------------------------------------------------------------------------------ |++| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |++| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |++| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |++| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=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_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |++| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |++| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |## Capabilities@@@ -242,8 +245,8 @@ mods API, for a principal token or an owner/admin session); `games.world.announc`games.world.emit`, and `games.storage.kv` for script packs. Script bindingsare absent unless approved for that install.--Grants the `games` principal needs in OpenVibe.Network:--`events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account,++Service grants the `games` principal needs:++`inventory.item.grant` (openvibe.inventory), `events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account,revocation and mod-grant subscriptions), `media.object.upload`(openvibe.media, namespace `games`), `identity.subject.resolve`(openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage`@@@ -27,6 +27,7 @@"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)","M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe","M5 first part in working tree (not deployed): one Games process hosts Scraplandia and Salvage Rush in separate place and instance registries; server-scored rounds, per-game achievements and paginated all-time/week leaderboards in Games",++ "six achievement rewards granted as free OpenVibe.Inventory items, never sold or traded; GAMES_ITEMS=on/off and GAMES_INVENTORY_URL configure delivery","map editor with assets mirrored to OpenVibe.Media","declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit","server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",@@@ -543,7 +543,7 @@weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — andhaul your salvage home before another scrapper takes it off your corpse. Hold the circlewhen the recovery bird comes: every valuable you extract scores haul points on this-- week's board.++ week's board. Achievements give items worn across OpenVibe.</p><div class="tags"><span>persistent world</span><span>physics sandbox</span><span>survival</span@@@ -646,7 +646,8 @@<h3>SALVAGE RUSH</h3><p>Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each-- pickup; rounds have a public leaderboard and three achievements.++ pickup; rounds have a public leaderboard and three achievements that give items worn++ across OpenVibe.</p><div class="tags"><span>timed rounds</span><span>on-foot</span><span>leaderboard</span>@@@ -603,7 +603,12 @@ export class Hud {const data = (await response.json()) as {games: {game: string-- achievements: { name: string; description: string; unlockedAt: number | null }[]++ achievements: {++ name: string++ description: string++ reward?: { item: string; name: string }++ unlockedAt: number | null++ }[]}[]}if (this.activeTab !== 'achievements') return@@@ -613,7 +618,7 @@ export class Hud {[]) {const line = document.createElement('p')line.className = 'skill-row'-- line.textContent = `${achievement.unlockedAt ? '✓' : '○'} ${achievement.name} — ${achievement.description}`++ line.textContent = `${achievement.unlockedAt ? '✓' : '○'} ${achievement.name} — ${achievement.description}${achievement.reward ? ` Gives the ${achievement.reward.name} on OpenVibe.Inventory.` : ''}`body.append(line)}if (!body.children.length) body.textContent = 'No achievements for this game yet.'@@@ -95,6 +95,9 @@ export interface PlatformConfig {eventsUrl: string | null/** OpenVibe.Media base; null = map assets stay local only. */mediaUrl: string | null++ /** OpenVibe.Inventory base and item reward switch. */++ inventoryUrl: string++ items: boolean/** Media namespace (tenant app id) Games writes objects into. */mediaNamespace: string/** At most one `games.world.saved` checkpoint event per this many minutes. */@@@ -131,6 +134,8 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {),eventsUrl: env.EVENTS_URL && env.EVENTS_PUBLISH !== 'off' ? trimSlash(env.EVENTS_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'),++ items: env.GAMES_ITEMS !== 'off' && !!secret,mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')@@@ -144,6 +144,7 @@ export class GameProgressWriter {constructor(private readonly db: Db,private readonly onError: (error: unknown) => void,++ private readonly afterCommit?: (subject: string) => void,) {this.timer = setInterval(() => void this.flush().catch(onError), 2000)this.timer.unref()@@@ -178,6 +179,7 @@ export class GameProgressWriter {for (const [roundId, entry] of this.pending) {await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)this.pending.delete(roundId)++ for (const result of entry.results) this.afterCommit?.(result.key)}for (const [id, haul] of this.hauls) {await recordHaul(@@@ -189,10 +191,12 @@ export class GameProgressWriter {haul.finishedAt,)this.hauls.delete(id)++ this.afterCommit?.(haul.key)}for (const [key, at] of this.joined) {await recordJoined(this.db, key, at)this.joined.delete(key)++ this.afterCommit?.(key)}}})()@@@ -63,6 +63,8 @@ export type ResolvedConnectionIdentity = TicketIdentityexport interface GameIntegrations {/** A server-confirmed authenticated join, for place-scoped achievements. */playerJoined?: (key: string) => void++ /** Revisit item rewards after a guest account is adopted. */++ guestAdopted?: (subject: string) => void/** Called only for completed server-simulated rounds. Never accepts a client message. */roundFinished?: (roundId: string,@@@ -157,9 +157,10 @@ export class SessionsSystem implements System {subjectId,Date.now(),)-- if (g.moved > 0)++ if (g.moved > 0) {ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })-- else if (g.full)++ ctx.integrations.guestAdopted?.(subjectId)++ } else if (g.full)ctx.log.info('guest character kept: account slots full', { subject: subjectId })}} else {@@@ -59,6 +59,7 @@ import {import { createAccountData, networkSender, type AccountEvent } from './platform/accountData.js'import { MediaMirror } from './platform/mediaMirror.js'import { createPlatformClient } from './platform/serviceClient.js'++import { InventoryRewards } from './platform/inventoryRewards.js'import {createRevocationEvents,ensureRevocationSubscription,@@@ -110,8 +111,18 @@ async function main(): Promise<void> {)const builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACKconst content = createContent(packs, builtInPack)-- const progressWriter = new GameProgressWriter(db, (error) =>-- log.warn('game progression write failed', { error: String(error) }),++ let inventoryRewards: InventoryRewards | null = null++ const settleRewards = (subject: string): void => {++ void inventoryRewards++ ?.settleSubject(subject)++ .catch((error: unknown) =>++ log.warn('inventory reward lookup failed', { error: String(error) }),++ )++ }++ const progressWriter = new GameProgressWriter(++ db,++ (error) => log.warn('game progression write failed', { error: String(error) }),++ settleRewards,)const builtInModIds = new Set((SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),@@@ -159,6 +170,14 @@ async function main(): Promise<void> {// Every service call carries the `games` principal's client-credentials// token; with no client secret configured none of this talks to anything.const platform = createPlatformClient(config.platform)++ inventoryRewards = new InventoryRewards({++ db,++ url: config.platform.inventoryUrl,++ enabled: config.platform.items && !!platform,++ tokens: platform?.tokens ?? null,++ warn: (message) => log.warn(message),++ })++ inventoryRewards.start()const platformLog = log.child({ system: 'platform' })const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })let outbox: PgOutbox | null = null@@@ -297,6 +316,7 @@ async function main(): Promise<void> {mods: modRuntime,...(scripts ? { scripts } : {}),...(progressSummary ? { progressSummary } : {}),++ guestAdopted: settleRewards,...(config.placeId === 'scraplandia'? {playerJoined: (key: string) => progressWriter.submitJoined(key),@@@ -366,6 +386,7 @@ async function main(): Promise<void> {}),...(roomScripts ? { scripts: roomScripts } : {}),...(progressSummary ? { progressSummary } : {}),++ guestAdopted: settleRewards,...(config.placeId === 'scraplandia'? {playerJoined: (key: string) => progressWriter.submitJoined(key),@@@ -466,6 +487,7 @@ async function main(): Promise<void> {),...(roomScripts ? { scripts: roomScripts } : {}),...(progressSummary ? { progressSummary } : {}),++ guestAdopted: settleRewards,...(secondaryPlace === 'scraplandia'? {playerJoined: (key: string) => progressWriter.submitJoined(key),@@@ -588,7 +610,11 @@ async function main(): Promise<void> {0,),// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.-- onMerged: (from, into, mergeId) => store.identity.mergeSubject(from, into, mergeId, Date.now()),++ onMerged: async (from, into, mergeId) => {++ const result = await store.identity.mergeSubject(from, into, mergeId, Date.now())++ settleRewards(into)++ return result++ },// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each// close saves its character) before its characters are erased....(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),@@@ -968,6 +994,7 @@ async function main(): Promise<void> {await within(2000, outbox?.stop())await mods.flushWrites()await progressWriter.stop()++ inventoryRewards?.stop()await store.close()await valkey?.close()log.info('stopped', {nnew file mode 100644@@@ -0,0 +1,258 @@++import { describe, expect, it } from 'vitest'++import { sql, type Db } 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 { InventoryRewards } from './inventoryRewards.js'++++const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'++const aliases = ['fx_toxic', 'fx_golden', 'hat_cowboy', 'fx_neon', 'px_flames', 'px_stars']++++function fakeNetwork() {++ const grants: Record<string, unknown>[] = []++ const calls: string[] = []++ 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,++ )++ calls.push(`${init?.method ?? 'GET'} ${url.pathname}`)++ 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('grant_type')).toBe('client_credentials')++ expect(form.get('audience')).toBe('openvibe.inventory')++ expect(form.get('scope')).toBe('inventory.item.grant')++ return reply(200, { access_token: 'fake-token', token_type: 'Bearer', expires_in: 3600 })++ }++ if (url.pathname === '/api/v1/definitions') {++ expect(url.searchParams.get('issuer')).toBe('service:live')++ return reply(200, {++ definitions: aliases.map((alias, i) => ({++ id: `itd_${i}`,++ issuer: 'service:live',++ aliases: [alias],++ })),++ })++ }++ if (url.pathname === '/api/v1/grants') {++ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer fake-token')++ if (failures-- > 0) return reply(503, { code: 'inventory.unavailable' })++ grants.push(JSON.parse(String(init?.body)) as Record<string, unknown>)++ return reply(201, { instance: { id: 'iti_1' } })++ }++ return reply(404, {})++ }++ return {++ fetchImpl,++ grants,++ calls,++ fail: () => {++ failures = 1++ },++ }++}++++async function setup(enabled = true) {++ const store = await openTestStore()++ const fake = fakeNetwork()++ const config = loadPlatformConfig({++ OV_OAUTH_CLIENT_SECRET: 'secret',++ OV_NETWORK_INTERNAL_URL: 'http://network.test',++ GAMES_INVENTORY_URL: 'http://inventory.test',++ GAMES_ITEMS: enabled ? 'on' : 'off',++ })++ const platform = createPlatformClient(config, fake.fetchImpl as never)!++ // A clock the tests move: a failed grant waits out its backoff before the timer asks again.++ const clock = { at: Date.now() }++ const items = new InventoryRewards({++ db: store.db,++ url: config.inventoryUrl,++ enabled: config.items,++ tokens: platform.tokens,++ fetchImpl: fake.fetchImpl,++ now: () => clock.at,++ })++ const advance = (ms: number) => {++ clock.at += ms++ }++ return { store, fake, items, advance }++}++++async function state(db: Db, key: string, game: string, achievement: string) {++ return db.maybe<{++ item_state: string | null++ item_error: string | null++ item_attempts: number++ }>(sql`++ SELECT item_state, item_error, item_attempts FROM game_achievements++ WHERE account_key = ${key} AND game_id = ${game} AND achievement_id = ${achievement}++ `)++}++++describe('Inventory achievement rewards', () => {++ it('grants an account unlock once with earned origin and stable idempotency key', async () => {++ const { store, fake, items } = await setup()++ await recordHaul(store.db, subject, 'haul-1', 'Player', 100, 1000)++ await items.settleSubject(subject)++ await items.retryPending()++ expect(fake.grants).toEqual([++ expect.objectContaining({++ definition_id: 'itd_0',++ subject,++ origin: 'earned',++ idempotency_key: `games:scraplandia:first-haul:${subject}`,++ }),++ ])++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'granted',++ item_attempts: 1,++ })++ })++++ it('leaves guest unlocks alone until adoption moves them to the subject', async () => {++ const { store, fake, items } = await setup()++ const guest = 'guest-test-key'++ await store.players.upsert({++ id: 'guest-character',++ token: guest,++ name: 'Guest',++ pos: [0, 1, 0],++ yaw: 0,++ inventory: { size: 24, hotbar: 6, slots: [] },++ skills: {},++ friends: [],++ appearance: null,++ charSlot: 0,++ armor: null,++ reputation: {},++ unlocks: [],++ activeJob: null,++ stats: null,++ updatedAt: 1,++ })++ await recordRound(++ store.db,++ 'salvage-rush',++ 'round-1',++ [{ key: guest, name: 'Guest', score: 1, rank: 2 }],++ 1000,++ )++ await items.retryPending()++ expect(fake.grants).toHaveLength(0)++ await store.identity.adoptGuestCharacter(guest, subject, 2000)++ await items.settleSubject(subject)++ expect(fake.grants).toEqual([expect.objectContaining({ subject, definition_id: 'itd_3' })])++ })++++ it('records a failure and retries it after its backoff', async () => {++ const { store, fake, items, advance } = await setup()++ fake.fail()++ await recordHaul(store.db, subject, 'haul-1', 'Player', 100, 1000)++ await items.settleSubject(subject)++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'failed',++ item_attempts: 1,++ })++ await items.retryPending()++ expect(fake.grants).toHaveLength(0)++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'failed',++ item_attempts: 1,++ })++ advance(3 * 60_000)++ await items.retryPending()++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'granted',++ item_attempts: 2,++ })++ expect(fake.grants).toHaveLength(1)++ })++++ it('backfills an older unlocked achievement with no item state', async () => {++ const { store, fake, items } = await setup()++ await recordHaul(store.db, subject, 'old-haul', 'Player', 100, 1000)++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: null,++ })++ await items.retryPending()++ expect(fake.grants).toHaveLength(1)++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'granted',++ })++ })++++ it('retries a failed grant on the timer', async () => {++ const { store, fake, items, advance } = await setup()++ fake.fail()++ await recordHaul(store.db, subject, 'haul-timer', 'Player', 100, 1000)++ await items.settleSubject(subject)++ advance(3 * 60_000)++ items.start(20)++ try {++ const deadline = Date.now() + 2000++ while (++ (await state(store.db, subject, 'scraplandia', 'first-haul'))?.item_state !== 'granted' &&++ Date.now() < deadline++ ) {++ await new Promise((resolve) => setTimeout(resolve, 20))++ }++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'granted',++ item_attempts: 2,++ })++ } finally {++ items.stop()++ }++ })++++ it('exports grant state and moves it with account achievements', async () => {++ const { store, items } = await setup()++ const from = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1'++ await recordHaul(store.db, from, 'haul-merge', 'Player', 100, 1000)++ await items.settleSubject(from)++ expect((await store.identity.exportSubject(from)).gameAchievements).toEqual([++ expect.objectContaining({++ achievement_id: 'first-haul',++ item_state: 'granted',++ item_instance: 'iti_1',++ }),++ ])++ await store.identity.mergeSubject(from, subject, 'merge-1', 2000)++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: 'granted',++ })++ expect(await state(store.db, from, 'scraplandia', 'first-haul')).toBeNull()++ await store.identity.eraseSubjects([subject])++ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toBeNull()++ })++++ it('does nothing when off or the achievement has no reward', async () => {++ const off = await setup(false)++ await recordHaul(off.store.db, subject, 'haul-1', 'Player', 100, 1000)++ await off.items.settleSubject(subject)++ await off.items.retryPending()++ expect(off.fake.calls).toEqual([])++ expect(await state(off.store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({++ item_state: null,++ item_attempts: 0,++ })++ const on = await setup()++ await recordJoined(on.store.db, subject, 1000)++ await on.items.settleSubject(subject)++ await on.items.retryPending()++ expect(on.fake.calls).toEqual([])++ expect(await state(on.store.db, subject, 'scraplandia', 'first-visit')).toMatchObject({++ item_state: null,++ item_attempts: 0,++ })++ })++})nnew file mode 100644@@@ -0,0 +1,200 @@++/** Inventory items earned by Games achievements. All network work happens after unlock commits. */++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.inventory'++const SCOPE = 'inventory.item.grant'++const RETRY_MS = 3 * 60_000++const CACHE_MS = 60 * 60_000++const TIMEOUT_MS = 5000++const rewards = {++ scraplandia: SCRAPLANDIA_PACK.achievements ?? [],++ 'salvage-rush': SALVAGE_RUSH_PACK.game?.achievements ?? [],++}++++type Row = {++ game_id: string++ achievement_id: string++ account_key: string++ item_state: string | null++ item_attempts?: number | null++ item_updated_at?: number | string | null++}++++/** A failed grant waits 2^attempts minutes (at most 6 h) before the timer tries it again. */++function backoffMs(attempts: number): number {++ return Math.min(6 * 3600_000, 2 ** Math.min(9, Math.max(1, attempts)) * 60_000)++}++++export class InventoryRewards {++ private timer: ReturnType<typeof setInterval> | null = null++ private cache: { at: number; aliases: Map<string, string> } | null = null++ private readonly active = new Set<string>()++ private scanning = false++++ constructor(++ private readonly opts: {++ db: Db++ url: string++ enabled: boolean++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> } | null++ fetchImpl?: typeof fetch++ now?: () => number++ warn?: (message: string) => void++ },++ ) {}++++ private get now(): number {++ return (this.opts.now ?? Date.now)()++ }++ private get fetchImpl(): typeof fetch {++ return this.opts.fetchImpl ?? fetch++ }++ private get base(): string {++ return this.opts.url.replace(/\/+$/, '')++ }++++ private definition(game: string, achievement: string) {++ if (game !== 'scraplandia' && game !== 'salvage-rush') return null++ return rewards[game].find((def) => def.id === achievement && def.reward) ?? null++ }++++ private async definitionId(alias: string): Promise<string> {++ if (!this.cache || this.now - this.cache.at >= CACHE_MS) {++ const response = await this.fetchImpl(++ `${this.base}/api/v1/definitions?issuer=service%3Alive&limit=500`,++ {++ signal: AbortSignal.timeout(TIMEOUT_MS),++ },++ )++ if (!response.ok) throw new Error(`Inventory definitions ${response.status}`)++ const body = (await response.json()) as {++ definitions?: { id: string; issuer: string; aliases?: string[] }[]++ }++ const aliases = new Map<string, string>()++ for (const def of body.definitions ?? []) {++ if (def.issuer === 'service:live')++ for (const name of def.aliases ?? []) aliases.set(name, def.id)++ }++ this.cache = { at: this.now, aliases }++ }++ const id = this.cache.aliases.get(alias)++ if (!id) throw new Error(`Inventory definition missing: ${alias}`)++ return id++ }++++ /** Settle one committed unlock, or every pending unlock for a newly adopted subject. */++ async settleSubject(subject: string): Promise<void> {++ if (!this.opts.enabled || !this.opts.tokens || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject))++ return++ const rows = await this.opts.db.many<Row>(sql`++ SELECT game_id, achievement_id, account_key, item_state FROM game_achievements++ WHERE account_key = ${subject} AND item_state IS DISTINCT FROM 'granted'++ ORDER BY unlocked_at LIMIT 50++ `)++ for (const row of rows) await this.settle(row)++ }++++ private async settle(row: Row): Promise<void> {++ if (++ !this.opts.enabled ||++ !this.opts.tokens ||++ row.item_state === 'granted' ||++ !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(row.account_key)++ )++ return++ const def = this.definition(row.game_id, row.achievement_id)++ if (!def?.reward) return++ const key = `${row.game_id}:${row.achievement_id}:${row.account_key}`++ if (this.active.has(key)) return++ this.active.add(key)++ try {++ const current = await this.opts.db.maybe<Row>(sql`++ SELECT game_id, achievement_id, account_key, item_state FROM game_achievements++ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}++ AND account_key = ${row.account_key}++ `)++ if (!current || current.item_state === 'granted') return++ const id = await this.definitionId(def.reward.item)++ const token = await this.opts.tokens.getToken({ audience: AUDIENCE, scope: SCOPE })++ const response = await this.fetchImpl(`${this.base}/api/v1/grants`, {++ method: 'POST',++ headers: {++ accept: 'application/json',++ 'content-type': 'application/json',++ authorization: `Bearer ${token}`,++ },++ body: JSON.stringify({++ definition_id: id,++ subject: row.account_key,++ idempotency_key: `games:${key}`,++ origin: 'earned',++ reason: `Games: ${row.game_id} — ${def.name}`.slice(0, 200),++ }),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ })++ const body = (await response.json().catch(() => null)) as {++ instance?: { id?: string }++ code?: string++ } | null++ if (!response.ok)++ throw new Error(`Inventory ${response.status}${body?.code ? `: ${body.code}` : ''}`)++ await this.opts.db.exec(sql`++ UPDATE game_achievements SET item_state = 'granted', item_error = NULL,++ item_instance = ${body?.instance?.id ?? null}, item_attempts = item_attempts + 1,++ item_updated_at = ${this.now}++ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}++ AND account_key = ${row.account_key}++ `)++ } catch (error) {++ const message = String((error as Error)?.message ?? error).slice(0, 200)++ this.opts.warn?.(`Inventory reward ${key}: ${message}`)++ await this.opts.db.exec(sql`++ UPDATE game_achievements SET item_state = 'failed', item_error = ${message},++ item_attempts = item_attempts + 1, item_updated_at = ${this.now}++ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}++ AND account_key = ${row.account_key} AND item_state IS DISTINCT FROM 'granted'++ `)++ } finally {++ this.active.delete(key)++ }++ }++++ /** Bounded scan also catches achievements unlocked before item rewards existed. */++ async retryPending(limit = 50): Promise<void> {++ if (!this.opts.enabled || !this.opts.tokens || this.scanning) return++ this.scanning = true++ try {++ const rows = await this.opts.db.many<Row>(sql`++ SELECT game_id, achievement_id, account_key, item_state, item_attempts, item_updated_at FROM game_achievements++ WHERE account_key LIKE 'usr_%' AND item_state IS DISTINCT FROM 'granted'++ AND ((game_id = 'scraplandia' AND achievement_id IN ('first-haul', 'big-haul', 'haul-week'))++ OR (game_id = 'salvage-rush' AND achievement_id IN ('first-haul', 'big-haul', 'arena-winner')))++ ORDER BY COALESCE(item_updated_at, 0), unlocked_at LIMIT ${Math.max(1, Math.min(limit, 100))}++ `)++ for (const row of rows) {++ // An Inventory that keeps refusing (an outage, or the grant not yet in place) is not asked every 3 minutes.++ if (row.item_state === 'failed' && this.now - Number(row.item_updated_at ?? 0) < backoffMs(Number(row.item_attempts ?? 0)))++ continue++ await this.settle(row)++ }++ } finally {++ this.scanning = false++ }++ }++++ start(intervalMs = RETRY_MS): void {++ if (!this.opts.enabled || !this.opts.tokens || this.timer) return++ const run = () =>++ void this.retryPending().catch((error: unknown) => this.opts.warn?.(String(error)))++ run()++ this.timer = setInterval(run, intervalMs)++ this.timer.unref()++ }++++ stop(): void {++ if (this.timer) clearInterval(this.timer)++ this.timer = null++ }++}@@@ -45,6 +45,7 @@ export interface ContentPackV2 {id: stringname: stringdescription: string++ reward?: { item: string; name: string }criterion:| { event: 'player_joined' }| { event: 'haul_recorded'; minScore?: number }@@@ -60,6 +61,7 @@ export interface ContentPackV2 {id: stringname: stringdescription: string++ reward?: { item: string; name: string }criterion: { event: 'round_finished'; minScore?: number; minRank?: number }}[]}@@@ -44,18 +44,21 @@ export const SALVAGE_RUSH_PACK: ContentPackV2 = {achievements: [{id: 'first-haul',++ reward: { item: 'fx_neon', name: 'Neon Name' },name: 'First Haul',description: 'Score in a Salvage Rush round.',criterion: { event: 'round_finished', minScore: 1 },},{id: 'big-haul',++ reward: { item: 'px_flames', name: 'Flames' },name: 'Big Haul',description: 'Score at least five points in one round.',criterion: { event: 'round_finished', minScore: 5 },},{id: 'arena-winner',++ reward: { item: 'px_stars', name: 'Stars' },name: 'Arena Winner',description: 'Finish a round in first place.',criterion: { event: 'round_finished', minRank: 1 },@@@ -16,18 +16,21 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {},{id: 'first-haul',++ reward: { item: 'fx_toxic', name: 'Toxic Name' },name: 'First Haul',description: 'Extract valuables to Scrap City.',criterion: { event: 'haul_recorded' },},{id: 'big-haul',++ reward: { item: 'fx_golden', name: 'Golden Name' },name: 'Big Haul',description: 'Score at least 500 haul points in one extraction.',criterion: { event: 'haul_recorded', minScore: 500 },},{id: 'haul-week',++ reward: { item: 'hat_cowboy', name: 'Cowboy Hat' },name: 'Haul Week',description: 'Score at least 2,000 haul points in one week.',criterion: { event: 'haul_week', minScore: 2000 },nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- M5: Inventory delivery state for earned achievement items.++ALTER TABLE game_achievements++ ADD COLUMN item_state text CHECK (item_state IN ('granted', 'failed')),++ ADD COLUMN item_error text,++ ADD COLUMN item_instance text,++ ADD COLUMN item_attempts integer NOT NULL DEFAULT 0,++ ADD COLUMN item_updated_at bigint;++CREATE INDEX game_achievements_items_due ON game_achievements (item_state, unlocked_at)++ WHERE account_key LIKE 'usr_%' AND item_state IS DISTINCT FROM 'granted';@@@ -63,11 +63,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {`)await db.exec(sql`DELETE FROM game_best_scores WHERE account_key = ${from}`)await db.exec(sql`-- INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)-- SELECT game_id, achievement_id, ${into}, unlocked_at++ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at, item_state, item_error, item_instance, item_attempts, item_updated_at)++ SELECT game_id, achievement_id, ${into}, unlocked_at,++ CASE WHEN ${from} LIKE 'usr_%' THEN item_state ELSE NULL END,++ CASE WHEN ${from} LIKE 'usr_%' THEN item_error ELSE NULL END,++ CASE WHEN ${from} LIKE 'usr_%' THEN item_instance ELSE NULL END,++ CASE WHEN ${from} LIKE 'usr_%' THEN item_attempts ELSE 0 END,++ CASE WHEN ${from} LIKE 'usr_%' THEN item_updated_at ELSE NULL ENDFROM game_achievements WHERE account_key = ${from}ON CONFLICT (game_id, achievement_id, account_key) DO UPDATE SET-- unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)++ unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at),++ item_state = CASE WHEN game_achievements.item_state = 'granted' OR excluded.item_state = 'granted' THEN 'granted' ELSE NULL END,++ item_error = CASE WHEN game_achievements.item_state = 'granted' OR excluded.item_state = 'granted' THEN NULL ELSE COALESCE(game_achievements.item_error, excluded.item_error) END,++ item_instance = COALESCE(game_achievements.item_instance, excluded.item_instance),++ item_attempts = game_achievements.item_attempts + excluded.item_attempts,++ item_updated_at = GREATEST(game_achievements.item_updated_at, excluded.item_updated_at)`)await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)await db.exec(sql`@@@ -173,7 +183,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {sql`SELECT game_id, player_name, score, finished_at FROM game_best_scores WHERE account_key = ${subject} ORDER BY game_id`,)const gameAchievements = await db.many<Record<string, unknown>>(-- sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,++ sql`SELECT game_id, achievement_id, unlocked_at, item_state, item_error, item_instance, item_attempts, item_updated_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,)const gameHauls = await db.many<Record<string, unknown>>(sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/platform/inventoryRewards.test.ts'++// covers: 'apps/server/src/platform'++// Generated by node test/run.js --index; do not edit.
@@ -182,6 +182,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -182,6 +182,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
first-visit achievement is stored locally in Games; achievement events are not yet published.
first-visit achievement is stored locally in Games; achievement events are not yet published.
Progression events come from what a save actually wrote, in the same
Progression events come from what a save actually wrote, in the same
transaction.
transaction.
+- **Inventory.** Six built-in achievements give Live items: Scraplandia First Haul (Toxic Name), Big Haul (Golden Name), Haul Week (Cowboy Hat); Salvage Rush First Haul (Neon Name), Big Haul (Flames), Arena Winner (Stars). Games grants them after unlock or guest adoption using its own OAuth client; failed grants retry. Inventory items are free, never sold or traded.
- **Media.** Map-editor assets (textures, paint masks, glb models; the only
- **Media.** Map-editor assets (textures, paint masks, glb models; the only
uploaded files Games has) are still served from local disk and are also
uploaded files Games has) are still served from local disk and are also
copied into Media objects (`kind: asset`, namespace `MEDIA_NAMESPACE`), with
copied into Media objects (`kind: asset`, namespace `MEDIA_NAMESPACE`), with
@@ -221,17 +222,19 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -221,17 +222,19 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
props, the world clock and the mod registry survive the restart;
props, the world clock and the mod registry survive the restart;
`sliceTest.ts` covers the gameplay state end to end.
`sliceTest.ts` covers the gameplay state end to end.
-Platform environment (all optional; unset = off):
+Platform environment (all optional; service calls need `OV_OAUTH_CLIENT_SECRET`):
-| Variable | Purpose |
-| --------------------------- | ------------------------------------------------------------------------------------- |
-| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |
-| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |
-| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |
-| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=off` disables) |
-| `MEDIA_URL` | OpenVibe.Media base; enables the asset mirror (`MEDIA_MIRROR=off` disables) |
-| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
-| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
+| Variable | Purpose |
+| --------------------------- | ------------------------------------------------------------------------------------------ |
+| `OV_OAUTH_CLIENT_ID` | OAuth client / principal id (default `games`) |
+| `OV_OAUTH_CLIENT_SECRET` | Its secret: enables SSO and every service call |
+| `OV_NETWORK_INTERNAL_URL` | Network base for `/oauth/token`, identity and the JWKS (e.g. `http://127.0.0.1:4000`) |
+| `EVENTS_URL` | OpenVibe.Events base; enables the outbox (`EVENTS_PUBLISH=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_ITEMS` | `on` (default) or `off`; off also when the OAuth client secret is unset |
+| `MEDIA_NAMESPACE` | Media tenant for the copies (default `games`) |
+| `WORLD_SAVED_EVENT_MINUTES` | Checkpoint event interval (default 15) |
## Capabilities
## Capabilities
@@ -242,8 +245,8 @@ mods API, for a principal token or an owner/admin session); `games.world.announc
@@ -242,8 +245,8 @@ mods API, for a principal token or an owner/admin session); `games.world.announc
`games.world.emit`, and `games.storage.kv` for script packs. Script bindings
`games.world.emit`, and `games.storage.kv` for script packs. Script bindings
are absent unless approved for that install.
are absent unless approved for that install.
-Grants the `games` principal needs in OpenVibe.Network:
-`events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account,
+Service grants the `games` principal needs:
+`inventory.item.grant` (openvibe.inventory), `events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account,
revocation and mod-grant subscriptions), `media.object.upload`
revocation and mod-grant subscriptions), `media.object.upload`
(openvibe.media, namespace `games`), `identity.subject.resolve`
(openvibe.media, namespace `games`), `identity.subject.resolve`
(openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage`
(openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage`
@@ -27,6 +27,7 @@
@@ -27,6 +27,7 @@
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe",
"M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe",
"M5 first part in working tree (not deployed): one Games process hosts Scraplandia and Salvage Rush in separate place and instance registries; server-scored rounds, per-game achievements and paginated all-time/week leaderboards in Games",
"M5 first part in working tree (not deployed): one Games process hosts Scraplandia and Salvage Rush in separate place and instance registries; server-scored rounds, per-game achievements and paginated all-time/week leaderboards in Games",
+ "six achievement rewards granted as free OpenVibe.Inventory items, never sold or traded; GAMES_ITEMS=on/off and GAMES_INVENTORY_URL configure delivery",
"map editor with assets mirrored to OpenVibe.Media",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",
"server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",
@@ -543,7 +543,7 @@
@@ -543,7 +543,7 @@
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
haul your salvage home before another scrapper takes it off your corpse. Hold the circle
haul your salvage home before another scrapper takes it off your corpse. Hold the circle
when the recovery bird comes: every valuable you extract scores haul points on this
when the recovery bird comes: every valuable you extract scores haul points on this
- week's board.
+ week's board. Achievements give items worn across OpenVibe.
</p>
</p>
<div class="tags">
<div class="tags">
<span>persistent world</span><span>physics sandbox</span><span>survival</span
<span>persistent world</span><span>physics sandbox</span><span>survival</span
@@ -646,7 +646,8 @@
@@ -646,7 +646,8 @@
<h3>SALVAGE RUSH</h3>
<h3>SALVAGE RUSH</h3>
<p>
<p>
Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each
Collect scrap around a compact arena in 90-second on-foot rounds. The server scores each
- pickup; rounds have a public leaderboard and three achievements.
+ pickup; rounds have a public leaderboard and three achievements that give items worn
+ across OpenVibe.
</p>
</p>
<div class="tags">
<div class="tags">
<span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
<span>timed rounds</span><span>on-foot</span><span>leaderboard</span>
@@ -603,7 +603,12 @@ export class Hud {
@@ -603,7 +603,12 @@ export class Hud {
const data = (await response.json()) as {
const data = (await response.json()) as {
games: {
games: {
game: string
game: string
- achievements: { name: string; description: string; unlockedAt: number | null }[]
+ achievements: {
+ name: string
+ description: string
+ reward?: { item: string; name: string }
+ unlockedAt: number | null
+ }[]
}[]
}[]
}
}
if (this.activeTab !== 'achievements') return
if (this.activeTab !== 'achievements') return
@@ -613,7 +618,7 @@ export class Hud {
@@ -613,7 +618,7 @@ export class Hud {
[]) {
[]) {
const line = document.createElement('p')
const line = document.createElement('p')
line.className = 'skill-row'
line.className = 'skill-row'
- line.textContent = `${achievement.unlockedAt ? '✓' : '○'} ${achievement.name} — ${achievement.description}`
+ line.textContent = `${achievement.unlockedAt ? '✓' : '○'} ${achievement.name} — ${achievement.description}${achievement.reward ? ` Gives the ${achievement.reward.name} on OpenVibe.Inventory.` : ''}`
body.append(line)
body.append(line)
}
}
if (!body.children.length) body.textContent = 'No achievements for this game yet.'
if (!body.children.length) body.textContent = 'No achievements for this game yet.'
@@ -95,6 +95,9 @@ export interface PlatformConfig {
@@ -95,6 +95,9 @@ export interface PlatformConfig {
eventsUrl: string | null
eventsUrl: string | null
/** OpenVibe.Media base; null = map assets stay local only. */
/** OpenVibe.Media base; null = map assets stay local only. */
mediaUrl: string | null
mediaUrl: string | null
+ /** OpenVibe.Inventory base and item reward switch. */
+ inventoryUrl: string
+ items: boolean
/** 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. */
@@ -131,6 +134,8 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
@@ -131,6 +134,8 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
),
),
eventsUrl: env.EVENTS_URL && env.EVENTS_PUBLISH !== 'off' ? trimSlash(env.EVENTS_URL) : null,
eventsUrl: env.EVENTS_URL && env.EVENTS_PUBLISH !== 'off' ? trimSlash(env.EVENTS_URL) : null,
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'),
+ items: env.GAMES_ITEMS !== 'off' && !!secret,
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 ?? '')
@@ -144,6 +144,7 @@ export class GameProgressWriter {
@@ -144,6 +144,7 @@ export class GameProgressWriter {
constructor(
constructor(
private readonly db: Db,
private readonly db: Db,
private readonly onError: (error: unknown) => void,
private readonly onError: (error: unknown) => void,
+ private readonly afterCommit?: (subject: string) => void,
) {
) {
this.timer = setInterval(() => void this.flush().catch(onError), 2000)
this.timer = setInterval(() => void this.flush().catch(onError), 2000)
this.timer.unref()
this.timer.unref()
@@ -178,6 +179,7 @@ export class GameProgressWriter {
@@ -178,6 +179,7 @@ export class GameProgressWriter {
for (const [roundId, entry] of this.pending) {
for (const [roundId, entry] of this.pending) {
await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
this.pending.delete(roundId)
this.pending.delete(roundId)
+ for (const result of entry.results) this.afterCommit?.(result.key)
}
}
for (const [id, haul] of this.hauls) {
for (const [id, haul] of this.hauls) {
await recordHaul(
await recordHaul(
@@ -189,10 +191,12 @@ export class GameProgressWriter {
@@ -189,10 +191,12 @@ export class GameProgressWriter {
haul.finishedAt,
haul.finishedAt,
)
)
this.hauls.delete(id)
this.hauls.delete(id)
+ this.afterCommit?.(haul.key)
}
}
for (const [key, at] of this.joined) {
for (const [key, at] of this.joined) {
await recordJoined(this.db, key, at)
await recordJoined(this.db, key, at)
this.joined.delete(key)
this.joined.delete(key)
+ this.afterCommit?.(key)
}
}
}
}
})()
})()
@@ -63,6 +63,8 @@ export type ResolvedConnectionIdentity = TicketIdentity
@@ -63,6 +63,8 @@ export type ResolvedConnectionIdentity = TicketIdentity
export interface GameIntegrations {
export interface GameIntegrations {
/** A server-confirmed authenticated join, for place-scoped achievements. */
/** A server-confirmed authenticated join, for place-scoped achievements. */
playerJoined?: (key: string) => void
playerJoined?: (key: string) => void
+ /** Revisit item rewards after a guest account is adopted. */
+ guestAdopted?: (subject: string) => void
/** Called only for completed server-simulated rounds. Never accepts a client message. */
/** Called only for completed server-simulated rounds. Never accepts a client message. */
roundFinished?: (
roundFinished?: (
roundId: string,
roundId: string,
@@ -157,9 +157,10 @@ export class SessionsSystem implements System {
@@ -157,9 +157,10 @@ export class SessionsSystem implements System {
subjectId,
subjectId,
Date.now(),
Date.now(),
)
)
- if (g.moved > 0)
+ if (g.moved > 0) {
ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
- else if (g.full)
+ ctx.integrations.guestAdopted?.(subjectId)
+ } else if (g.full)
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
}
}
} else {
} else {
@@ -59,6 +59,7 @@ import {
@@ -59,6 +59,7 @@ import {
import { createAccountData, networkSender, type AccountEvent } from './platform/accountData.js'
import { createAccountData, networkSender, type AccountEvent } from './platform/accountData.js'
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 {
import {
createRevocationEvents,
createRevocationEvents,
ensureRevocationSubscription,
ensureRevocationSubscription,
@@ -110,8 +111,18 @@ async function main(): Promise<void> {
@@ -110,8 +111,18 @@ async function main(): Promise<void> {
)
)
const builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK
const builtInPack = config.placeId === 'salvage-rush' ? SALVAGE_RUSH_PACK : SCRAPLANDIA_PACK
const content = createContent(packs, builtInPack)
const content = createContent(packs, builtInPack)
- const progressWriter = new GameProgressWriter(db, (error) =>
- log.warn('game progression write failed', { error: String(error) }),
+ let inventoryRewards: InventoryRewards | null = null
+ const settleRewards = (subject: string): void => {
+ void inventoryRewards
+ ?.settleSubject(subject)
+ .catch((error: unknown) =>
+ log.warn('inventory reward lookup failed', { error: String(error) }),
+ )
+ }
+ const progressWriter = new GameProgressWriter(
+ db,
+ (error) => log.warn('game progression write failed', { error: String(error) }),
+ settleRewards,
)
)
const builtInModIds = new Set(
const builtInModIds = new Set(
(SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
(SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
@@ -159,6 +170,14 @@ async function main(): Promise<void> {
@@ -159,6 +170,14 @@ async function main(): Promise<void> {
// Every service call carries the `games` principal's client-credentials
// Every service call carries the `games` principal's client-credentials
// token; with no client secret configured none of this talks to anything.
// token; with no client secret configured none of this talks to anything.
const platform = createPlatformClient(config.platform)
const platform = createPlatformClient(config.platform)
+ inventoryRewards = new InventoryRewards({
+ db,
+ url: config.platform.inventoryUrl,
+ enabled: config.platform.items && !!platform,
+ tokens: platform?.tokens ?? null,
+ warn: (message) => log.warn(message),
+ })
+ inventoryRewards.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
@@ -297,6 +316,7 @@ async function main(): Promise<void> {
@@ -297,6 +316,7 @@ async function main(): Promise<void> {
mods: modRuntime,
mods: modRuntime,
...(scripts ? { scripts } : {}),
...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
+ guestAdopted: settleRewards,
...(config.placeId === 'scraplandia'
...(config.placeId === 'scraplandia'
? {
? {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
@@ -366,6 +386,7 @@ async function main(): Promise<void> {
@@ -366,6 +386,7 @@ async function main(): Promise<void> {
}),
}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
+ guestAdopted: settleRewards,
...(config.placeId === 'scraplandia'
...(config.placeId === 'scraplandia'
? {
? {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
@@ -466,6 +487,7 @@ async function main(): Promise<void> {
@@ -466,6 +487,7 @@ async function main(): Promise<void> {
),
),
...(roomScripts ? { scripts: roomScripts } : {}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
+ guestAdopted: settleRewards,
...(secondaryPlace === 'scraplandia'
...(secondaryPlace === 'scraplandia'
? {
? {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
@@ -588,7 +610,11 @@ async function main(): Promise<void> {
@@ -588,7 +610,11 @@ async function main(): Promise<void> {
0,
0,
),
),
// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
- onMerged: (from, into, mergeId) => store.identity.mergeSubject(from, into, mergeId, Date.now()),
+ onMerged: async (from, into, mergeId) => {
+ const result = await store.identity.mergeSubject(from, into, mergeId, Date.now())
+ settleRewards(into)
+ return result
+ },
// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each
// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each
// close saves its character) before its characters are erased.
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
@@ -968,6 +994,7 @@ async function main(): Promise<void> {
@@ -968,6 +994,7 @@ async function main(): Promise<void> {
await within(2000, outbox?.stop())
await within(2000, outbox?.stop())
await mods.flushWrites()
await mods.flushWrites()
await progressWriter.stop()
await progressWriter.stop()
+ inventoryRewards?.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,258 @@
@@ -0,0 +1,258 @@
+import { describe, expect, it } from 'vitest'
+import { sql, type Db } 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 { InventoryRewards } from './inventoryRewards.js'
+
+const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
+const aliases = ['fx_toxic', 'fx_golden', 'hat_cowboy', 'fx_neon', 'px_flames', 'px_stars']
+
+function fakeNetwork() {
+ const grants: Record<string, unknown>[] = []
+ const calls: string[] = []
+ 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,
+ )
+ calls.push(`${init?.method ?? 'GET'} ${url.pathname}`)
+ 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('grant_type')).toBe('client_credentials')
+ expect(form.get('audience')).toBe('openvibe.inventory')
+ expect(form.get('scope')).toBe('inventory.item.grant')
+ return reply(200, { access_token: 'fake-token', token_type: 'Bearer', expires_in: 3600 })
+ }
+ if (url.pathname === '/api/v1/definitions') {
+ expect(url.searchParams.get('issuer')).toBe('service:live')
+ return reply(200, {
+ definitions: aliases.map((alias, i) => ({
+ id: `itd_${i}`,
+ issuer: 'service:live',
+ aliases: [alias],
+ })),
+ })
+ }
+ if (url.pathname === '/api/v1/grants') {
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer fake-token')
+ if (failures-- > 0) return reply(503, { code: 'inventory.unavailable' })
+ grants.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
+ return reply(201, { instance: { id: 'iti_1' } })
+ }
+ return reply(404, {})
+ }
+ return {
+ fetchImpl,
+ grants,
+ calls,
+ fail: () => {
+ failures = 1
+ },
+ }
+}
+
+async function setup(enabled = true) {
+ const store = await openTestStore()
+ const fake = fakeNetwork()
+ const config = loadPlatformConfig({
+ OV_OAUTH_CLIENT_SECRET: 'secret',
+ OV_NETWORK_INTERNAL_URL: 'http://network.test',
+ GAMES_INVENTORY_URL: 'http://inventory.test',
+ GAMES_ITEMS: enabled ? 'on' : 'off',
+ })
+ const platform = createPlatformClient(config, fake.fetchImpl as never)!
+ // A clock the tests move: a failed grant waits out its backoff before the timer asks again.
+ const clock = { at: Date.now() }
+ const items = new InventoryRewards({
+ db: store.db,
+ url: config.inventoryUrl,
+ enabled: config.items,
+ tokens: platform.tokens,
+ fetchImpl: fake.fetchImpl,
+ now: () => clock.at,
+ })
+ const advance = (ms: number) => {
+ clock.at += ms
+ }
+ return { store, fake, items, advance }
+}
+
+async function state(db: Db, key: string, game: string, achievement: string) {
+ return db.maybe<{
+ item_state: string | null
+ item_error: string | null
+ item_attempts: number
+ }>(sql`
+ SELECT item_state, item_error, item_attempts FROM game_achievements
+ WHERE account_key = ${key} AND game_id = ${game} AND achievement_id = ${achievement}
+ `)
+}
+
+describe('Inventory achievement rewards', () => {
+ it('grants an account unlock once with earned origin and stable idempotency key', async () => {
+ const { store, fake, items } = await setup()
+ await recordHaul(store.db, subject, 'haul-1', 'Player', 100, 1000)
+ await items.settleSubject(subject)
+ await items.retryPending()
+ expect(fake.grants).toEqual([
+ expect.objectContaining({
+ definition_id: 'itd_0',
+ subject,
+ origin: 'earned',
+ idempotency_key: `games:scraplandia:first-haul:${subject}`,
+ }),
+ ])
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'granted',
+ item_attempts: 1,
+ })
+ })
+
+ it('leaves guest unlocks alone until adoption moves them to the subject', async () => {
+ const { store, fake, items } = await setup()
+ const guest = 'guest-test-key'
+ await store.players.upsert({
+ id: 'guest-character',
+ token: guest,
+ name: 'Guest',
+ pos: [0, 1, 0],
+ yaw: 0,
+ inventory: { size: 24, hotbar: 6, slots: [] },
+ skills: {},
+ friends: [],
+ appearance: null,
+ charSlot: 0,
+ armor: null,
+ reputation: {},
+ unlocks: [],
+ activeJob: null,
+ stats: null,
+ updatedAt: 1,
+ })
+ await recordRound(
+ store.db,
+ 'salvage-rush',
+ 'round-1',
+ [{ key: guest, name: 'Guest', score: 1, rank: 2 }],
+ 1000,
+ )
+ await items.retryPending()
+ expect(fake.grants).toHaveLength(0)
+ await store.identity.adoptGuestCharacter(guest, subject, 2000)
+ await items.settleSubject(subject)
+ expect(fake.grants).toEqual([expect.objectContaining({ subject, definition_id: 'itd_3' })])
+ })
+
+ it('records a failure and retries it after its backoff', async () => {
+ const { store, fake, items, advance } = await setup()
+ fake.fail()
+ await recordHaul(store.db, subject, 'haul-1', 'Player', 100, 1000)
+ await items.settleSubject(subject)
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'failed',
+ item_attempts: 1,
+ })
+ await items.retryPending()
+ expect(fake.grants).toHaveLength(0)
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'failed',
+ item_attempts: 1,
+ })
+ advance(3 * 60_000)
+ await items.retryPending()
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'granted',
+ item_attempts: 2,
+ })
+ expect(fake.grants).toHaveLength(1)
+ })
+
+ it('backfills an older unlocked achievement with no item state', async () => {
+ const { store, fake, items } = await setup()
+ await recordHaul(store.db, subject, 'old-haul', 'Player', 100, 1000)
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: null,
+ })
+ await items.retryPending()
+ expect(fake.grants).toHaveLength(1)
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'granted',
+ })
+ })
+
+ it('retries a failed grant on the timer', async () => {
+ const { store, fake, items, advance } = await setup()
+ fake.fail()
+ await recordHaul(store.db, subject, 'haul-timer', 'Player', 100, 1000)
+ await items.settleSubject(subject)
+ advance(3 * 60_000)
+ items.start(20)
+ try {
+ const deadline = Date.now() + 2000
+ while (
+ (await state(store.db, subject, 'scraplandia', 'first-haul'))?.item_state !== 'granted' &&
+ Date.now() < deadline
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ }
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'granted',
+ item_attempts: 2,
+ })
+ } finally {
+ items.stop()
+ }
+ })
+
+ it('exports grant state and moves it with account achievements', async () => {
+ const { store, items } = await setup()
+ const from = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1'
+ await recordHaul(store.db, from, 'haul-merge', 'Player', 100, 1000)
+ await items.settleSubject(from)
+ expect((await store.identity.exportSubject(from)).gameAchievements).toEqual([
+ expect.objectContaining({
+ achievement_id: 'first-haul',
+ item_state: 'granted',
+ item_instance: 'iti_1',
+ }),
+ ])
+ await store.identity.mergeSubject(from, subject, 'merge-1', 2000)
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: 'granted',
+ })
+ expect(await state(store.db, from, 'scraplandia', 'first-haul')).toBeNull()
+ await store.identity.eraseSubjects([subject])
+ expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toBeNull()
+ })
+
+ it('does nothing when off or the achievement has no reward', async () => {
+ const off = await setup(false)
+ await recordHaul(off.store.db, subject, 'haul-1', 'Player', 100, 1000)
+ await off.items.settleSubject(subject)
+ await off.items.retryPending()
+ expect(off.fake.calls).toEqual([])
+ expect(await state(off.store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
+ item_state: null,
+ item_attempts: 0,
+ })
+ const on = await setup()
+ await recordJoined(on.store.db, subject, 1000)
+ await on.items.settleSubject(subject)
+ await on.items.retryPending()
+ expect(on.fake.calls).toEqual([])
+ expect(await state(on.store.db, subject, 'scraplandia', 'first-visit')).toMatchObject({
+ item_state: null,
+ item_attempts: 0,
+ })
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
+/** Inventory items earned by Games achievements. All network work happens after unlock commits. */
+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.inventory'
+const SCOPE = 'inventory.item.grant'
+const RETRY_MS = 3 * 60_000
+const CACHE_MS = 60 * 60_000
+const TIMEOUT_MS = 5000
+const rewards = {
+ scraplandia: SCRAPLANDIA_PACK.achievements ?? [],
+ 'salvage-rush': SALVAGE_RUSH_PACK.game?.achievements ?? [],
+}
+
+type Row = {
+ game_id: string
+ achievement_id: string
+ account_key: string
+ item_state: string | null
+ item_attempts?: number | null
+ item_updated_at?: number | string | null
+}
+
+/** A failed grant waits 2^attempts minutes (at most 6 h) before the timer tries it again. */
+function backoffMs(attempts: number): number {
+ return Math.min(6 * 3600_000, 2 ** Math.min(9, Math.max(1, attempts)) * 60_000)
+}
+
+export class InventoryRewards {
+ private timer: ReturnType<typeof setInterval> | null = null
+ private cache: { at: number; aliases: Map<string, string> } | null = null
+ private readonly active = new Set<string>()
+ private scanning = false
+
+ constructor(
+ private readonly opts: {
+ db: Db
+ url: string
+ enabled: boolean
+ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> } | null
+ fetchImpl?: typeof fetch
+ now?: () => number
+ warn?: (message: string) => void
+ },
+ ) {}
+
+ private get now(): number {
+ return (this.opts.now ?? Date.now)()
+ }
+ private get fetchImpl(): typeof fetch {
+ return this.opts.fetchImpl ?? fetch
+ }
+ private get base(): string {
+ return this.opts.url.replace(/\/+$/, '')
+ }
+
+ private definition(game: string, achievement: string) {
+ if (game !== 'scraplandia' && game !== 'salvage-rush') return null
+ return rewards[game].find((def) => def.id === achievement && def.reward) ?? null
+ }
+
+ private async definitionId(alias: string): Promise<string> {
+ if (!this.cache || this.now - this.cache.at >= CACHE_MS) {
+ const response = await this.fetchImpl(
+ `${this.base}/api/v1/definitions?issuer=service%3Alive&limit=500`,
+ {
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ },
+ )
+ if (!response.ok) throw new Error(`Inventory definitions ${response.status}`)
+ const body = (await response.json()) as {
+ definitions?: { id: string; issuer: string; aliases?: string[] }[]
+ }
+ const aliases = new Map<string, string>()
+ for (const def of body.definitions ?? []) {
+ if (def.issuer === 'service:live')
+ for (const name of def.aliases ?? []) aliases.set(name, def.id)
+ }
+ this.cache = { at: this.now, aliases }
+ }
+ const id = this.cache.aliases.get(alias)
+ if (!id) throw new Error(`Inventory definition missing: ${alias}`)
+ return id
+ }
+
+ /** Settle one committed unlock, or every pending unlock for a newly adopted subject. */
+ async settleSubject(subject: string): Promise<void> {
+ if (!this.opts.enabled || !this.opts.tokens || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject))
+ return
+ const rows = await this.opts.db.many<Row>(sql`
+ SELECT game_id, achievement_id, account_key, item_state FROM game_achievements
+ WHERE account_key = ${subject} AND item_state IS DISTINCT FROM 'granted'
+ ORDER BY unlocked_at LIMIT 50
+ `)
+ for (const row of rows) await this.settle(row)
+ }
+
+ private async settle(row: Row): Promise<void> {
+ if (
+ !this.opts.enabled ||
+ !this.opts.tokens ||
+ row.item_state === 'granted' ||
+ !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(row.account_key)
+ )
+ return
+ const def = this.definition(row.game_id, row.achievement_id)
+ if (!def?.reward) return
+ const key = `${row.game_id}:${row.achievement_id}:${row.account_key}`
+ if (this.active.has(key)) return
+ this.active.add(key)
+ try {
+ const current = await this.opts.db.maybe<Row>(sql`
+ SELECT game_id, achievement_id, account_key, item_state FROM game_achievements
+ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}
+ AND account_key = ${row.account_key}
+ `)
+ if (!current || current.item_state === 'granted') return
+ const id = await this.definitionId(def.reward.item)
+ const token = await this.opts.tokens.getToken({ audience: AUDIENCE, scope: SCOPE })
+ const response = await this.fetchImpl(`${this.base}/api/v1/grants`, {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ definition_id: id,
+ subject: row.account_key,
+ idempotency_key: `games:${key}`,
+ origin: 'earned',
+ reason: `Games: ${row.game_id} — ${def.name}`.slice(0, 200),
+ }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ })
+ const body = (await response.json().catch(() => null)) as {
+ instance?: { id?: string }
+ code?: string
+ } | null
+ if (!response.ok)
+ throw new Error(`Inventory ${response.status}${body?.code ? `: ${body.code}` : ''}`)
+ await this.opts.db.exec(sql`
+ UPDATE game_achievements SET item_state = 'granted', item_error = NULL,
+ item_instance = ${body?.instance?.id ?? null}, item_attempts = item_attempts + 1,
+ item_updated_at = ${this.now}
+ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}
+ AND account_key = ${row.account_key}
+ `)
+ } catch (error) {
+ const message = String((error as Error)?.message ?? error).slice(0, 200)
+ this.opts.warn?.(`Inventory reward ${key}: ${message}`)
+ await this.opts.db.exec(sql`
+ UPDATE game_achievements SET item_state = 'failed', item_error = ${message},
+ item_attempts = item_attempts + 1, item_updated_at = ${this.now}
+ WHERE game_id = ${row.game_id} AND achievement_id = ${row.achievement_id}
+ AND account_key = ${row.account_key} AND item_state IS DISTINCT FROM 'granted'
+ `)
+ } finally {
+ this.active.delete(key)
+ }
+ }
+
+ /** Bounded scan also catches achievements unlocked before item rewards existed. */
+ async retryPending(limit = 50): Promise<void> {
+ if (!this.opts.enabled || !this.opts.tokens || this.scanning) return
+ this.scanning = true
+ try {
+ const rows = await this.opts.db.many<Row>(sql`
+ SELECT game_id, achievement_id, account_key, item_state, item_attempts, item_updated_at FROM game_achievements
+ WHERE account_key LIKE 'usr_%' AND item_state IS DISTINCT FROM 'granted'
+ AND ((game_id = 'scraplandia' AND achievement_id IN ('first-haul', 'big-haul', 'haul-week'))
+ OR (game_id = 'salvage-rush' AND achievement_id IN ('first-haul', 'big-haul', 'arena-winner')))
+ ORDER BY COALESCE(item_updated_at, 0), unlocked_at LIMIT ${Math.max(1, Math.min(limit, 100))}
+ `)
+ for (const row of rows) {
+ // An Inventory that keeps refusing (an outage, or the grant not yet in place) is not asked every 3 minutes.
+ if (row.item_state === 'failed' && this.now - Number(row.item_updated_at ?? 0) < backoffMs(Number(row.item_attempts ?? 0)))
+ continue
+ await this.settle(row)
+ }
+ } finally {
+ this.scanning = false
+ }
+ }
+
+ start(intervalMs = RETRY_MS): void {
+ if (!this.opts.enabled || !this.opts.tokens || this.timer) return
+ const run = () =>
+ void this.retryPending().catch((error: unknown) => this.opts.warn?.(String(error)))
+ run()
+ this.timer = setInterval(run, intervalMs)
+ this.timer.unref()
+ }
+
+ stop(): void {
+ if (this.timer) clearInterval(this.timer)
+ this.timer = null
+ }
+}
@@ -45,6 +45,7 @@ export interface ContentPackV2 {
@@ -45,6 +45,7 @@ export interface ContentPackV2 {
id: string
id: string
name: string
name: string
description: string
description: string
+ reward?: { item: string; name: string }
criterion:
criterion:
| { event: 'player_joined' }
| { event: 'player_joined' }
| { event: 'haul_recorded'; minScore?: number }
| { event: 'haul_recorded'; minScore?: number }
@@ -60,6 +61,7 @@ export interface ContentPackV2 {
@@ -60,6 +61,7 @@ export interface ContentPackV2 {
id: string
id: string
name: string
name: string
description: string
description: string
+ reward?: { item: string; name: string }
criterion: { event: 'round_finished'; minScore?: number; minRank?: number }
criterion: { event: 'round_finished'; minScore?: number; minRank?: number }
}[]
}[]
}
}
@@ -44,18 +44,21 @@ export const SALVAGE_RUSH_PACK: ContentPackV2 = {
@@ -44,18 +44,21 @@ export const SALVAGE_RUSH_PACK: ContentPackV2 = {
achievements: [
achievements: [
{
{
id: 'first-haul',
id: 'first-haul',
+ reward: { item: 'fx_neon', name: 'Neon Name' },
name: 'First Haul',
name: 'First Haul',
description: 'Score in a Salvage Rush round.',
description: 'Score in a Salvage Rush round.',
criterion: { event: 'round_finished', minScore: 1 },
criterion: { event: 'round_finished', minScore: 1 },
},
},
{
{
id: 'big-haul',
id: 'big-haul',
+ reward: { item: 'px_flames', name: 'Flames' },
name: 'Big Haul',
name: 'Big Haul',
description: 'Score at least five points in one round.',
description: 'Score at least five points in one round.',
criterion: { event: 'round_finished', minScore: 5 },
criterion: { event: 'round_finished', minScore: 5 },
},
},
{
{
id: 'arena-winner',
id: 'arena-winner',
+ reward: { item: 'px_stars', name: 'Stars' },
name: 'Arena Winner',
name: 'Arena Winner',
description: 'Finish a round in first place.',
description: 'Finish a round in first place.',
criterion: { event: 'round_finished', minRank: 1 },
criterion: { event: 'round_finished', minRank: 1 },
@@ -16,18 +16,21 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
@@ -16,18 +16,21 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
},
},
{
{
id: 'first-haul',
id: 'first-haul',
+ reward: { item: 'fx_toxic', name: 'Toxic Name' },
name: 'First Haul',
name: 'First Haul',
description: 'Extract valuables to Scrap City.',
description: 'Extract valuables to Scrap City.',
criterion: { event: 'haul_recorded' },
criterion: { event: 'haul_recorded' },
},
},
{
{
id: 'big-haul',
id: 'big-haul',
+ reward: { item: 'fx_golden', name: 'Golden Name' },
name: 'Big Haul',
name: 'Big Haul',
description: 'Score at least 500 haul points in one extraction.',
description: 'Score at least 500 haul points in one extraction.',
criterion: { event: 'haul_recorded', minScore: 500 },
criterion: { event: 'haul_recorded', minScore: 500 },
},
},
{
{
id: 'haul-week',
id: 'haul-week',
+ reward: { item: 'hat_cowboy', name: 'Cowboy Hat' },
name: 'Haul Week',
name: 'Haul Week',
description: 'Score at least 2,000 haul points in one week.',
description: 'Score at least 2,000 haul points in one week.',
criterion: { event: 'haul_week', minScore: 2000 },
criterion: { event: 'haul_week', minScore: 2000 },
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- M5: Inventory delivery state for earned achievement items.
+ALTER TABLE game_achievements
+ ADD COLUMN item_state text CHECK (item_state IN ('granted', 'failed')),
+ ADD COLUMN item_error text,
+ ADD COLUMN item_instance text,
+ ADD COLUMN item_attempts integer NOT NULL DEFAULT 0,
+ ADD COLUMN item_updated_at bigint;
+CREATE INDEX game_achievements_items_due ON game_achievements (item_state, unlocked_at)
+ WHERE account_key LIKE 'usr_%' AND item_state IS DISTINCT FROM 'granted';
@@ -63,11 +63,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -63,11 +63,21 @@ export function createIdentityRepository(db: Db): IdentityRepository {
`)
`)
await db.exec(sql`DELETE FROM game_best_scores WHERE account_key = ${from}`)
await db.exec(sql`DELETE FROM game_best_scores WHERE account_key = ${from}`)
await db.exec(sql`
await db.exec(sql`
- INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)
- SELECT game_id, achievement_id, ${into}, unlocked_at
+ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at, item_state, item_error, item_instance, item_attempts, item_updated_at)
+ SELECT game_id, achievement_id, ${into}, unlocked_at,
+ CASE WHEN ${from} LIKE 'usr_%' THEN item_state ELSE NULL END,
+ CASE WHEN ${from} LIKE 'usr_%' THEN item_error ELSE NULL END,
+ CASE WHEN ${from} LIKE 'usr_%' THEN item_instance ELSE NULL END,
+ CASE WHEN ${from} LIKE 'usr_%' THEN item_attempts ELSE 0 END,
+ CASE WHEN ${from} LIKE 'usr_%' THEN item_updated_at ELSE NULL END
FROM game_achievements WHERE account_key = ${from}
FROM game_achievements WHERE account_key = ${from}
ON CONFLICT (game_id, achievement_id, account_key) DO UPDATE SET
ON CONFLICT (game_id, achievement_id, account_key) DO UPDATE SET
- unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)
+ unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at),
+ item_state = CASE WHEN game_achievements.item_state = 'granted' OR excluded.item_state = 'granted' THEN 'granted' ELSE NULL END,
+ item_error = CASE WHEN game_achievements.item_state = 'granted' OR excluded.item_state = 'granted' THEN NULL ELSE COALESCE(game_achievements.item_error, excluded.item_error) END,
+ item_instance = COALESCE(game_achievements.item_instance, excluded.item_instance),
+ item_attempts = game_achievements.item_attempts + excluded.item_attempts,
+ item_updated_at = GREATEST(game_achievements.item_updated_at, excluded.item_updated_at)
`)
`)
await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)
await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)
await db.exec(sql`
await db.exec(sql`
@@ -173,7 +183,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -173,7 +183,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
sql`SELECT game_id, player_name, score, finished_at FROM game_best_scores WHERE account_key = ${subject} ORDER BY game_id`,
sql`SELECT game_id, player_name, score, finished_at FROM game_best_scores WHERE account_key = ${subject} ORDER BY game_id`,
)
)
const gameAchievements = await db.many<Record<string, unknown>>(
const gameAchievements = await db.many<Record<string, unknown>>(
- sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,
+ sql`SELECT game_id, achievement_id, unlocked_at, item_state, item_error, item_instance, item_attempts, item_updated_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,
)
)
const gameHauls = await db.many<Record<string, unknown>>(
const gameHauls = await db.many<Record<string, unknown>>(
sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/platform/inventoryRewards.test.ts'
+// covers: 'apps/server/src/platform'
+// Generated by node test/run.js --index; do not edit.