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
Changes
@@@ -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;@@@ -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 filennew file mode 100644@@@ -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 filennew file mode 100644@@@ -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 filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=progressSummary.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -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@@@ -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.sizethis.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.eventsif (recorder) {// Baseline = what the database holds for this character (nothing for a@@@ -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 []nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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++ }++}
@@ -4,6 +4,7 @@ import { type ClientMessage } from '@openvibe/protocol';
@@ -4,6 +4,7 @@ import { type ClientMessage } from '@openvibe/protocol';
import { type Logger } from '@openvibe/shared';
import { type Logger } from '@openvibe/shared';
import type { ServerConfig } from '../config.js';
import type { ServerConfig } from '../config.js';
import type { GameEventRecorder } from '../platform/gameEvents.js';
import type { GameEventRecorder } from '../platform/gameEvents.js';
+import type { ProgressSummaryWriter } from '../platform/progressSummary.js';
import type { ModRuntime } from '../mods/runtime.js';
import type { ModRuntime } from '../mods/runtime.js';
import type { ServerMetrics } from '../observability/metrics.js';
import type { ServerMetrics } from '../observability/metrics.js';
import type { GameWorld } from './gameWorld.js';
import type { GameWorld } from './gameWorld.js';
@@ -26,6 +27,8 @@ export interface GameIntegrations {
@@ -26,6 +27,8 @@ export interface GameIntegrations {
events?: GameEventRecorder;
events?: GameEventRecorder;
/** Installed mods (content packs), reconciled every tick. */
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime;
mods?: ModRuntime;
+ /** The person's games.progress.summary user module on Network, written when a character leaves. */
+ progressSummary?: ProgressSummaryWriter;
}
}
export declare class GameServer {
export declare class GameServer {
private readonly config;
private readonly config;
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=progressSummary.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
@@ -70,6 +70,7 @@ import type { ServerConfig } from '../config.js'
@@ -70,6 +70,7 @@ import type { ServerConfig } from '../config.js'
import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
import { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
import { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.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 type { ModHost, ModRuntime } from '../mods/runtime.js'
import { worldSpawn } from '@openvibe/content'
import { worldSpawn } from '@openvibe/content'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
@@ -118,6 +119,8 @@ export interface GameIntegrations {
@@ -118,6 +119,8 @@ export interface GameIntegrations {
events?: GameEventRecorder
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime
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. */
/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
@@ -1201,6 +1204,12 @@ export class GameServer {
@@ -1201,6 +1204,12 @@ export class GameServer {
this.world.entities.remove(session.entityId)
this.world.entities.remove(session.entityId)
this.world.spatial.remove(session.entityId)
this.world.spatial.remove(session.entityId)
this.savePlayer(session, true)
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.broadcastDespawn(session.entityId)
this.metrics.sessions = this.sessions.size
this.metrics.sessions = this.sessions.size
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
@@ -1253,8 +1262,10 @@ export class GameServer {
@@ -1253,8 +1262,10 @@ export class GameServer {
// account in its first free slot, once.
// account in its first free slot, once.
if (subjectId && isGuestToken(msg.token)) {
if (subjectId && isGuestToken(msg.token)) {
const g = this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
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 {
} else {
// A guest token may never look like an account key (`usr_…`,
// A guest token may never look like an account key (`usr_…`,
@@ -1381,6 +1392,7 @@ export class GameServer {
@@ -1381,6 +1392,7 @@ export class GameServer {
this.send(session, this.timeWire())
this.send(session, this.timeWire())
this.sendReputation(session)
this.sendReputation(session)
this.metrics.sessions = this.sessions.size
this.metrics.sessions = this.sessions.size
+ this.integrations.progressSummary?.playerJoined(session.playerId as string)
const recorder = this.integrations.events
const recorder = this.integrations.events
if (recorder) {
if (recorder) {
// Baseline = what the database holds for this character (nothing for a
// Baseline = what the database holds for this character (nothing for a
@@ -25,6 +25,7 @@ import { ModRegistry } from './mods/registry.js'
@@ -25,6 +25,7 @@ import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
+import { ProgressSummaryWriter } from './platform/progressSummary.js'
import {
import {
EVENT_SOURCE,
EVENT_SOURCE,
GameEventRecorder,
GameEventRecorder,
@@ -34,7 +35,10 @@ import {
@@ -34,7 +35,10 @@ import {
} from './platform/gameEvents.js'
} from './platform/gameEvents.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 { createRevocationEvents, ensureRevocationSubscription } from './platform/revocationEvents.js'
+import {
+ createRevocationEvents,
+ ensureRevocationSubscription,
+} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
/**
/**
@@ -153,6 +157,15 @@ async function main(): Promise<void> {
@@ -153,6 +157,15 @@ async function main(): Promise<void> {
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
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
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
@@ -163,7 +176,12 @@ async function main(): Promise<void> {
@@ -163,7 +176,12 @@ async function main(): Promise<void> {
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
log: platformLog,
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 => {
const subscribe = (attempt: number): void => {
ensureRevocationSubscription({
ensureRevocationSubscription({
eventsUrl: config.platform.eventsUrl as string,
eventsUrl: config.platform.eventsUrl as string,
@@ -172,7 +190,10 @@ async function main(): Promise<void> {
@@ -172,7 +190,10 @@ async function main(): Promise<void> {
tokens: platform.tokens,
tokens: platform.tokens,
log: platformLog,
log: platformLog,
}).catch((err: unknown) => {
}).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()
if (attempt < 5) setTimeout(() => subscribe(attempt + 1), 60_000 * attempt).unref()
})
})
}
}
@@ -227,7 +248,8 @@ async function main(): Promise<void> {
@@ -227,7 +248,8 @@ async function main(): Promise<void> {
account = resolved.key
account = resolved.key
// Guest conversion (WS-B task 8): this browser's guest character shows up in the account's
// 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.
// 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)) {
} else if (!isGuestToken(token)) {
// Never list an account key's characters for a guest query.
// Never list an account key's characters for a guest query.
return []
return []
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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
+ }
+}