From 0e699cab2bccf04eb5965a06c531079cd2d0d8d5 Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Mon, 12 Oct 2026 14:20:29 -0400 Subject: [PATCH] [match] Persist and broadcast presence visibility (#127) * [match] Persist visibility changes and refresh own-player presence * [match] Propagate saved visibility to friends --- apps/match/src/match.app.ts | 52 ++++++++++++++---- apps/match/src/test/integration/api.test.ts | 58 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 3baee59..b55a69d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -539,8 +539,8 @@ function redactInstanceForPresence(instance: RoomInstance) { /** * The SubscriptionUpdatePresence message a friend receives when the player changes rooms: * a presence snapshot of who, and the redacted instance they're now in (null when in no - * room → `isOnline` false). `statusVisibility` is forced to 0 (Everyone) so the player - * isn't hidden from friends. `appVersion` is the subject's own build (from their presence + * room → `isOnline` false). `statusVisibility` follows the saved preference so clients + * apply their native visibility rules. `appVersion` is the subject's own build (from their presence * row) and MUST be a string — the client's presence DTO reads it with a string reader, and * a numeric value aborts the whole SignalR frame ("expected String Begin Token"), dropping * the room/presence update. @@ -548,11 +548,12 @@ function redactInstanceForPresence(instance: RoomInstance) { function presenceUpdateMessage( playerId: number, instance: RoomInstance | null, - appVersion?: string | null + appVersion?: string | null, + statusVisibility = 0 ) { return { playerId, - statusVisibility: 0, + statusVisibility, deviceClass: 0, vrMovementMode: 0, roomInstance: instance ? redactInstanceForPresence(instance) : null, @@ -584,7 +585,12 @@ async function notifyFriendsPresence(env: Env, playerId: number): Promise await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( friendIds, NotificationType.SubscriptionUpdatePresence, - presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion) + presenceUpdateMessage( + playerId, + presence?.roomInstance ?? null, + presence?.appVersion, + presence?.statusVisibility + ) ) } catch (err) { logger.error('failed to push SubscriptionUpdatePresence to friends', { @@ -2077,15 +2083,41 @@ const app = new Hono() async (c) => { const id = await authedId(c) if (id !== null) { - const body = await c.req.parseBody().catch(() => ({}) as Record) + // Accept native form/query enum values and JSON integrations. + const body: unknown = c.req.header('content-type')?.includes('application/json') + ? await c.req.json().catch(() => null) + : await c.req.parseBody().catch(() => null) + const value = + c.req.query('statusVisibility') ?? + (body && typeof body === 'object' + ? ((body as Record).statusVisibility ?? + (body as Record).StatusVisibility) + : body) const sv = - typeof body.statusVisibility === 'string' - ? Number.parseInt(body.statusVisibility, 10) - : NaN + typeof value === 'number' + ? value + : typeof value === 'string' && /^\d+$/.test(value) + ? Number(value) + : NaN const presence = await getPresence(c.env.DB, id) - if (presence && !Number.isNaN(sv)) { + if (presence && Number.isInteger(sv) && sv >= 0 && sv <= 3) { presence.statusVisibility = sv await setPresence(c.env.DB, presence) + // The settings menu reads the cached own-player presence. A heartbeat + // response alone does not refresh it; publish the native hub update. + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + [id], + NotificationType.SubscriptionUpdatePresence, + playerPayload(id, presence) + ) + } catch (error) { + logger.error('failed to push own visibility update', { + playerId: id, + error: String(error), + }) + } + await notifyFriendsPresence(c.env, id) } } return c.body(null, 200) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index bfb68d8..b35a6db 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1221,6 +1221,64 @@ describe('public endpoints', () => { } }) + test('visibility changes persist and push the updated own-player cache', async () => { + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + const headers = await bearer('9200', '20260323.14') + await env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (9200, 9201, 3)' + ).run() + await setPresence(env.DB, { + accountId: 9200, + roomInstance: null, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: '20260323.14', + }) + const update = async (body: string, contentType: string) => + exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': contentType }, + body, + }) + for (const value of [1, 2, 3, 0]) { + expect((await update(JSON.stringify(value), 'application/json')).status).toBe(200) + const response = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers, + }) + expect(await response.json()).toMatchObject({ statusVisibility: value }) + } + await update('statusVisibility=2', 'application/x-www-form-urlencoded') + await update('99', 'application/json') + const response = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers, + }) + expect(await response.json()).toMatchObject({ statusVisibility: 2 }) + const sent = await ( + await hub().fetch('http://do/all') + ).json< + Array<{ + playerIds: number[] + notificationType: string + data: { statusVisibility: number; playerId: number } + }> + >() + expect(sent).toHaveLength(10) + expect(sent[8]).toMatchObject({ + playerIds: [9200], + notificationType: 'PresenceUpdate', + data: { playerId: 9200, statusVisibility: 2 }, + }) + expect(sent[9]).toMatchObject({ + playerIds: [9201], + notificationType: 'PresenceUpdate', + data: { playerId: 9200, statusVisibility: 2 }, + }) + }) test('PUT /player/statusvisibility returns 200', async () => { const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' }) expect(res.status).toBe(200)