update presence on drop

This commit is contained in:
Devin Zuczek
2026-07-23 22:19:25 -04:00
parent 82f011c0b0
commit cfc29cb175
2 changed files with 57 additions and 2 deletions
+4
View File
@@ -673,6 +673,10 @@ const app = new Hono<App>()
// The instance they were in lost a player — recompute its fullness so a
// full room frees up. No-op for the synthetic dorm/orientation instances.
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
// Their presence changed — tell online friends they went offline. Presence
// is already cleared, so notifyFriendsPresence reads null and sends the
// offline snapshot (roomInstance null, isOnline false).
await notifyFriendsPresence(c, id)
}
}
return c.body(null, 200)
+53 -2
View File
@@ -1084,7 +1084,7 @@ describe('auth-gated endpoints', () => {
// fan-out is a single batch call carrying the friend ids.
type Batch = {
playerIds: number[]
notificationType: number
notificationType: number | string
data: {
playerId: number
statusVisibility: number
@@ -1110,7 +1110,7 @@ describe('auth-gated endpoints', () => {
// Delivered to the two friends (in both graph directions), not the pending-request
// player (9703).
expect(batch.playerIds.slice().sort((a, b) => a - b)).toEqual([9701, 9702])
expect(batch.notificationType).toBe(12) // NotificationType.SubscriptionUpdatePresence
expect(batch.notificationType).toBe('PresenceUpdate') // NotificationType.SubscriptionUpdatePresence
expect(batch.data).toMatchObject({
playerId: 9700,
statusVisibility: 0, // Everyone — not hidden from friends
@@ -1140,6 +1140,57 @@ describe('auth-gated endpoints', () => {
expect(await (await hub().fetch('http://do/all')).json()).toEqual([])
})
test('logout fires SubscriptionUpdatePresence (offline) to friends', async () => {
type Batch = {
playerIds: number[]
notificationType: number | string
data: { playerId: number; isOnline: boolean; roomInstance: Record<string, unknown> | null }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Batch[]> =>
(await (await hub().fetch('http://do/all')).json()) as Batch[]
// 9900 is friends with 9901.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
.bind(9900, 9901, 3)
.run()
// 9900 enters a room (presence created), then reset the hub so we isolate the
// logout push from the entry push.
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('9900'),
})
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/player/logout`, {
method: 'POST',
headers: await bearer('9900'),
})
expect(res.status).toBe(200)
// The friend gets an offline presence snapshot: no room, isOnline false.
const batch = await sent()
expect(batch).toHaveLength(1)
expect(batch[0].playerIds).toEqual([9901])
expect(batch[0].notificationType).toBe('PresenceUpdate') // SubscriptionUpdatePresence
expect(batch[0].data).toMatchObject({ playerId: 9900, isOnline: false, roomInstance: null })
// Presence is actually cleared — a second logout (no presence) fires nothing.
await hub().fetch('http://do/all', { method: 'DELETE' })
await exports.default.fetch(`${ORIGIN}/player/logout`, {
method: 'POST',
headers: await bearer('9900'),
})
expect(await sent()).toEqual([])
// An unauthenticated logout is a no-op too.
await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST' })
expect(await sent()).toEqual([])
})
test('POST /matchmake/player/:id follows a friend into their room, friends only', async () => {
// 9800 is friends with 9801 (in a room) and 9803 (not in any room); 9802 is not a
// friend.