openvibers/openvibe.games Commits

games.progress.summary on Network (Contracts user module, WS-B task 9): when a signed-in character leaves, Games folds the session into the person's summary (best total skill level, playtime, last world; achievements never written: Scraplandia has none) as the owning service, read-modify-write with If-Match; guests have no record and Network being down only logs

OP OpenVibers committed 25 Sept 2026 · 091fdd6192 · 10 files, +304 −7


Changes

ddiff --git a/apps/server/dist-types/game/gameServer.d.ts b/apps/server/dist-types/game/gameServer.d.ts
iindex 00c2233..d59f9a1 100644
---- a/apps/server/dist-types/game/gameServer.d.ts
++++ b/apps/server/dist-types/game/gameServer.d.ts
@@@ -4,6 +4,7 @@ import { type ClientMessage } from '@openvibe/protocol';
import { type Logger } from '@openvibe/shared';
import type { ServerConfig } from '../config.js';
import type { GameEventRecorder } from '../platform/gameEvents.js';
++import type { ProgressSummaryWriter } from '../platform/progressSummary.js';
import type { ModRuntime } from '../mods/runtime.js';
import type { ServerMetrics } from '../observability/metrics.js';
import type { GameWorld } from './gameWorld.js';
@@@ -26,6 +27,8 @@ export interface GameIntegrations {
events?: GameEventRecorder;
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime;
++ /** The person's games.progress.summary user module on Network, written when a character leaves. */
++ progressSummary?: ProgressSummaryWriter;
}
export declare class GameServer {
private readonly config;
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 069eabb..626bb07 100644
---- a/apps/server/dist-types/game/gameServer.d.ts.map
++++ b/apps/server/dist-types/game/gameServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.d.ts b/apps/server/dist-types/platform/progressSummary.d.ts
nnew file mode 100644
iindex 0000000..cc8063b
---- /dev/null
++++ b/apps/server/dist-types/platform/progressSummary.d.ts
@@@ -0,0 +1,33 @@
++import type { OpenVibeClient } from 'openvibe-sdk/core';
++export declare const NAMESPACE = "games.progress.summary";
++export interface ProgressSummary {
++ level?: number;
++ achievements?: number;
++ playtime_hours?: number;
++ last_world?: string;
++}
++export interface SessionOutcome {
++ levels: Record<string, number>;
++ sessionSeconds: number;
++ world: string;
++}
++/** The next record from the one stored and a finished session. */
++export declare function mergeSummary(prev: ProgressSummary | undefined, s: SessionOutcome): ProgressSummary;
++interface Log {
++ warn(msg: string, fields?: Record<string, unknown>): void;
++}
++export declare class ProgressSummaryWriter {
++ private readonly log;
++ private readonly now;
++ private readonly joined;
++ private readonly modules;
++ private inflight;
++ constructor(client: OpenVibeClient, log: Log, now?: () => number);
++ /** A character entered the world (its session starts counting). */
++ playerJoined(playerId: string): void;
++ /** A character left: fold the session into the person's summary. Resolves when written (or given up). */
++ playerLeft(playerId: string, subjectId: string | null, levels: Record<string, number>, world: string): Promise<void>;
++ pending(): number;
++}
++export {};
++//# sourceMappingURL=progressSummary.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.d.ts.map b/apps/server/dist-types/platform/progressSummary.d.ts.map
nnew file mode 100644
iindex 0000000..4f74c07
---- /dev/null
++++ b/apps/server/dist-types/platform/progressSummary.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"progressSummary.d.ts","sourceRoot":"","sources":["../../src/platform/progressSummary.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAEvD,eAAO,MAAM,SAAS,2BAA2B,CAAA;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,cAAc,EAAE,MAAM,CAAA;IACtB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,kEAAkE;AAClE,wBAAgB,YAAY,CAC1B,IAAI,EAAE,eAAe,GAAG,SAAS,EACjC,CAAC,EAAE,cAAc,GAChB,eAAe,CAUjB;AAED,UAAU,GAAG;IACX,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1D;AAED,qBAAa,qBAAqB;IAO9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsD;IAC9E,OAAO,CAAC,QAAQ,CAAI;gBAGlB,MAAM,EAAE,cAAc,EACL,GAAG,EAAE,GAAG,EACR,GAAG,GAAE,MAAM,MAAiB;IAK/C,mEAAmE;IACnE,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAIpC,yGAAyG;IACzG,UAAU,CACR,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;IA0BhB,OAAO,IAAI,MAAM;CAGlB"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.test.d.ts b/apps/server/dist-types/platform/progressSummary.test.d.ts
nnew file mode 100644
iindex 0000000..dd6bdf8
---- /dev/null
++++ b/apps/server/dist-types/platform/progressSummary.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=progressSummary.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.test.d.ts.map b/apps/server/dist-types/platform/progressSummary.test.d.ts.map
nnew file mode 100644
iindex 0000000..ee0c2d1
---- /dev/null
++++ b/apps/server/dist-types/platform/progressSummary.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"progressSummary.test.d.ts","sourceRoot":"","sources":["../../src/platform/progressSummary.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 82584ad..736286a 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -70,6 +70,7 @@ import type { ServerConfig } from '../config.js'
import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
import { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
++import type { ProgressSummaryWriter } from '../platform/progressSummary.js'
import type { ModHost, ModRuntime } from '../mods/runtime.js'
import { worldSpawn } from '@openvibe/content'
import type { ServerMetrics } from '../observability/metrics.js'
@@@ -118,6 +119,8 @@ export interface GameIntegrations {
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime
++ /** The person's games.progress.summary user module on Network, written when a character leaves. */
++ progressSummary?: ProgressSummaryWriter
}
/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
@@@ -1201,6 +1204,12 @@ export class GameServer {
this.world.entities.remove(session.entityId)
this.world.spatial.remove(session.entityId)
this.savePlayer(session, true)
++ void this.integrations.progressSummary?.playerLeft(
++ session.playerId as string,
++ session.subjectId,
++ sessionProgress(session).levels,
++ this.world.content.world.id,
++ )
this.broadcastDespawn(session.entityId)
this.metrics.sessions = this.sessions.size
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
@@@ -1253,8 +1262,10 @@ export class GameServer {
// account in its first free slot, once.
if (subjectId && isGuestToken(msg.token)) {
const g = this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
-- if (g.moved > 0) this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
-- else if (g.full) this.log.info('guest character kept: account slots full', { subject: subjectId })
++ if (g.moved > 0)
++ this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
++ else if (g.full)
++ this.log.info('guest character kept: account slots full', { subject: subjectId })
}
} else {
// A guest token may never look like an account key (`usr_…`,
@@@ -1381,6 +1392,7 @@ export class GameServer {
this.send(session, this.timeWire())
this.sendReputation(session)
this.metrics.sessions = this.sessions.size
++ this.integrations.progressSummary?.playerJoined(session.playerId as string)
const recorder = this.integrations.events
if (recorder) {
// Baseline = what the database holds for this character (nothing for a
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 54e1d7d..33ff63b 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -25,6 +25,7 @@ import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
++import { ProgressSummaryWriter } from './platform/progressSummary.js'
import {
EVENT_SOURCE,
GameEventRecorder,
@@@ -34,7 +35,10 @@ import {
} from './platform/gameEvents.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { createPlatformClient } from './platform/serviceClient.js'
--import { createRevocationEvents, ensureRevocationSubscription } from './platform/revocationEvents.js'
++import {
++ createRevocationEvents,
++ ensureRevocationSubscription,
++} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
/**
@@@ -153,6 +157,15 @@ async function main(): Promise<void> {
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
++ // games.progress.summary on Network (WS-B task 9): needs the games principal.
++ ...(platform
++ ? {
++ progressSummary: new ProgressSummaryWriter(
++ platform.client,
++ platformLog.child({ system: 'progress-summary' }),
++ ),
++ }
++ : {}),
})
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
@@@ -163,7 +176,12 @@ async function main(): Promise<void> {
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
log: platformLog,
})
-- if (platform && config.platform.eventsUrl && config.platform.eventsSecrets[0] && config.platform.eventsEndpoint) {
++ if (
++ platform &&
++ config.platform.eventsUrl &&
++ config.platform.eventsSecrets[0] &&
++ config.platform.eventsEndpoint
++ ) {
const subscribe = (attempt: number): void => {
ensureRevocationSubscription({
eventsUrl: config.platform.eventsUrl as string,
@@@ -172,7 +190,10 @@ async function main(): Promise<void> {
tokens: platform.tokens,
log: platformLog,
}).catch((err: unknown) => {
-- platformLog.warn('events subscription not ready (will retry)', { error: String((err as Error)?.message ?? err), attempt })
++ platformLog.warn('events subscription not ready (will retry)', {
++ error: String((err as Error)?.message ?? err),
++ attempt,
++ })
if (attempt < 5) setTimeout(() => subscribe(attempt + 1), 60_000 * attempt).unref()
})
}
@@@ -227,7 +248,8 @@ async function main(): Promise<void> {
account = resolved.key
// Guest conversion (WS-B task 8): this browser's guest character shows up in the account's
// list (and moves there) the moment it signs in, before any slot is picked.
-- if (resolved.subjectId && isGuestToken(token)) store.identity.adoptGuestCharacter(token, resolved.subjectId, Date.now())
++ if (resolved.subjectId && isGuestToken(token))
++ store.identity.adoptGuestCharacter(token, resolved.subjectId, Date.now())
} else if (!isGuestToken(token)) {
// Never list an account key's characters for a guest query.
return []
ddiff --git a/apps/server/src/platform/progressSummary.test.ts b/apps/server/src/platform/progressSummary.test.ts
nnew file mode 100644
iindex 0000000..a88e287
---- /dev/null
++++ b/apps/server/src/platform/progressSummary.test.ts
@@@ -0,0 +1,116 @@
++import { describe, expect, it } from 'vitest'
++import { OpenVibeError, type OpenVibeClient } from 'openvibe-sdk/core'
++import { mergeSummary, NAMESPACE, ProgressSummaryWriter } from './progressSummary.js'
++
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'
++
++/** A stub Network: one record per (namespace, subject) with revisions and If-Match, like the real one. */
++function stubNetwork(opts: { conflictOnce?: boolean } = {}) {
++ const records = new Map<string, { data: Record<string, unknown>; revision: number }>()
++ const calls: string[] = []
++ let conflict = opts.conflictOnce ?? false
++ const client = {
++ async json(o: {
++ method?: string
++ path: string
++ json?: { data: Record<string, unknown> }
++ headers?: Record<string, string | undefined>
++ }) {
++ const m = o.path.match(/^\/internal\/modules\/([^/]+)\/([^/]+)$/)
++ if (!m) throw new Error(`unexpected ${o.path}`)
++ const key = `${decodeURIComponent(m[1] as string)}|${decodeURIComponent(m[2] as string)}`
++ const method = o.method ?? 'GET'
++ calls.push(`${method} ${key}`)
++ const rec = records.get(key)
++ if (method === 'GET') {
++ if (!rec)
++ throw new OpenVibeError({ status: 404, code: 'modules.not_found', message: 'none' })
++ return { ...rec }
++ }
++ const want = o.headers?.['If-Match']
++ if (conflict) {
++ conflict = false
++ records.set(key, {
++ data: { level: 99, playtime_hours: 1 },
++ revision: (rec?.revision ?? 0) + 1,
++ })
++ throw new OpenVibeError({
++ status: 412,
++ code: 'modules.revision_conflict',
++ message: 'moved',
++ })
++ }
++ if (want !== undefined && want !== `"${rec?.revision ?? 0}"`)
++ throw new OpenVibeError({
++ status: 412,
++ code: 'modules.revision_conflict',
++ message: 'moved',
++ })
++ const next = { data: o.json?.data ?? {}, revision: (rec?.revision ?? 0) + 1 }
++ records.set(key, next)
++ return next
++ },
++ }
++ return { client: client as unknown as OpenVibeClient, records, calls }
++}
++
++const quiet = { warn: () => {} }
++
++describe('games.progress.summary', () => {
++ it('merges a session: the best total level, accumulated playtime, the last world', () => {
++ expect(
++ mergeSummary(undefined, {
++ levels: { mining: 3, crafting: 2 },
++ sessionSeconds: 1800,
++ world: 'scraplandia',
++ }),
++ ).toEqual({ level: 5, playtime_hours: 0.5, last_world: 'scraplandia' })
++ expect(
++ mergeSummary(
++ { level: 9, playtime_hours: 2, achievements: 4 },
++ { levels: { mining: 3 }, sessionSeconds: 360, world: 'w' },
++ ),
++ ).toEqual({ level: 9, playtime_hours: 2.1, last_world: 'w', achievements: 4 })
++ })
++
++ it('writes when a signed-in character leaves, and nothing for guests', async () => {
++ let t = 1_000_000
++ const net = stubNetwork()
++ const w = new ProgressSummaryWriter(net.client, quiet, () => t)
++ w.playerJoined('p1')
++ t += 2 * 3600_000
++ await w.playerLeft('p1', ANN, { mining: 4, salvage: 6 }, 'scraplandia')
++ expect(net.records.get(`${NAMESPACE}|${ANN}`)).toEqual({
++ data: { level: 10, playtime_hours: 2, last_world: 'scraplandia' },
++ revision: 1,
++ })
++ w.playerJoined('p2')
++ await w.playerLeft('p2', 'gst_01JAB2C3D4E5F6G7H8J9K0MNPG', { mining: 50 }, 'scraplandia')
++ await w.playerLeft('p3', null, { mining: 50 }, 'scraplandia')
++ expect(net.calls.filter((c) => c.startsWith('PUT')).length).toBe(1)
++ })
++
++ it('reads again when another writer moved the record', async () => {
++ const net = stubNetwork({ conflictOnce: true })
++ const w = new ProgressSummaryWriter(net.client, quiet, () => 0)
++ await w.playerLeft('p1', ANN, { mining: 1 }, 'scraplandia')
++ expect(net.records.get(`${NAMESPACE}|${ANN}`)?.data).toEqual({
++ level: 99,
++ playtime_hours: 1,
++ last_world: 'scraplandia',
++ })
++ })
++
++ it('never throws when Network is down', async () => {
++ const warnings: string[] = []
++ const client = {
++ json: async () => {
++ throw new OpenVibeError({ status: 503, code: 'network.unavailable', message: 'down' })
++ },
++ } as unknown as OpenVibeClient
++ const w = new ProgressSummaryWriter(client, { warn: (m) => warnings.push(m) })
++ await expect(w.playerLeft('p1', ANN, { mining: 1 }, 'w')).resolves.toBeUndefined()
++ expect(warnings).toEqual(['progress summary not written'])
++ expect(w.pending()).toBe(0)
++ })
++})
ddiff --git a/apps/server/src/platform/progressSummary.ts b/apps/server/src/platform/progressSummary.ts
nnew file mode 100644
iindex 0000000..de7f37f
---- /dev/null
++++ b/apps/server/src/platform/progressSummary.ts
@@@ -0,0 +1,107 @@
++/**
++ * games.progress.summary on OpenVibe.Network (openvibe-contracts user-module namespace, roadmap WS-B
++ * task 9): a public summary of a signed-in person's progress, for other sites. The characters and
++ * their inventories stay Games' truth.
++ *
++ * level the best total skill level among the person's characters
++ * playtime_hours accumulated from sessions (joined → left), one decimal
++ * last_world the world they last played
++ *
++ * Scraplandia has no achievement system, so `achievements` is never written (an existing value is kept).
++ * Written when a character leaves, as the owning service (grant games network.modules.write on
++ * games.progress.summary), read-modify-write through openvibe-sdk/modules (412 → read again). Guests
++ * (gst_) and local players have no record. Network being down only logs: the next session catches up.
++ */
++import { createModulesClient } from 'openvibe-sdk/modules'
++import type { OpenVibeClient } from 'openvibe-sdk/core'
++
++export const NAMESPACE = 'games.progress.summary'
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
++
++export interface ProgressSummary {
++ level?: number
++ achievements?: number
++ playtime_hours?: number
++ last_world?: string
++}
++
++export interface SessionOutcome {
++ levels: Record<string, number>
++ sessionSeconds: number
++ world: string
++}
++
++/** The next record from the one stored and a finished session. */
++export function mergeSummary(
++ prev: ProgressSummary | undefined,
++ s: SessionOutcome,
++): ProgressSummary {
++ const total = Object.values(s.levels).reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
++ const hours = (prev?.playtime_hours ?? 0) + Math.max(0, s.sessionSeconds) / 3600
++ const out: ProgressSummary = {
++ level: Math.max(prev?.level ?? 0, Math.floor(total)),
++ playtime_hours: Math.round(hours * 10) / 10,
++ last_world: s.world.slice(0, 120),
++ }
++ if (typeof prev?.achievements === 'number') out.achievements = prev.achievements
++ return out
++}
++
++interface Log {
++ warn(msg: string, fields?: Record<string, unknown>): void
++}
++
++export class ProgressSummaryWriter {
++ private readonly joined = new Map<string, number>()
++ private readonly modules: ReturnType<typeof createModulesClient>['forSubject']
++ private inflight = 0
++
++ constructor(
++ client: OpenVibeClient,
++ private readonly log: Log,
++ private readonly now: () => number = Date.now,
++ ) {
++ this.modules = createModulesClient(client).forSubject
++ }
++
++ /** A character entered the world (its session starts counting). */
++ playerJoined(playerId: string): void {
++ this.joined.set(playerId, this.now())
++ }
++
++ /** A character left: fold the session into the person's summary. Resolves when written (or given up). */
++ playerLeft(
++ playerId: string,
++ subjectId: string | null,
++ levels: Record<string, number>,
++ world: string,
++ ): Promise<void> {
++ const since = this.joined.get(playerId)
++ this.joined.delete(playerId)
++ if (!subjectId || !USER_SUBJECT.test(subjectId)) return Promise.resolve()
++ const outcome: SessionOutcome = {
++ levels,
++ world,
++ sessionSeconds: since === undefined ? 0 : (this.now() - since) / 1000,
++ }
++ this.inflight++
++ return this.modules
++ .update(NAMESPACE, subjectId, (prev: ProgressSummary | undefined) =>
++ mergeSummary(prev, outcome),
++ )
++ .then(() => undefined)
++ .catch((err: unknown) => {
++ this.log.warn('progress summary not written', {
++ subject: subjectId,
++ error: String((err as Error)?.message ?? err),
++ })
++ })
++ .finally(() => {
++ this.inflight--
++ })
++ }
++
++ pending(): number {
++ return this.inflight
++ }
++}