[match] improve presence delete on expiry/logout

This commit is contained in:
Devin Zuczek
2026-10-08 10:37:18 -04:00
parent e54224dbd0
commit 1415ebcc06
3 changed files with 89 additions and 12 deletions
+28 -12
View File
@@ -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)