openvibers/openvibe.games Commits
Characters are the game's, not a platform feature: no 3-slot cap, no sign-in lock (#158)
Changes
@@@ -1216,12 +1216,11 @@<section class="account"><div class="account-inner">-- <h2>One character as a guest. <b>Three</b> with an OpenVibe account.</h2>++ <h2>Play as a guest. <b>Keep everything</b> with an OpenVibe account.</h2><p>-- Jump into any game instantly as a guest — your character is tied to this browser and-- connection. Sign in with your <b>OpenVibe</b> account to unlock three character slots,-- keep progress safe across devices and IP changes, and carry one identity across-- openvibe.live, openvibe.tools and every game here.++ Jump into any game instantly as a guest: your characters live in this browser. Sign in++ with your <b>OpenVibe</b> account to keep them and your progress on every device, and to++ carry one identity across openvibe.live, openvibe.tools and every game here.</p><a class="signin" href="/auth/login?next=%2F">Sign in with OpenVibe</a><div class="fine">One account. All of OpenVibe.</div>@@@ -586,7 +586,7 @@ async function start(): Promise<void> {hud.setStatus('could not join — try signing in again from the home page')}} else if (msg.reason === 'guest_one_character') {-- hud.setStatus('guests get one character — sign in with OpenVibe for 3 slots')++ hud.setStatus('this server keeps one character per guest — sign in with OpenVibe to keep more')} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {localStorage.removeItem('ovg_sso')hud.setStatus(@@@ -1,10 +1,10 @@--import type { Appearance } from '@openvibe/protocol'++import { MAX_CHARACTERS, type Appearance } from '@openvibe/protocol'/**-- * MMO-style character select: an account token owns up to three characters.-- * Pick one to play, or claim an empty slot (which runs the customization-- * screen). Pure DOM overlay — resolves with the chosen slot and, for-- * existing characters, their saved identity.++ * Character select: your characters in this game, and a "New character" button. There is no slot count to unlock++ * (owner, 2026-10-27: the character slot restriction "shouldn't be one of the built-in features of the site"): signed in++ * or a guest, you keep as many as you like up to MAX_CHARACTERS, a technical ceiling. Signing in keeps them on every++ * device. Pure DOM overlay: resolves with the chosen slot and, for an existing character, its saved identity.*/export interface CharacterInfo {slot: number@@@ -23,25 +23,29 @@ export function characterSelect(const panel = document.createElement('div')panel.className = 'customize-panel'panel.innerHTML = '<h1>CHOOSE YOUR SCRAPPER</h1>'-- const maxSlots = authed ? 3 : 1-- for (let slot = 0; slot < 3; slot++) {-- const existing = characters.find((c) => c.slot === slot) ?? null-- const row = document.createElement('button')-- row.className = 'cust-btn char-slot'-- if (slot >= maxSlots && !existing) {-- row.classList.add('char-locked')-- row.innerHTML = `<b>🔒 Locked</b><span>slot ${slot + 1}</span>`-- row.disabled = true-- } else {-- row.innerHTML = existing-- ? `<b>${escapeHtml(existing.name)}</b><span>slot ${slot + 1}</span>`-- : `<b>+ New character</b><span>slot ${slot + 1}</span>`-- row.addEventListener('click', () => {-- overlay.remove()-- resolve({ slot, existing })-- })-- }-- panel.appendChild(row)++ const pick = (slot: number, existing: CharacterInfo | null) => {++ overlay.remove()++ resolve({ slot, existing })++ }++ const row = (label: string, sub: string, onClick: () => void) => {++ const b = document.createElement('button')++ b.type = 'button'++ b.className = 'cust-btn char-slot'++ const name = document.createElement('b')++ name.textContent = label++ const small = document.createElement('span')++ small.textContent = sub++ b.append(name, small)++ b.addEventListener('click', onClick)++ return b++ }++ const mine = [...characters].sort((a, b) => a.slot - b.slot)++ for (const c of mine) panel.appendChild(row(c.name, 'Play', () => pick(c.slot, c)))++ const used = new Set(mine.map((c) => c.slot))++ let free = 0++ while (free < MAX_CHARACTERS && used.has(free)) free++++ if (free < MAX_CHARACTERS) {++ panel.appendChild(row('+ New character', mine.length ? 'Start another scrapper' : 'Make your scrapper', () => pick(free, null)))}if (authed) {const bar = document.createElement('div')@@@ -61,10 +65,9 @@ export function characterSelect(if (!authed) {const upsell = document.createElement('div')upsell.className = 'char-upsell'-- upsell.innerHTML =-- 'Guests get <b>one</b> scrapper, tied to this connection — it can be lost if ' +-- 'your browser data and IP both change. Sign in to unlock <b>3 character slots</b> ' +-- 'and keep your progress safe forever.'++ upsell.textContent =++ 'As a guest, your characters live in this browser: they can be lost if its data is cleared. ' +++ 'Sign in to keep them, and your progress, on every device.'panel.appendChild(upsell)const btn = document.createElement('button')btn.className = 'cust-btn char-sso-btn'@@@ -78,7 +81,3 @@ export function characterSelect(uiRoot.appendChild(overlay)})}----function escapeHtml(s: string): string {-- return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)--}@@@ -162,7 +162,7 @@ export function customizeScreen(? '<div class="cust-guest-note">⚠️ You are creating a <b>guest</b> scrapper. ' +'It lives on this connection and can be lost. ' +'<a href="/auth/login">Sign in with OpenVibe</a> to save your progress ' +-- 'for good — and get 3 character slots.</div>'++ 'for good, on every device.</div>': ''}</div>@@@ -1197,10 +1197,6 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {font-size: calc(12px * var(--ov-text-scale, 1));}--.char-locked {-- opacity: 0.45;-- cursor: not-allowed;--}.char-upsell {margin-top: 14px;padding: 12px 14px;@@@ -52,7 +52,7 @@ export interface PlayerSession {* sign-out everywhere after it closes the session (network.user.token_valid_after).*/authIat: number | null-- /** Character slot under the account token (0..2). */++ /** Which of the account's characters this is (0 … MAX_CHARACTERS - 1). */charSlot: number/** openvibe.network rank; gates edit mode + moderation. */rank: 'owner' | 'admin' | 'moderator' | null@@@ -141,8 +141,8 @@ export class SessionsSystem implements System {let authIat: number | null = nulllet rank: 'owner' | 'admin' | 'moderator' | null = nullif ('subject' in id) {-- // A signed-in account is keyed by its canonical subject (3 slots,-- // follows you across devices).++ // A signed-in account is keyed by its canonical subject (its characters++ // follow it across devices).subjectId = id.subjecttoken = id.subjectauthIat = id.authIat@@@ -164,15 +164,10 @@ export class SessionsSystem implements System {ctx.log.info('guest character kept: account slots full', { subject: subjectId })}} else {-- // Guests get ONE character bound to the hashed guest key the ticket++ // A guest's characters are bound to the hashed guest key the ticket// carried. The raw token never reaches the server here: `guest:` +// sha256hex is exactly the account_key the repository stores, so// hello's `token` is ignored entirely (ADR-0007 decision 8).-- if (slot > 0) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))-- conn.close(4010, 'guest_one_character')-- return-- }token = `guest:${id.guestKeyHash}`}// A disconnect save of this character may still be queued: read after it lands, not before.@@@ -94,7 +94,7 @@ export const HOME_FAQ: readonly { q: string; a: string }[] = Object.freeze([},{q: 'Do I need an account?',-- a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account adds three character slots, keeps your progress across devices and puts your name on the leaderboards.',++ a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account keeps your characters and progress on every device and puts your name on the leaderboards.',},{q: 'What do achievements give me?',@@@ -357,7 +357,7 @@ const HOW: readonly { title: string; text: string }[] = [},{title: 'Sign in to keep it',-- text: 'An OpenVibe account adds three character slots, saves progress across devices and puts your name on the boards.',++ text: 'An OpenVibe account keeps your characters and progress on every device and puts your name on the boards.',},{title: 'Earn and build',@@@ -1,4 +1,5 @@import { describe, expect, it } from 'vitest'++import { MAX_CHARACTERS } from '@openvibe/protocol'import { sql, type Db } from 'openvibe-sdk/db'import type { PlayerDto } from '../dto.js'import { openTestStore } from '../testing.js'@@@ -67,9 +68,8 @@ describe('guest conversion (WS-B task 8)', () => {it('keeps the guest character when every slot is taken, and does nothing without one', async () => {const store = await openTestStore()await store.players.upsertMany([-- player('a0', SUBJECT, 0),-- player('a1', SUBJECT, 1),-- player('a2', SUBJECT, 2),++ // Every place an account has (MAX_CHARACTERS; three were all there was before 2026-10-27).++ ...Array.from({ length: MAX_CHARACTERS }, (_, i) => player(`a${i}`, SUBJECT, i)),player('g1', 'guestfull01', 0),])expect(await store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({@@@ -90,7 +90,7 @@ describe('account merge (ADR-029)', () => {const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', async () => {const store = await openTestStore()-- // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.++ // The survivor has slot 0; the folded-in account has slots 0, 1 and 2. Its 0 clashes and takes the next free place.await store.players.upsertMany([player('s0', SUBJECT, 0),player('f0', FROM, 0),@@@ -99,19 +99,31 @@ describe('account merge (ADR-029)', () => {])expect(await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),-- ).toEqual({ moved: 2, kept: 1, already: false })++ ).toEqual({ moved: 3, kept: 0, already: false })expect((await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),).toEqual([++ ['f0', 3],['f1', 1],['f2', 2],['s0', 0],])-- expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])++ expect((await store.players.listByToken(FROM)).length).toBe(0)expect(await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),).toEqual({ moved: 0, kept: 0, already: true })-- expect((await store.players.listByToken(FROM)).length).toBe(1)++ })++++ it('keeps on the folded-in account what does not fit a full survivor', async () => {++ const store = await openTestStore()++ await store.players.upsertMany([++ ...Array.from({ length: MAX_CHARACTERS }, (_, i) => player(`s${i}`, SUBJECT, i)),++ player('f0', FROM, 0),++ ])++ expect(++ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2H1', 5),++ ).toEqual({ moved: 0, kept: 1, already: false })++ expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])})})@@@ -1,3 +1,4 @@++import { MAX_CHARACTERS } from '@openvibe/protocol'import { sql, type Db } from 'openvibe-sdk/db'import { accountKey } from '../accountKey.js'import type {@@@ -14,6 +15,9 @@ import { idList } from './sqlUtils.js'import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'++/** The character places of one account, in order (protocol MAX_CHARACTERS). */++const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)++/*** PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry* and the Media mirror queue. The game simulation never touches these; apps/server's platform@@@ -120,7 +124,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {let moved = 0let slot: number | null = nullfor (const row of rows) {-- const free = [0, 1, 2].find((s) => !taken.has(s))++ const free = SLOTS.find((s) => !taken.has(s))if (free === undefined) breakawait moveToSlot(row.id, subjectId, subjectId, free)taken.add(free)@@@ -154,7 +158,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {}let kept = 0for (const row of clashing) {-- const slot = [0, 1, 2].find((s) => !taken.has(s))++ const slot = SLOTS.find((s) => !taken.has(s))if (slot === undefined) {kept++continue@@@ -2,6 +2,13 @@ import { z } from 'zod'import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'import { AppearanceSchema } from '../appearance.js'++/**++ * How many characters one account (or one guest browser) may keep in a game. Not a feature: a technical ceiling for the++ * slot index. The platform used to sell "3 character slots" for signing in (owner, 2026-10-27: "the character slot …++ * restriction thing … shouldn't be one of the built-in features of the site … more modular, open to modders").++ */++export const MAX_CHARACTERS = 24++/*** Client -> server messages, defined as zod schemas because the server must* treat every inbound byte as hostile. Types are inferred from schemas so@@@ -18,8 +25,8 @@ export const ClientHelloSchema = z.object({.optional(),/** Requested place. Older clients omit it and join the configured default. */place: z.string().min(1).max(64).optional(),-- /** Character slot under this account (MMO-style, 3 max). */-- slot: z.number().int().min(0).max(2).default(0),++ /** Which of this account's characters (0 … MAX_CHARACTERS - 1); a game, not the platform, decides how many it uses. */++ slot: z.number().int().min(0).max(MAX_CHARACTERS - 1).default(0),name: z.string().min(1).max(24),appearance: AppearanceSchema,})
@@ -1216,12 +1216,11 @@
@@ -1216,12 +1216,11 @@
<section class="account">
<section class="account">
<div class="account-inner">
<div class="account-inner">
- <h2>One character as a guest. <b>Three</b> with an OpenVibe account.</h2>
+ <h2>Play as a guest. <b>Keep everything</b> with an OpenVibe account.</h2>
<p>
<p>
- Jump into any game instantly as a guest — your character is tied to this browser and
- connection. Sign in with your <b>OpenVibe</b> account to unlock three character slots,
- keep progress safe across devices and IP changes, and carry one identity across
- openvibe.live, openvibe.tools and every game here.
+ Jump into any game instantly as a guest: your characters live in this browser. Sign in
+ with your <b>OpenVibe</b> account to keep them and your progress on every device, and to
+ carry one identity across openvibe.live, openvibe.tools and every game here.
</p>
</p>
<a class="signin" href="/auth/login?next=%2F">Sign in with OpenVibe</a>
<a class="signin" href="/auth/login?next=%2F">Sign in with OpenVibe</a>
<div class="fine">One account. All of OpenVibe.</div>
<div class="fine">One account. All of OpenVibe.</div>
@@ -586,7 +586,7 @@ async function start(): Promise<void> {
@@ -586,7 +586,7 @@ async function start(): Promise<void> {
hud.setStatus('could not join — try signing in again from the home page')
hud.setStatus('could not join — try signing in again from the home page')
}
}
} else if (msg.reason === 'guest_one_character') {
} else if (msg.reason === 'guest_one_character') {
- hud.setStatus('guests get one character — sign in with OpenVibe for 3 slots')
+ hud.setStatus('this server keeps one character per guest — sign in with OpenVibe to keep more')
} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {
} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {
localStorage.removeItem('ovg_sso')
localStorage.removeItem('ovg_sso')
hud.setStatus(
hud.setStatus(
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
-import type { Appearance } from '@openvibe/protocol'
+import { MAX_CHARACTERS, type Appearance } from '@openvibe/protocol'
/**
/**
- * MMO-style character select: an account token owns up to three characters.
- * Pick one to play, or claim an empty slot (which runs the customization
- * screen). Pure DOM overlay — resolves with the chosen slot and, for
- * existing characters, their saved identity.
+ * Character select: your characters in this game, and a "New character" button. There is no slot count to unlock
+ * (owner, 2026-10-27: the character slot restriction "shouldn't be one of the built-in features of the site"): signed in
+ * or a guest, you keep as many as you like up to MAX_CHARACTERS, a technical ceiling. Signing in keeps them on every
+ * device. Pure DOM overlay: resolves with the chosen slot and, for an existing character, its saved identity.
*/
*/
export interface CharacterInfo {
export interface CharacterInfo {
slot: number
slot: number
@@ -23,25 +23,29 @@ export function characterSelect(
@@ -23,25 +23,29 @@ export function characterSelect(
const panel = document.createElement('div')
const panel = document.createElement('div')
panel.className = 'customize-panel'
panel.className = 'customize-panel'
panel.innerHTML = '<h1>CHOOSE YOUR SCRAPPER</h1>'
panel.innerHTML = '<h1>CHOOSE YOUR SCRAPPER</h1>'
- const maxSlots = authed ? 3 : 1
- for (let slot = 0; slot < 3; slot++) {
- const existing = characters.find((c) => c.slot === slot) ?? null
- const row = document.createElement('button')
- row.className = 'cust-btn char-slot'
- if (slot >= maxSlots && !existing) {
- row.classList.add('char-locked')
- row.innerHTML = `<b>🔒 Locked</b><span>slot ${slot + 1}</span>`
- row.disabled = true
- } else {
- row.innerHTML = existing
- ? `<b>${escapeHtml(existing.name)}</b><span>slot ${slot + 1}</span>`
- : `<b>+ New character</b><span>slot ${slot + 1}</span>`
- row.addEventListener('click', () => {
- overlay.remove()
- resolve({ slot, existing })
- })
- }
- panel.appendChild(row)
+ const pick = (slot: number, existing: CharacterInfo | null) => {
+ overlay.remove()
+ resolve({ slot, existing })
+ }
+ const row = (label: string, sub: string, onClick: () => void) => {
+ const b = document.createElement('button')
+ b.type = 'button'
+ b.className = 'cust-btn char-slot'
+ const name = document.createElement('b')
+ name.textContent = label
+ const small = document.createElement('span')
+ small.textContent = sub
+ b.append(name, small)
+ b.addEventListener('click', onClick)
+ return b
+ }
+ const mine = [...characters].sort((a, b) => a.slot - b.slot)
+ for (const c of mine) panel.appendChild(row(c.name, 'Play', () => pick(c.slot, c)))
+ const used = new Set(mine.map((c) => c.slot))
+ let free = 0
+ while (free < MAX_CHARACTERS && used.has(free)) free++
+ if (free < MAX_CHARACTERS) {
+ panel.appendChild(row('+ New character', mine.length ? 'Start another scrapper' : 'Make your scrapper', () => pick(free, null)))
}
}
if (authed) {
if (authed) {
const bar = document.createElement('div')
const bar = document.createElement('div')
@@ -61,10 +65,9 @@ export function characterSelect(
@@ -61,10 +65,9 @@ export function characterSelect(
if (!authed) {
if (!authed) {
const upsell = document.createElement('div')
const upsell = document.createElement('div')
upsell.className = 'char-upsell'
upsell.className = 'char-upsell'
- upsell.innerHTML =
- 'Guests get <b>one</b> scrapper, tied to this connection — it can be lost if ' +
- 'your browser data and IP both change. Sign in to unlock <b>3 character slots</b> ' +
- 'and keep your progress safe forever.'
+ upsell.textContent =
+ 'As a guest, your characters live in this browser: they can be lost if its data is cleared. ' +
+ 'Sign in to keep them, and your progress, on every device.'
panel.appendChild(upsell)
panel.appendChild(upsell)
const btn = document.createElement('button')
const btn = document.createElement('button')
btn.className = 'cust-btn char-sso-btn'
btn.className = 'cust-btn char-sso-btn'
@@ -78,7 +81,3 @@ export function characterSelect(
@@ -78,7 +81,3 @@ export function characterSelect(
uiRoot.appendChild(overlay)
uiRoot.appendChild(overlay)
})
})
}
}
-
-function escapeHtml(s: string): string {
- return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
-}
@@ -162,7 +162,7 @@ export function customizeScreen(
@@ -162,7 +162,7 @@ export function customizeScreen(
? '<div class="cust-guest-note">⚠️ You are creating a <b>guest</b> scrapper. ' +
? '<div class="cust-guest-note">⚠️ You are creating a <b>guest</b> scrapper. ' +
'It lives on this connection and can be lost. ' +
'It lives on this connection and can be lost. ' +
'<a href="/auth/login">Sign in with OpenVibe</a> to save your progress ' +
'<a href="/auth/login">Sign in with OpenVibe</a> to save your progress ' +
- 'for good — and get 3 character slots.</div>'
+ 'for good, on every device.</div>'
: ''
: ''
}
}
</div>
</div>
@@ -1197,10 +1197,6 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1197,10 +1197,6 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
font-size: calc(12px * var(--ov-text-scale, 1));
font-size: calc(12px * var(--ov-text-scale, 1));
}
}
-.char-locked {
- opacity: 0.45;
- cursor: not-allowed;
-}
.char-upsell {
.char-upsell {
margin-top: 14px;
margin-top: 14px;
padding: 12px 14px;
padding: 12px 14px;
@@ -52,7 +52,7 @@ export interface PlayerSession {
@@ -52,7 +52,7 @@ export interface PlayerSession {
* sign-out everywhere after it closes the session (network.user.token_valid_after).
* sign-out everywhere after it closes the session (network.user.token_valid_after).
*/
*/
authIat: number | null
authIat: number | null
- /** Character slot under the account token (0..2). */
+ /** Which of the account's characters this is (0 … MAX_CHARACTERS - 1). */
charSlot: number
charSlot: number
/** openvibe.network rank; gates edit mode + moderation. */
/** openvibe.network rank; gates edit mode + moderation. */
rank: 'owner' | 'admin' | 'moderator' | null
rank: 'owner' | 'admin' | 'moderator' | null
@@ -141,8 +141,8 @@ export class SessionsSystem implements System {
@@ -141,8 +141,8 @@ export class SessionsSystem implements System {
let authIat: number | null = null
let authIat: number | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
if ('subject' in id) {
if ('subject' in id) {
- // A signed-in account is keyed by its canonical subject (3 slots,
- // follows you across devices).
+ // A signed-in account is keyed by its canonical subject (its characters
+ // follow it across devices).
subjectId = id.subject
subjectId = id.subject
token = id.subject
token = id.subject
authIat = id.authIat
authIat = id.authIat
@@ -164,15 +164,10 @@ export class SessionsSystem implements System {
@@ -164,15 +164,10 @@ export class SessionsSystem implements System {
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
}
}
} else {
} else {
- // Guests get ONE character bound to the hashed guest key the ticket
+ // A guest's characters are bound to the hashed guest key the ticket
// carried. The raw token never reaches the server here: `guest:` +
// carried. The raw token never reaches the server here: `guest:` +
// sha256hex is exactly the account_key the repository stores, so
// sha256hex is exactly the account_key the repository stores, so
// hello's `token` is ignored entirely (ADR-0007 decision 8).
// hello's `token` is ignored entirely (ADR-0007 decision 8).
- if (slot > 0) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
- conn.close(4010, 'guest_one_character')
- return
- }
token = `guest:${id.guestKeyHash}`
token = `guest:${id.guestKeyHash}`
}
}
// A disconnect save of this character may still be queued: read after it lands, not before.
// A disconnect save of this character may still be queued: read after it lands, not before.
@@ -94,7 +94,7 @@ export const HOME_FAQ: readonly { q: string; a: string }[] = Object.freeze([
@@ -94,7 +94,7 @@ export const HOME_FAQ: readonly { q: string; a: string }[] = Object.freeze([
},
},
{
{
q: 'Do I need an account?',
q: 'Do I need an account?',
- a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account adds three character slots, keeps your progress across devices and puts your name on the leaderboards.',
+ a: 'No. You can play as a guest with one character tied to this browser. An OpenVibe account keeps your characters and progress on every device and puts your name on the leaderboards.',
},
},
{
{
q: 'What do achievements give me?',
q: 'What do achievements give me?',
@@ -357,7 +357,7 @@ const HOW: readonly { title: string; text: string }[] = [
@@ -357,7 +357,7 @@ const HOW: readonly { title: string; text: string }[] = [
},
},
{
{
title: 'Sign in to keep it',
title: 'Sign in to keep it',
- text: 'An OpenVibe account adds three character slots, saves progress across devices and puts your name on the boards.',
+ text: 'An OpenVibe account keeps your characters and progress on every device and puts your name on the boards.',
},
},
{
{
title: 'Earn and build',
title: 'Earn and build',
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
+import { MAX_CHARACTERS } from '@openvibe/protocol'
import { sql, type Db } from 'openvibe-sdk/db'
import { sql, type Db } from 'openvibe-sdk/db'
import type { PlayerDto } from '../dto.js'
import type { PlayerDto } from '../dto.js'
import { openTestStore } from '../testing.js'
import { openTestStore } from '../testing.js'
@@ -67,9 +68,8 @@ describe('guest conversion (WS-B task 8)', () => {
@@ -67,9 +68,8 @@ describe('guest conversion (WS-B task 8)', () => {
it('keeps the guest character when every slot is taken, and does nothing without one', async () => {
it('keeps the guest character when every slot is taken, and does nothing without one', async () => {
const store = await openTestStore()
const store = await openTestStore()
await store.players.upsertMany([
await store.players.upsertMany([
- player('a0', SUBJECT, 0),
- player('a1', SUBJECT, 1),
- player('a2', SUBJECT, 2),
+ // Every place an account has (MAX_CHARACTERS; three were all there was before 2026-10-27).
+ ...Array.from({ length: MAX_CHARACTERS }, (_, i) => player(`a${i}`, SUBJECT, i)),
player('g1', 'guestfull01', 0),
player('g1', 'guestfull01', 0),
])
])
expect(await store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({
expect(await store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({
@@ -90,7 +90,7 @@ describe('account merge (ADR-029)', () => {
@@ -90,7 +90,7 @@ describe('account merge (ADR-029)', () => {
const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', async () => {
it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', async () => {
const store = await openTestStore()
const store = await openTestStore()
- // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.
+ // The survivor has slot 0; the folded-in account has slots 0, 1 and 2. Its 0 clashes and takes the next free place.
await store.players.upsertMany([
await store.players.upsertMany([
player('s0', SUBJECT, 0),
player('s0', SUBJECT, 0),
player('f0', FROM, 0),
player('f0', FROM, 0),
@@ -99,19 +99,31 @@ describe('account merge (ADR-029)', () => {
@@ -99,19 +99,31 @@ describe('account merge (ADR-029)', () => {
])
])
expect(
expect(
await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),
await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),
- ).toEqual({ moved: 2, kept: 1, already: false })
+ ).toEqual({ moved: 3, kept: 0, already: false })
expect(
expect(
(await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),
(await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),
).toEqual([
).toEqual([
+ ['f0', 3],
['f1', 1],
['f1', 1],
['f2', 2],
['f2', 2],
['s0', 0],
['s0', 0],
])
])
- expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])
+ expect((await store.players.listByToken(FROM)).length).toBe(0)
expect(
expect(
await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),
await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),
).toEqual({ moved: 0, kept: 0, already: true })
).toEqual({ moved: 0, kept: 0, already: true })
- expect((await store.players.listByToken(FROM)).length).toBe(1)
+ })
+
+ it('keeps on the folded-in account what does not fit a full survivor', async () => {
+ const store = await openTestStore()
+ await store.players.upsertMany([
+ ...Array.from({ length: MAX_CHARACTERS }, (_, i) => player(`s${i}`, SUBJECT, i)),
+ player('f0', FROM, 0),
+ ])
+ expect(
+ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2H1', 5),
+ ).toEqual({ moved: 0, kept: 1, already: false })
+ expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])
})
})
})
})
@@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
+import { MAX_CHARACTERS } from '@openvibe/protocol'
import { sql, type Db } from 'openvibe-sdk/db'
import { sql, type Db } from 'openvibe-sdk/db'
import { accountKey } from '../accountKey.js'
import { accountKey } from '../accountKey.js'
import type {
import type {
@@ -14,6 +15,9 @@ import { idList } from './sqlUtils.js'
@@ -14,6 +15,9 @@ import { idList } from './sqlUtils.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'
import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'
+/** The character places of one account, in order (protocol MAX_CHARACTERS). */
+const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
+
/**
/**
* PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
* PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
* and the Media mirror queue. The game simulation never touches these; apps/server's platform
* and the Media mirror queue. The game simulation never touches these; apps/server's platform
@@ -120,7 +124,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -120,7 +124,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
let moved = 0
let moved = 0
let slot: number | null = null
let slot: number | null = null
for (const row of rows) {
for (const row of rows) {
- const free = [0, 1, 2].find((s) => !taken.has(s))
+ const free = SLOTS.find((s) => !taken.has(s))
if (free === undefined) break
if (free === undefined) break
await moveToSlot(row.id, subjectId, subjectId, free)
await moveToSlot(row.id, subjectId, subjectId, free)
taken.add(free)
taken.add(free)
@@ -154,7 +158,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -154,7 +158,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
}
}
let kept = 0
let kept = 0
for (const row of clashing) {
for (const row of clashing) {
- const slot = [0, 1, 2].find((s) => !taken.has(s))
+ const slot = SLOTS.find((s) => !taken.has(s))
if (slot === undefined) {
if (slot === undefined) {
kept++
kept++
continue
continue
@@ -2,6 +2,13 @@ import { z } from 'zod'
@@ -2,6 +2,13 @@ import { z } from 'zod'
import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'
import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'
import { AppearanceSchema } from '../appearance.js'
import { AppearanceSchema } from '../appearance.js'
+/**
+ * How many characters one account (or one guest browser) may keep in a game. Not a feature: a technical ceiling for the
+ * slot index. The platform used to sell "3 character slots" for signing in (owner, 2026-10-27: "the character slot …
+ * restriction thing … shouldn't be one of the built-in features of the site … more modular, open to modders").
+ */
+export const MAX_CHARACTERS = 24
+
/**
/**
* Client -> server messages, defined as zod schemas because the server must
* Client -> server messages, defined as zod schemas because the server must
* treat every inbound byte as hostile. Types are inferred from schemas so
* treat every inbound byte as hostile. Types are inferred from schemas so
@@ -18,8 +25,8 @@ export const ClientHelloSchema = z.object({
@@ -18,8 +25,8 @@ export const ClientHelloSchema = z.object({
.optional(),
.optional(),
/** Requested place. Older clients omit it and join the configured default. */
/** Requested place. Older clients omit it and join the configured default. */
place: z.string().min(1).max(64).optional(),
place: z.string().min(1).max(64).optional(),
- /** Character slot under this account (MMO-style, 3 max). */
- slot: z.number().int().min(0).max(2).default(0),
+ /** Which of this account's characters (0 … MAX_CHARACTERS - 1); a game, not the platform, decides how many it uses. */
+ slot: z.number().int().min(0).max(MAX_CHARACTERS - 1).default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
appearance: AppearanceSchema,
appearance: AppearanceSchema,
})
})