mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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:
|
* 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
|
* 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
|
* room → `isOnline` false). `statusVisibility` follows the saved preference so clients
|
||||||
* isn't hidden from friends. `appVersion` is the subject's own build (from their presence
|
* 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
|
* 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
|
* a numeric value aborts the whole SignalR frame ("expected String Begin Token"), dropping
|
||||||
* the room/presence update.
|
* the room/presence update.
|
||||||
@@ -548,11 +548,12 @@ function redactInstanceForPresence(instance: RoomInstance) {
|
|||||||
function presenceUpdateMessage(
|
function presenceUpdateMessage(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
instance: RoomInstance | null,
|
instance: RoomInstance | null,
|
||||||
appVersion?: string | null
|
appVersion?: string | null,
|
||||||
|
statusVisibility = 0
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
playerId,
|
playerId,
|
||||||
statusVisibility: 0,
|
statusVisibility,
|
||||||
deviceClass: 0,
|
deviceClass: 0,
|
||||||
vrMovementMode: 0,
|
vrMovementMode: 0,
|
||||||
roomInstance: instance ? redactInstanceForPresence(instance) : null,
|
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(
|
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||||
friendIds,
|
friendIds,
|
||||||
NotificationType.SubscriptionUpdatePresence,
|
NotificationType.SubscriptionUpdatePresence,
|
||||||
presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion)
|
presenceUpdateMessage(
|
||||||
|
playerId,
|
||||||
|
presence?.roomInstance ?? null,
|
||||||
|
presence?.appVersion,
|
||||||
|
presence?.statusVisibility
|
||||||
|
)
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push SubscriptionUpdatePresence to friends', {
|
logger.error('failed to push SubscriptionUpdatePresence to friends', {
|
||||||
@@ -2077,15 +2083,41 @@ const app = new Hono<App>()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id !== null) {
|
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 =
|
const sv =
|
||||||
typeof body.statusVisibility === 'string'
|
typeof value === 'number'
|
||||||
? Number.parseInt(body.statusVisibility, 10)
|
? value
|
||||||
: NaN
|
: typeof value === 'string' && /^\d+$/.test(value)
|
||||||
|
? Number(value)
|
||||||
|
: NaN
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
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
|
presence.statusVisibility = sv
|
||||||
await setPresence(c.env.DB, presence)
|
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)
|
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 () => {
|
test('PUT /player/statusvisibility returns 200', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Reference in New Issue
Block a user