openvibers/openvibe.games Commits

Guest conversion (WS-B task 8): the character a browser played as a guest moves into the account that signs in on it, into the first free slot, once (remembered by a hash of the guest token, never the token); it happens at the character list and at join, and a full account keeps the guest character where it was. Tests for the repository and the guest-then-sign-in flow

OP OpenVibers committed 24 Sept 2026 · e307eaa2d8 · 7 files, +93 −2


Changes

ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 23734ee..069eabb 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":"AAAA,OAAO,EAyBL,aAAa,EAkBd,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,uBAAuB,CAAA;AAExE,OAAO,EAIL,KAAK,aAAa,EAGnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAUL,KAAK,MAAM,EAEZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,KAAK,EAAe,iBAAiB,EAAoB,MAAM,2BAA2B,CAAA;AACjG,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAa/C,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1C;AAKD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,6DAA6D;IAC7D,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB;AAcD,qBAAa,UAAU;IA2BnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,YAAY;IA/B/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoB;IAClD,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA0D;IAC9F,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkB;IAC9C,+EAA+E;IAC/E,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,aAAa,CAAI;IACzB,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAkB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,gBAAwB;IACxC,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,mEAAmE;IACnE,OAAO,CAAC,MAAM,CAAiC;gBAG5B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,YAAY,GAAE,gBAAqB;IA8FtD,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiBrB,SAAS,CAAC,IAAI,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,IAAI;IA6dzD,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgE;IAE5F,6DAA6D;IAC7D,OAAO,CAAC,OAAO;IAgCf,OAAO,CAAC,cAAc;IAMtB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAqEpB,iDAAiD;IACjD,OAAO,CAAC,SAAS;IA+DjB,OAAO,CAAC,QAAQ;IAgChB,OAAO,CAAC,cAAc;IAYtB,uEAAuE;IACvE,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,QAAQ;IAIhB;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IA6JtB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;YA4B1B,WAAW;IA0LzB,OAAO,CAAC,aAAa;IAoFrB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAUnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAyBnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAiDnB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IA6FlB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAyCpB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IAmCxB,mEAAmE;IACnE,OAAO,CAAC,eAAe;IAyBvB,qEAAqE;IACrE,OAAO,CAAC,cAAc;IAmEtB;+DAC2D;IAC3D,OAAO,CAAC,eAAe;IAmBvB,wEAAwE;IACxE,OAAO,CAAC,WAAW;IAiCnB;;;OAGG;IACH,OAAO,CAAC,aAAa;IAyBrB,yEAAyE;IACzE,OAAO,CAAC,gBAAgB;IAgDxB,OAAO,CAAC,QAAQ;IAehB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IA8FpB,mEAAmE;IACnE,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,SAAS;IAajB,IAAI,IAAI,IAAI;IA6KZ,OAAO,CAAC,mBAAmB;IA+E3B,OAAO,CAAC,SAAS;IAyCjB;;;OAGG;IACH,KAAK,CAAC,MAAM,GAAE,YAAY,GAAG,UAAyB,GAAG,IAAI;IAiC7D,6BAA6B;IAC7B,QAAQ,IAAI,IAAI;IAMhB,6FAA6F;IAC7F,OAAO,CAAC,UAAU;IAclB;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAqBvB;IAED,OAAO,CAAC,WAAW;IAyBnB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,cAAc;IAkBtB,0EAA0E;IAC1E,OAAO,CAAC,wBAAwB;IAUhC,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAO1B,sEAAsE;IACtE;;;;OAIG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM;IAiB9D,kBAAkB,IAAI,IAAI;IAK1B,yFAAyF;IACzF,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,WAAW,CAAC,MAAM,CAAC;CAG9D"}
\\ 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/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 2db9891..82584ad 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -1249,6 +1249,13 @@ export class GameServer {
subjectId = account.subjectId
authIat = tokenIat(msg.auth)
rank = user.rank
++ // Guest conversion (WS-B task 8): the character this browser played as a guest joins the
++ // 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 })
++ }
} else {
// A guest token may never look like an account key (`usr_…`,
// `ovn:…`): that would open someone else's characters. The client
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex b2f6e42..f786e15 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -301,6 +301,26 @@ describe('Games on platform identity, events and mods (real server, restart)', (
b.dispose()
}, 60_000)
++ it('a guest who signs in keeps their character (guest conversion, WS-B task 8)', async () => {
++ const c = boot(platform)
++ const guest = connect(c.game)
++ const played = await guest.hello('guestconv77')
++ expect(played.welcome).toBeDefined()
++ const guestPlayer = (played.welcome as { playerId: string }).playerId
++ c.game.shutdown()
++ await c.outbox.stop()
++ c.dispose()
++ const d = boot(platform)
++ const signedIn = connect(d.game)
++ expect((await signedIn.hello('guestconv77', 'tok-ana')).welcome).toBeDefined()
++ const mine = d.store.players.listByToken(SUBJECT)
++ expect(mine.some((p) => p.id === guestPlayer)).toBe(true)
++ expect(d.store.players.listByToken('guestconv77')).toEqual([])
++ d.game.shutdown()
++ await d.outbox.stop()
++ d.dispose()
++ }, 60_000)
++
it('a sign-out everywhere closes the signed-in session, never a guest (WS-B task 4)', async () => {
const c = boot(platform)
const ana = connect(c.game)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 70638e0..54e1d7d 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -223,7 +223,11 @@ async function main(): Promise<void> {
if (auth) {
const user = await resolveNetworkUser(config.networkAuthUrl, auth)
if (!user) return []
-- account = accountForNetworkUser(store, user, Date.now()).key
++ const resolved = accountForNetworkUser(store, user, Date.now())
++ 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())
} else if (!isGuestToken(token)) {
// Never list an account key's characters for a guest query.
return []
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 0633fd7..f55f51e 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -70,6 +70,17 @@ export interface IdentityRepository {
): { moved: number; conflicts: number }
/** The subject a legacy key was adopted into, if any. */
subjectForLegacy(legacyKey: string): string | null
++ /**
++ * Guest conversion (roadmap WS-B task 8): the character a browser played as a guest moves into the
++ * account that signs in on it, into the first free slot (0..2). Once per guest token (remembered by
++ * a hash of it, never the token); `moved` 0 when there is nothing to move, it was already adopted,
++ * or every slot is taken (`full`).
++ */
++ adoptGuestCharacter(
++ guestToken: string,
++ subjectId: string,
++ now: number,
++ ): { moved: number; slot: number | null; full: boolean }
/** Distinct account keys under a legacy prefix that still own characters. */
legacyAccountKeys(prefix: string): string[]
}
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.test.ts b/packages/persistence/src/sqlite/platformRepositories.test.ts
iindex 66050a6..a2b3c06 100644
---- a/packages/persistence/src/sqlite/platformRepositories.test.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.test.ts
@@@ -35,6 +35,30 @@ function player(
}
}
++describe('guest conversion (WS-B task 8)', () => {
++ it('moves the guest character into the first free slot, once, keyed by a hash of the token', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([player('g1', 'guest00042abc', 0), player('a0', SUBJECT, 0)])
++ expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 10)).toEqual({ moved: 1, slot: 1, full: false })
++ expect(store.players.listByToken(SUBJECT).map((p) => [p.id, p.charSlot]).sort()).toEqual([['a0', 0], ['g1', 1]])
++ expect(store.players.listByToken('guest00042abc')).toEqual([])
++ expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 11)).toEqual({ moved: 0, slot: null, full: false })
++ const keys = (store.db.prepare('SELECT legacy_key FROM identity_legacy_map').all() as { legacy_key: string }[]).map((r) => r.legacy_key)
++ expect(keys.some((k) => k.includes('guest00042abc'))).toBe(false)
++ expect(keys.some((k) => /^guest:[0-9a-f]{32}$/.test(k))).toBe(true)
++ store.close()
++ })
++
++ it('keeps the guest character when every slot is taken, and does nothing without one', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([player('a0', SUBJECT, 0), player('a1', SUBJECT, 1), player('a2', SUBJECT, 2), player('g1', 'guestfull01', 0)])
++ expect(store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({ moved: 0, slot: null, full: true })
++ expect(store.players.listByToken('guestfull01').length).toBe(1)
++ expect(store.identity.adoptGuestCharacter('nobodyhere01', SUBJECT, 1)).toEqual({ moved: 0, slot: null, full: false })
++ store.close()
++ })
++})
++
describe('identity repository (legacy -> canonical subject)', () => {
it('moves every legacy character to the subject once and records the map', () => {
const store = openSqliteStore(':memory:')
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.ts b/packages/persistence/src/sqlite/platformRepositories.ts
iindex 1c89a34..68068a5 100644
---- a/packages/persistence/src/sqlite/platformRepositories.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.ts
@@@ -1,3 +1,4 @@
++import { createHash } from 'node:crypto'
import type Database from 'better-sqlite3'
import type {
MediaMirrorDto,
@@@ -62,9 +63,33 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
},
)
++ const moveToSlot = db.prepare('UPDATE players SET token = ?, subject_id = ?, char_slot = ? WHERE id = ?')
++ const adoptGuest = db.transaction((guestToken: string, subjectId: string, now: number) => {
++ const key = `guest:${createHash('sha256').update(guestToken).digest('hex').slice(0, 32)}`
++ const none = { moved: 0, slot: null, full: false }
++ if (mapped.get(key)) return none
++ const rows = legacyRows.all(guestToken)
++ if (rows.length === 0) return none
++ const taken = new Set(takenSlots.all(subjectId).map((r) => r.char_slot))
++ let moved = 0
++ let slot: number | null = null
++ for (const row of rows) {
++ const free = [0, 1, 2].find((s) => !taken.has(s))
++ if (free === undefined) break
++ moveToSlot.run(subjectId, subjectId, free, row.id)
++ taken.add(free)
++ slot ??= free
++ moved++
++ }
++ if (moved === 0) return { moved: 0, slot: null, full: true }
++ recordMap.run(key, subjectId, 'guest', moved, rows.length - moved, now)
++ return { moved, slot, full: false }
++ })
++
return {
adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
adopt(legacyKey, subjectId, source, now),
++ adoptGuestCharacter: (guestToken, subjectId, now) => adoptGuest(guestToken, subjectId, now),
subjectForLegacy(legacyKey) {
return mapped.get(legacyKey)?.subject_id ?? null
},