diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 3d2f446..3baee59 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -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, playerId: number): Promise { +async function notifyFriendsPresence(env: Env, playerId: number): Promise { 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(c.env.DB, playerId) - await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + const presence = await getPresence(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, 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() // 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() * 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 { 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) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 38a035a..bf77b46 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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 | null } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const sent = async (): Promise => + (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') diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 82bab80..2658e94 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -373,6 +373,25 @@ export async function getExpiredPresenceInstanceIds( return results.map((r) => r.id) } +/** + * The players whose presence rows have expired — the players about to be purged by + * {@link deleteExpiredPresence}. Read BEFORE the purge: once the rows are gone nothing + * remembers who lapsed, and their friends are owed the same offline + * SubscriptionUpdatePresence a logout sends (the lapse IS their logout, just one the + * client never got to post). Includes lobby (null-instance) presence — a player idle + * in the lobby who stops heartbeating has gone offline too. + */ +export async function getExpiredPresenceAccountIds( + db: D1Database, + now = nowSeconds() +): Promise { + const { results } = await db + .prepare('SELECT account_id AS id FROM presence WHERE expires_at <= ?1 ORDER BY account_id') + .bind(now) + .all<{ id: number }>() + return results.map((r) => r.id) +} + /** * Purge expired presence rows — housekeeping only, since reads already ignore them * (and `INSERT OR REPLACE` keeps a single row per account, so the table is bounded