From 16402e64c0091672ef34486a00cbd9df23d729bf Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 00:02:57 +0200 Subject: [PATCH 1/2] fix(ui): poll/refetch fallback for a missed opponent_joined in the open-game wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #51 moved the auto-match "wait for an opponent" from the lobby matchmaking screen into the open game but did not carry over that screen's poll fallback. The notify hub is best-effort and never replays, the live-stream resubscribe sends no cursor, and the game screen refreshed only from push events — so an opponent_joined dropped while the stream was down (e.g. a mobile suspend) left the starter stuck on "searching for opponent" until they re-entered the game. Unlike opponent_moved/game_over, opponent_joined has no follow-up event to trigger the existing move-count gap refetch. Recover it in Game.svelte: (A) refetch once on stream reconnect (covers the common suspend/resume case and rescues a missed move/game_over too), and (B) poll game.state every 2.5s while still waiting with the stream down (mirrors the old matchmaking startPoll). Add a mock-mode __stream e2e seam and two specs isolating each path, fix the now-stale streamAlive comment, and document the fallback in ARCHITECTURE §10. --- docs/ARCHITECTURE.md | 5 ++++- ui/e2e/quickmatch.spec.ts | 35 +++++++++++++++++++++++++++++++++++ ui/src/game/Game.svelte | 25 +++++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 15 ++++++++++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 06f07af..a1e76e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -572,7 +572,10 @@ renders the next turn without a self-refetch. The `notify` package owns the Flat (fed wire-agnostic input structs by the domain services) and the gateway forwards every payload verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** -event; for the lobby **notification badge** (incoming friend requests + open +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 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 903649f..0c74a13 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -29,3 +29,38 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled(); }); + +// The opponent_joined push is best-effort and never replayed, so a join that lands while the live +// stream is down is lost. The waiting game screen recovers it without a push: a poll while the +// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops / +// restores the stream; while it is dropped the mock has no subscriber, so joinOpponent emits to +// no one — exactly the missed-event case. +async function enterOpenGame(page: import('@playwright/test').Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /New/ }).click(); + await page.locator('.variant').first().click(); + await page.getByRole('button', { name: /Start game/i }).click(); + await expect(page.getByText(/Searching for opponent/)).toBeVisible(); +} + +test('quick game: a poll recovers a join missed while the live stream is down', async ({ page }) => { + await enterOpenGame(page); + // Drop the stream (no auto-reconnect), then let the opponent join: the push reaches no one, so + // only the poll fallback can restore the UI. + await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop()); + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + await expect(page.getByText('Robo')).toBeVisible(); + await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); +}); + +test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => { + await enterOpenGame(page); + // The opponent joins while the stream is down (the push is lost), then it reconnects before the + // poll could tick — the reconnect refetch is what catches up. + await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop()); + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore()); + 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 80018c4..546eb40 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -238,6 +238,31 @@ } }); + // The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped + // while the stream is down — or shed from a full subscriber buffer — is gone for good. + // opponent_moved and game_over self-heal via the next event's move-count gap check, but + // opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two + // fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby: + + // (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed + // while it was down. The common case is a mobile suspend that drops the stream, the opponent + // joining during it, then a silent resume; this also rescues a missed opponent_moved/game_over. + let wasStreamAlive = app.streamAlive; + $effect(() => { + const alive = app.streamAlive; + if (alive && !wasStreamAlive) void load(); + wasStreamAlive = alive; + }); + + // (B) While still waiting for an opponent with the stream down, the opponent_joined push cannot + // reach us at all; poll the game state until the seat fills (then waitingForOpponent flips and + // the timer is torn down) or the stream returns and (A) takes over. + $effect(() => { + if (!waitingForOpponent || app.streamAlive) return; + const timer = setInterval(() => void load(), 2500); + return () => clearInterval(timer); + }); + 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 a9d8714..866de45 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -36,7 +36,9 @@ export interface Toast { export const app = $state<{ ready: boolean; - /** Whether the live-event stream is connected; drives the matchmaking poll fallback. */ + /** Whether the live-event stream is connected. The event hub is best-effort and never replays a + * missed event, so an open game waiting for an opponent uses this to recover: it polls the game + * state while the stream is down and refetches once on reconnect (see game/Game.svelte). */ streamAlive: boolean; session: Session | null; profile: Profile | null; @@ -500,3 +502,14 @@ if (typeof window !== 'undefined') { } telegramOnEvent('activated', goForeground); telegramOnEvent('deactivated', goBackground); + +// Mock-mode e2e seam (tree-shaken from a production build): drive the live-stream lifecycle so the +// e2e can exercise the open-game fallbacks for a missed opponent_joined. `drop` tears the stream +// down without scheduling a reconnect — so only the in-game poll can then recover — and `restore` +// reopens it (the reconnect catch-up path). Never wired outside mock mode. +if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') { + (window as unknown as { __stream?: { drop(): void; restore(): void } }).__stream = { + drop: closeStream, + restore: openStream, + }; +} -- 2.52.0 From 4409253dce9eca89c2b9ab6022aad58e48b3c40f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 00:14:49 +0200 Subject: [PATCH 2/2] fix(ui): also resync an open game on a foreground regain without a stream drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the residual tail of the previous commit: when the live stream stays alive across a brief suspend (Telegram/iOS can pause the socket without tearing it down), an in-game event shed from a full hub buffer is never recovered by the reconnect refetch (no reconnect) or the open-game poll (it only runs while the stream is down). Mirror the lobby's focus re-poll: bump app.resync on a foreground regain that did not drop the stream, and have Game.svelte refetch the open game once per resync. Also rescues a missed move/game_over after a suspend. Add a silent-join mock seam + an e2e isolating this path; extend the ARCHITECTURE §10 fallback note. --- docs/ARCHITECTURE.md | 5 +++-- ui/e2e/quickmatch.spec.ts | 12 ++++++++++++ ui/src/game/Game.svelte | 11 +++++++++++ ui/src/lib/app.svelte.ts | 13 ++++++++++++- ui/src/lib/gateway.ts | 3 ++- ui/src/lib/mock/client.ts | 25 ++++++++++++++++++++----- 6 files changed, 60 insertions(+), 9 deletions(-) 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); -- 2.52.0