mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[match] Persist and broadcast presence visibility (#127)
* [match] Persist visibility changes and refresh own-player presence * [match] Propagate saved visibility to friends
This commit is contained in:
+42
-10
@@ -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<void>
|
||||
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<App>()
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// 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<string, unknown>).statusVisibility ??
|
||||
(body as Record<string, unknown>).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<RoomInstance>(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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user