From 1fa841f9551176e0d9c60c7d03a619d8ec26bdcc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 16 Jun 2026 01:29:48 -0400 Subject: [PATCH] make new account + orientation work --- apps/api/src/default-settings.ts | 34 -------------- apps/auth/src/auth.app.ts | 8 +++- apps/auth/src/test/integration/api.test.ts | 3 +- apps/match/src/match.app.ts | 31 ++++++++----- apps/match/src/test/integration/api.test.ts | 45 ++++++++++++------- apps/playersettings/src/default-settings.ts | 17 ++++--- .../src/test/integration/api.test.ts | 7 +-- 7 files changed, 72 insertions(+), 73 deletions(-) diff --git a/apps/api/src/default-settings.ts b/apps/api/src/default-settings.ts index 936ebad..93d7e96 100644 --- a/apps/api/src/default-settings.ts +++ b/apps/api/src/default-settings.ts @@ -9,40 +9,6 @@ export interface PlayerSetting { } const DEFAULTS: ReadonlyArray = [ - ['Recroom.OOBE', '77'], - [ - 'SplitTestAssignedSegments', - '1|{"SplitTesting+PhotonMaxDatagrams_2021_01_11":"Off","SplitTesting+Curated_Rooms_2020_08_06":"Off","SplitTesting+RoomRecommendationsType_2020_08_14":"Aug14MinVisitors35000"}', - ], - ['PlayerSessionCount', '13'], - ['TUTORIAL_COMPLETE_MASK', '11'], - ['BACKPACK_FAVORITE_TOOL', '1'], - ['VoiceChat', '2'], - ['VRAUTOSPRINT', '1'], - ['VR_MOVEMENT_MODE', '0'], - ['COMFORT_SPRINT', '0'], - ['COMFORT_WALK', '0'], - ['COMFORT_VEHICLES', '0'], - ['COMFORT_FLY', '0'], - ['COMFORT_ROTATE', '0'], - ['COMFORT_FORCES', '0'], - ['COMFORT_FALL', '0'], - ['COMFORT_TELEPORT', '0'], - ['ROTATE_IN_PLACE_ENABLED', '1'], - ['ROTATION_INCREMENT', '2'], - ['CONTINUOUS_ROTATION_MODE', '1'], - ['DONT_LOCK_TOOLS_TO_HAND', '0'], - ['QualitySettings', '2'], - ['TeleportBuffer', '0'], - ['IgnoreBuffer', '1'], - ['FIRST_TIME_IN_FLAGS', '0'], - ['ShowRoomCenter', '1'], - ['USER_TRACKING', '1'], - ['STABILIZE_HANDS', '0'], - ['MakerPen_SnappingMode', '2'], - ['Recroom.ChallengeMap', '17'], - ['VoiceFilter2', '1'], - ['SFX_VOLUME_PERCENT_PREF', '1'], ] export function defaultSettings(playerId: number): PlayerSetting[] { diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 0e41ff2..d145363 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -28,6 +28,12 @@ const PLATFORM_TYPES: Record = { /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 +/** + * The client loads Orientation locally (no matchmake) and tags its instance with + * the sentinel id -2. The heartbeat must echo that exact `roomInstanceId` or the + * client treats presence as out-of-sync and bounces the player to the dorm. + */ +const ORIENTATION_INSTANCE_ID = -2 /** Presence TTL (s) — matches the match worker; refreshed by each heartbeat. */ const PRESENCE_TTL = 900 @@ -54,7 +60,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) const roomInstance = { - roomInstanceId: ORIENTATION_ROOM_ID, + roomInstanceId: ORIENTATION_INSTANCE_ID, roomId: ORIENTATION_ROOM_ID, subRoomId: num(sub?.SubRoomId, 1), roomInstanceType: 0, diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index bd8ded0..1cf1087 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -140,10 +140,11 @@ describe('auth worker routes', () => { const payload = await tokenFor('grant_type=create_account&platform_id=steam-456') const sub = payload.sub as string const presence = await env.MATCH_PRESENCE.get<{ - roomInstance: { roomId: number; location: string; name: string } + roomInstance: { roomInstanceId: number; roomId: number; location: string; name: string } }>(`presence:${sub}`, 'json') expect(presence).not.toBeNull() expect(presence!.roomInstance).toMatchObject({ + roomInstanceId: -2, roomId: 13, location: ORIENTATION_SCENE, name: '^Orientation', diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 5307e7e..7bd9e51 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -245,20 +245,15 @@ const app = new Hono() .notFound(withNotFound()) // ---- Player presence ----------------------------------------------------- - // Login/exclusivelogin are no-op acks (matching every reference server). They - // MUST NOT touch presence: the client calls exclusivelogin when going online, - // and clearing here would wipe the room matchmake just stored → empty KV → - // the heartbeat reports no room. Only logout clears presence. + // login/exclusivelogin/logout are all no-op acks and MUST NOT touch presence. + // The client fires a spurious `player/logout` during the account-creation + // bootstrap (right after create_account seeds the new player into Orientation); + // deleting presence here wiped that seed and bounced the player to the dorm. + // Presence is overwritten by matchmake/goto and expires on its own TTL, so we + // don't need to clear it on these lifecycle calls. .post('/player/login', (c) => c.body(null, 200)) .post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 })) - - // Logout: drop the player's presence (they're no longer in a room). Both - // reference servers expose this; returns 200. - .post('/player/logout', async (c) => { - const id = await authedId(c) - if (id !== null) await c.env.MATCH_PRESENCE.delete(presenceKey(id)) - return c.body(null, 200) - }) + .post('/player/logout', (c) => c.body(null, 200)) .get('/player', async (c) => { // Returns each requested player's presence. The C# reads the `id` query @@ -368,6 +363,18 @@ const app = new Hono() // isn't swallowed by the auth-gated matchmake handler. .post('/matchmake/none', async (c) => { const id = await authedId(c) + // FemRec (our 2023-client target) returns the player's *current* heartbeat + // here rather than forcing the dorm (the 2025 server's behavior we'd copied). + // Orientation is a solo room the client establishes via matchmake/none; if we + // force the dorm, the new player is warped out of Orientation within seconds. + // So: preserve existing presence; only fall back to the offline dorm when the + // player has none (e.g. the title screen before they've entered any room). + if (id !== null) { + const presence = await getPresence(c, id) + if (presence?.roomInstance) { + return c.json({ errorCode: 0, roomInstance: presence.roomInstance }) + } + } const instance = dormRoomInstance() if (id !== null) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 762b8c8..7cb49d0 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -154,7 +154,7 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null }) }) - test('POST /matchmake/none returns the offline dorm', async () => { + test('POST /matchmake/none returns the offline dorm when the player has no presence', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' }) expect(res.status).toBe(200) const body = (await res.json()) as { @@ -170,6 +170,29 @@ describe('public endpoints', () => { expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/) }) + test('POST /matchmake/none preserves an existing presence (does not warp to the dorm)', async () => { + const auth = await bearer('314') + // Put the player in a room first (RecCenter), establishing presence. + await exports.default.fetch(`${ORIGIN}/matchmake/2`, { method: 'POST', headers: auth }) + // matchmake/none must return that same room, not force the dorm — this is + // what keeps a new player in the solo Orientation room. + const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { + method: 'POST', + headers: auth, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + errorCode: number + roomInstance: { roomId: number; name: string; location: string } + } + expect(body.errorCode).toBe(0) + expect(body.roomInstance).toMatchObject({ + roomId: 2, + name: '^RecCenter', + location: RECCENTER_SCENE, + }) + }) + test('PUT /player/statusvisibility returns 200', async () => { const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' }) expect(res.status).toBe(200) @@ -340,23 +363,13 @@ describe('auth-gated endpoints', () => { }) }) - test('player/logout returns 200 and clears presence', async () => { - const headers = await bearer('77') - await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) - const out = await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers }) - expect(out.status).toBe(200) - const hb = (await ( - await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) - ).json()) as { roomInstance: unknown; isOnline: boolean } - expect(hb.roomInstance).toBeNull() - expect(hb.isOnline).toBe(false) - }) - - test('login/exclusivelogin do NOT clear presence (only logout does)', async () => { + test('player/login, exclusivelogin and logout all preserve presence', async () => { const headers = await bearer('9') await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) - // The client calls exclusivelogin when going online — it must not wipe the - // room matchmake just stored. + // None of these lifecycle calls may wipe presence — the client fires a + // spurious logout during the account-creation bootstrap, and exclusivelogin + // when going online. Clearing here would bounce the player to the dorm. + await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers }) await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST', headers }) await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers }) const hb = (await ( diff --git a/apps/playersettings/src/default-settings.ts b/apps/playersettings/src/default-settings.ts index 21b87cc..1224926 100644 --- a/apps/playersettings/src/default-settings.ts +++ b/apps/playersettings/src/default-settings.ts @@ -1,17 +1,22 @@ /** - * Default player settings seeded on a player's first read, ported verbatim from - * the C# `PlayerSettingsController.GetPlayerSettings`. Ordered; written to KV the - * first time a player has no stored settings. + * Default player settings seeded on a player's first read. Based on the C# + * `PlayerSettingsController.GetPlayerSettings`, but the onboarding/progress flags + * are reset to a *fresh* player — the C# values were copied from an already- + * onboarded account, which made new accounts skip Orientation (the client saw + * OOBE/tutorials as complete and warped them to the dorm within seconds). + * Ordered; written to KV the first time a player has no stored settings. */ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [ - { Key: 'Recroom.OOBE', Value: '77' }, + // 0 = no OOBE steps done, so the new-user Orientation flow runs. + { Key: 'Recroom.OOBE', Value: '0' }, { Key: 'SplitTestAssignedSegments', Value: '1|{"SplitTesting+PhotonMaxDatagrams_2021_01_11":"Off","SplitTesting+Curated_Rooms_2020_08_06":"Off","SplitTesting+RoomRecommendationsType_2020_08_14":"Aug14MinVisitors35000"}', }, - { Key: 'PlayerSessionCount', Value: '13' }, - { Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' }, + { Key: 'PlayerSessionCount', Value: '0' }, + // 0 = no tutorials completed (fresh player). + { Key: 'TUTORIAL_COMPLETE_MASK', Value: '0' }, { Key: 'BACKPACK_FAVORITE_TOOL', Value: '1' }, { Key: 'VoiceChat', Value: '2' }, { Key: 'VRAUTOSPRINT', Value: '1' }, diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index b9d36e2..4d60995 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -66,12 +66,13 @@ describe('playersettings endpoints', () => { const settings = (await res.json()) as Array<{ PlayerId: number; Key: string; Value: string }> expect(settings.length).toBeGreaterThan(0) expect(settings.every((s) => s.PlayerId === 100)).toBe(true) - expect(settings.find((s) => s.Key === 'Recroom.OOBE')?.Value).toBe('77') - expect(settings.find((s) => s.Key === 'PlayerSessionCount')?.Value).toBe('13') + // Fresh-player onboarding defaults so the new-user Orientation flow runs. + expect(settings.find((s) => s.Key === 'Recroom.OOBE')?.Value).toBe('0') + expect(settings.find((s) => s.Key === 'TUTORIAL_COMPLETE_MASK')?.Value).toBe('0') // Defaults were persisted to KV. const stored = await env.PLAYER_SETTINGS.get>('player:100', 'json') - expect(stored?.['Recroom.OOBE']).toBe('77') + expect(stored?.['Recroom.OOBE']).toBe('0') }) it('GET /playersettings reflects a value written by PUT', async () => {