openvibers/openvibe.games Commits

Games: six achievements give OpenVibe.Inventory items (granted to signed-in players once, retried with backoff) (#53)

OP OpenVibers committed 12 Oct 2026 · 63e5b69497 · 17 files, +563 −25

Plan T8 M5, "cross-game inventory": six achievements now give an OpenVibe.Inventory item, worn in chat, on overlays and on profiles across OpenVibe. Live is the issuer of these items, and Inventory names Games a grantor of them (Inventory#36), as it does Quest. Network gives Games `inventory.item.grant` (Network#167).

| Achievement | Item |
|---|---|
| Scraplandia: First Haul | Toxic Name |
| Scraplandia: Big Haul | Golden Name |
| Scraplandia: Haul Week | Cowboy Hat |
| Salvage Rush: First Haul | Neon Name |
| Salvage Rush: Big Haul | Flames |
| Salvage Rush: Arena Winner | Stars |

- **Reward data:** an optional `reward: { item, name }` on an achievement in the content packs, set on these six. How achievements are evaluated and scored is unchanged.
- **Granting** (`apps/server/src/platform/inventoryRewards.ts`, following Quest's `items.js`):
  - It runs only for players signed in with an OpenVibe account (a `usr_` subject), and only after the unlock commits. An Inventory outage never holds up or rolls back an unlock.
  - The call is `POST /api/v1/grants` with origin `earned` and the key `games:<game>:<achievement>:<subject>`, so a retry never gives two. Live's aliases resolve to Inventory ids once an hour.
  - Guests get their items when their guest progress is adopted into an account.
- **State:** migration `0006_inventory_rewards.sql` (expand) adds `item_state` (`granted` or `failed`), the error, the instance, the attempts and the time to `game_achievements`. A retry timer every 3 minutes picks up failed and never-tried rows, which also covers unlocks from before this release.
  - A failed grant waits 2^attempts minutes, at most 6 h. I added this in review: the first version asked Inventory again every 3 minutes, forever.
  - The state rows follow achievements through export, erasure and merge.
- **Config:** `GAMES_INVENTORY_URL` (default `https://inventory.openvibe.network`) and `GAMES_ITEMS=on|off`. Granting is off without the OAuth client secret.
- **UI:** the HUD's achievement list says "Gives the <item> on OpenVibe.Inventory". The home page's two game cards each add one line about it.

Built by Codex and reviewed. `pnpm typecheck` and `pnpm lint` pass. `inventoryRewards.test.ts` covers an account's grant (once, by key), a guest granted on adoption, a failure that waits out its backoff and is then retried (also by the timer), a backfilled older unlock, and `GAMES_ITEMS=off`; it passes 7 of 7. `accountData` and `config` tests pass too. CI runs the full suite.

Changes

ddiff --git a/README.md b/README.md
iindex cc17b94..e13c33b 100644
---- a/README.md
++++ b/README.md
@@@ -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.
Progression events come from what a save actually wrote, in the same
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
uploaded files Games has) are still served from local disk and are also
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
props, 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 bindings
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`
(openvibe.media, namespace `games`), `identity.subject.resolve`
(openvibe.network), `network.modules.write` on `games.progress.summary`, and `mods.grant.manage`
ddiff --git a/STATUS.json b/STATUS.json
iindex c923a7e..7e0ef2d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex eec48ff..dd9da7a 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -543,7 +543,7 @@
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
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>
<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>
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex db34a89..001af54 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -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.'
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 92f53e9..9a48a8a 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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 ?? '')
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
iindex ca9bbb0..9632a15 100644
---- a/apps/server/src/game/progression.ts
++++ b/apps/server/src/game/progression.ts
@@@ -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)
}
}
})()
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex ee6f00a..a70d4c7 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -63,6 +63,8 @@ export type ResolvedConnectionIdentity = TicketIdentity
export 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,
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 66d3a0e..4690624 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -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 {
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 3b48623..d3b9e73 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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_PACK
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(
(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', {
ddiff --git a/apps/server/src/platform/inventoryRewards.test.ts b/apps/server/src/platform/inventoryRewards.test.ts
nnew file mode 100644
iindex 0000000..7688862
---- /dev/null
++++ b/apps/server/src/platform/inventoryRewards.test.ts
@@@ -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,
++ })
++ })
++})
ddiff --git a/apps/server/src/platform/inventoryRewards.ts b/apps/server/src/platform/inventoryRewards.ts
nnew file mode 100644
iindex 0000000..ecbe922
---- /dev/null
++++ b/apps/server/src/platform/inventoryRewards.ts
@@@ -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
++ }
++}
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex 5ba7a52..d296edf 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -45,6 +45,7 @@ export interface ContentPackV2 {
id: string
name: string
description: string
++ reward?: { item: string; name: string }
criterion:
| { event: 'player_joined' }
| { event: 'haul_recorded'; minScore?: number }
@@@ -60,6 +61,7 @@ export interface ContentPackV2 {
id: string
name: string
description: string
++ reward?: { item: string; name: string }
criterion: { event: 'round_finished'; minScore?: number; minRank?: number }
}[]
}
ddiff --git a/packages/content/src/packs/salvageRush.ts b/packages/content/src/packs/salvageRush.ts
iindex 1d63392..b83712e 100644
---- a/packages/content/src/packs/salvageRush.ts
++++ b/packages/content/src/packs/salvageRush.ts
@@@ -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 },
ddiff --git a/packages/content/src/packs/scraplandia.ts b/packages/content/src/packs/scraplandia.ts
iindex fb2cac3..e9f4a2d 100644
---- a/packages/content/src/packs/scraplandia.ts
++++ b/packages/content/src/packs/scraplandia.ts
@@@ -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 },
ddiff --git a/packages/persistence/migrations/0006_inventory_rewards.sql b/packages/persistence/migrations/0006_inventory_rewards.sql
nnew file mode 100644
iindex 0000000..15088e2
---- /dev/null
++++ b/packages/persistence/migrations/0006_inventory_rewards.sql
@@@ -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';
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex 349bad3..33a7a77 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -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 END
FROM 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`,
ddiff --git a/test/apps__server__src__platform__inventoryRewards.test.js b/test/apps__server__src__platform__inventoryRewards.test.js
nnew file mode 100644
iindex 0000000..118f967
---- /dev/null
++++ b/test/apps__server__src__platform__inventoryRewards.test.js
@@@ -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.