mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[match] improve presence delete on expiry/logout
This commit is contained in:
+28
-12
@@ -16,6 +16,7 @@ import {
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getClubSummary,
|
||||
getExpiredPresenceAccountIds,
|
||||
getExpiredPresenceInstanceIds,
|
||||
getFriendIds,
|
||||
getJoinableInstance,
|
||||
@@ -565,19 +566,22 @@ function presenceUpdateMessage(
|
||||
|
||||
/**
|
||||
* Push a SubscriptionUpdatePresence to every online friend of `playerId` after their
|
||||
* presence changes (they entered a room). Mirrors the reference's PlayerPresenceChanged:
|
||||
* only currently-connected friends receive it (an offline friend gets nothing, not a
|
||||
* queued stale frame), so it's an ephemeral batch send. The room instance the friends
|
||||
* see is read from the player's stored presence — the authoritative record just written,
|
||||
* the same one the heartbeat replays. Best-effort: a hub or lookup failure is logged and
|
||||
* swallowed, so it never fails the matchmake that triggered it.
|
||||
* presence changes (they entered a room, logged out, or lapsed). Mirrors the reference's
|
||||
* PlayerPresenceChanged: only currently-connected friends receive it (an offline friend
|
||||
* gets nothing, not a queued stale frame), so it's an ephemeral batch send. The room
|
||||
* instance the friends see is read from the player's stored presence — the authoritative
|
||||
* record just written, the same one the heartbeat replays; with the row gone (logout, or
|
||||
* the cron sweep purging a lapsed row) the read is null and the frame is the offline
|
||||
* snapshot (roomInstance null, isOnline false). Takes `Env` rather than a request context
|
||||
* so the sweep, which has no request, sends the same frame. Best-effort: a hub or lookup
|
||||
* failure is logged and swallowed, so it never fails the matchmake that triggered it.
|
||||
*/
|
||||
async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise<void> {
|
||||
async function notifyFriendsPresence(env: Env, playerId: number): Promise<void> {
|
||||
try {
|
||||
const friendIds = await getFriendIds(c.env.DB, playerId)
|
||||
const friendIds = await getFriendIds(env.DB, playerId)
|
||||
if (friendIds.length === 0) return
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, playerId)
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
const presence = await getPresence<RoomInstance>(env.DB, playerId)
|
||||
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
friendIds,
|
||||
NotificationType.SubscriptionUpdatePresence,
|
||||
presenceUpdateMessage(playerId, presence?.roomInstance ?? null, presence?.appVersion)
|
||||
@@ -649,7 +653,7 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
// The player's presence changed — tell their online friends where they went, reading
|
||||
// the instance back from the presence we just stored. Best-effort; never blocks or
|
||||
// fails the matchmake.
|
||||
await notifyFriendsPresence(c, id)
|
||||
await notifyFriendsPresence(c.env, id)
|
||||
}
|
||||
|
||||
/** Returned when a room isn't in the DB — and for every other opaque refusal. */
|
||||
@@ -1859,7 +1863,7 @@ const app = new Hono<App>()
|
||||
// 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)
|
||||
await notifyFriendsPresence(c.env, id)
|
||||
}
|
||||
}
|
||||
return c.body(null, 200)
|
||||
@@ -3506,14 +3510,26 @@ const app = new Hono<App>()
|
||||
* running after the purge above: this order is what makes a lapsed row count as a
|
||||
* departure. Fullness is recomputed last, so it works from the final head-count and
|
||||
* skips (returns null for) the instances just deleted.
|
||||
*
|
||||
* A lapsed row is also the player going offline, exactly as a logout is — the client just
|
||||
* never got to post one (it crashed, or lost its connection). So the sweep tells each
|
||||
* lapsed player's online friends what `POST /player/logout` would have: the offline
|
||||
* SubscriptionUpdatePresence. Without it a crashed player's friends kept seeing them
|
||||
* online until their own client re-read the friends list. The account ids are read
|
||||
* before the purge (the rows are what remembers who lapsed) and the frames sent after
|
||||
* it, so `notifyFriendsPresence` reads no presence and sends the offline snapshot.
|
||||
*/
|
||||
async function sweepExpiredPresence(env: Env): Promise<void> {
|
||||
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
||||
const lapsedPlayerIds = await getExpiredPresenceAccountIds(env.DB)
|
||||
const removed = await deleteExpiredPresence(env.DB)
|
||||
const emptyInstanceIds = await deleteEmptyRoomInstances(env.DB)
|
||||
for (const instanceId of staleInstanceIds) {
|
||||
await refreshInstanceFullness(env.DB, instanceId)
|
||||
}
|
||||
for (const playerId of lapsedPlayerIds) {
|
||||
await notifyFriendsPresence(env, playerId)
|
||||
}
|
||||
// Sample the player count into `stat` — taken after the purge, so it's the live
|
||||
// rows and not the ones that just lapsed. One row per cron run: the `online` series.
|
||||
const online = await countOnlinePlayers(env.DB)
|
||||
|
||||
@@ -2161,6 +2161,48 @@ describe('auth-gated endpoints', () => {
|
||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||
})
|
||||
|
||||
test('the cron sweep tells a lapsed player’s friends they went offline, as a logout does', 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[]
|
||||
|
||||
// 825 is friends with 826; 827 has no friends and lapses alongside.
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
.bind(825, 826, 3)
|
||||
.run()
|
||||
await seedPresence(825, nowSeconds() - 10)
|
||||
await seedPresence(827, nowSeconds() - 10)
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
await scheduled(createScheduledController(), env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
// Exactly one frame: the friend of the lapsed player, carrying the offline
|
||||
// snapshot — the same frame `POST /player/logout` sends. The friendless player
|
||||
// produces none.
|
||||
const batch = await sent()
|
||||
expect(batch).toHaveLength(1)
|
||||
expect(batch[0]!.playerIds).toEqual([826])
|
||||
expect(batch[0]!.notificationType).toBe('PresenceUpdate') // SubscriptionUpdatePresence
|
||||
expect(batch[0]!.data).toMatchObject({ playerId: 825, isOnline: false, roomInstance: null })
|
||||
expect(await countPresenceRows(825)).toBe(0)
|
||||
|
||||
// A second sweep has nothing lapsed → nothing sent.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
const ctx2 = createExecutionContext()
|
||||
await scheduled(createScheduledController(), env, ctx2)
|
||||
await waitOnExecutionContext(ctx2)
|
||||
expect(await sent()).toEqual([])
|
||||
})
|
||||
|
||||
test('records an `online` stat sample of the live presence count on each run', async () => {
|
||||
await env.DB.prepare('DELETE FROM stat').run()
|
||||
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
|
||||
|
||||
Reference in New Issue
Block a user