diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a1e76e6..b3a545e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -574,8 +574,9 @@ verbatim. Auto-match needs no match poll — `Enqueue` returns the game the play synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** event. Unlike a move, that event has no follow-up delta to trigger the move-count gap recovery, so the waiting game screen recovers a missed join itself: it **polls `game.state` while the stream is -down and refetches once on stream reconnect**, so a join missed during an outage or while the app -was backgrounded still resolves the open game in place. For the lobby **notification badge** (incoming friend requests + open +down, refetches once on stream reconnect, and resyncs on a foreground regain that did not drop the +stream** (covering an event shed from a full hub buffer while suspended), so a join missed during an +outage or while the app was backgrounded still resolves the open game in place. For the lobby **notification badge** (incoming friend requests + open invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push missed while the app was hidden. **Out-of-app platform push** is a fallback the **gateway** routes from the same firehose: for an event whose recipient has **no diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 0c74a13..4e3b388 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -64,3 +64,15 @@ test('quick game: a stream reconnect recovers a join missed while it was down', await expect(page.getByText('Robo')).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); }); + +test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => { + await enterOpenGame(page); + // The opponent joins but the event is shed with the stream still alive (no reconnect, and the + // poll only runs while the stream is down): nothing has recovered yet. + await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently()); + await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + // Returning to the foreground resyncs the open game (pageshow drives goForeground). + await page.evaluate(() => window.dispatchEvent(new Event('pageshow'))); + await expect(page.getByText('Robo')).toBeVisible(); + await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); +}); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 546eb40..4e1de95 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -263,6 +263,17 @@ return () => clearInterval(timer); }); + // (C) Returning to the foreground without the stream having dropped (so (A) does not fire) may + // still have missed an event shed from a full hub buffer while suspended; refetch once per resync. + let lastResync = app.resync; + $effect(() => { + const r = app.resync; + if (r !== lastResync) { + lastResync = r; + void load(); + } + }); + function isCoarse(): boolean { return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 866de45..df11a9c 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -55,6 +55,10 @@ export const app = $state<{ notifications: number; /** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */ chatUnread: Record; + /** Monotonic counter bumped when the app returns to the foreground without the live stream + * having dropped. An open game watches it to refetch once, recovering an in-game event shed + * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ + resync: number; }>({ ready: false, streamAlive: false, @@ -70,6 +74,7 @@ export const app = $state<{ localeLocked: false, notifications: 0, chatUnread: {}, + resync: 0, }); let unsubscribeStream: (() => void) | null = null; @@ -104,7 +109,13 @@ function goForeground(): void { backgrounded = false; foregroundedAt = Date.now(); if (!app.session) return; - if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away + if (!app.streamAlive) { + openStream(); // re-establish a stream dropped while away (its reconnect refetch then runs) + } else { + // The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an + // event may have been shed from a full hub buffer while away; nudge an open game to refetch. + app.resync++; + } void refreshNotifications(); } diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index faa4c20..43dd0f8 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -22,7 +22,8 @@ if (isMock && typeof window !== 'undefined') { }; // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // attaches a robot on a timer). - (window as unknown as { __mock?: { joinOpponent(): void } }).__mock = { + (window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = { joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(), + joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(), }; } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index aa0bc27..5c40716 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -181,14 +181,22 @@ export class MockGateway implements GatewayClient { return { matched: false, game: structuredClone(g.view) }; } + // seatOpponent seats a robot in an open game's empty seat and returns the game (null once it is no + // longer open). Shared by the live join (which then pushes opponent_joined) and the silent e2e + // variant (which omits the push). + private seatOpponent(id: string): MockGame | null { + const game = this.games.get(id); + if (!game || game.view.status !== 'open') return null; + game.view.status = 'active'; + game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }; + return game; + } + // fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the // mock of a human or robot taking the seat. A no-op once the game is no longer open. private fillOpponent(id: string): void { - const game = this.games.get(id); - if (!game || game.view.status !== 'open') return; - game.view.status = 'active'; - game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }; - this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) }); + const game = this.seatOpponent(id); + if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) }); } // joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the @@ -198,6 +206,13 @@ export class MockGateway implements GatewayClient { if (this.openGameId) this.fillOpponent(this.openGameId); } + // joinPendingOpponentSilently seats the opponent WITHOUT pushing opponent_joined — the mock of an + // event shed from a full hub buffer while the stream is alive, so the UI recovers only on a + // foreground resync. e2e hook: window.__mock.joinOpponentSilently. + joinPendingOpponentSilently(): void { + if (this.openGameId) this.seatOpponent(this.openGameId); + } + async lobbyPoll(): Promise { if (this.pendingMatch) { const g = this.games.get(this.pendingMatch);