openvibers/openvibe.games Commits

Characters are the game's, not a platform feature: no 3-slot cap, no sign-in lock (#158)

OP OpenVibers committed 17 days ago · 5e81d3ed0d · 11 files, +77 −65

* Characters are the game's, not a platform feature: no 3-slot cap or sign-in lock; guests and accounts keep as many as they like up to a 24 technical ceiling; merges and guest adoption bring every character across

* Character select: drop the unused escapeHtml (names are text nodes now)

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 447dabb..36e27a9 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -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>
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex fb41321..a34031f 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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(
ddiff --git a/apps/client/src/ui/characterSelect.ts b/apps/client/src/ui/characterSelect.ts
iindex 2dd42a8..bd72d98 100644
---- a/apps/client/src/ui/characterSelect.ts
++++ b/apps/client/src/ui/characterSelect.ts
@@@ -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)};`)
--}
ddiff --git a/apps/client/src/ui/customizeScreen.ts b/apps/client/src/ui/customizeScreen.ts
iindex dd29f20..8a623e9 100644
---- a/apps/client/src/ui/customizeScreen.ts
++++ b/apps/client/src/ui/customizeScreen.ts
@@@ -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>
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex f9dac16..4d8292e 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -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;
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 3fd8f8c..b2110d0 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -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
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 4690624..79676e8 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -141,8 +141,8 @@ export class SessionsSystem implements System {
let authIat: number | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
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
token = id.subject
authIat = 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.
ddiff --git a/apps/server/src/net/portalHome.ts b/apps/server/src/net/portalHome.ts
iindex ee4003b..df2736c 100644
---- a/apps/server/src/net/portalHome.ts
++++ b/apps/server/src/net/portalHome.ts
@@@ -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',
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex cba47fb..dd61c4a 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -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'])
})
})
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex b8334fc..f73024f 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -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 = 0
let slot: number | null = null
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
await moveToSlot(row.id, subjectId, subjectId, free)
taken.add(free)
@@@ -154,7 +158,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
}
let kept = 0
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) {
kept++
continue
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 5f4a2ad..fc2301b 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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,
})