Merge pull request 'fix(ui): poll/refetch fallback for a missed opponent_joined in the open-game wait' (#54) from feature/open-game-wait-poll-fallback into development
This commit was merged in pull request #54.
This commit is contained in:
@@ -572,7 +572,11 @@ 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
|
(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
|
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**
|
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, 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
|
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
|
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
|
the **gateway** routes from the same firehose: for an event whose recipient has **no
|
||||||
|
|||||||
@@ -29,3 +29,50 @@ 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.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -238,6 +238,42 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (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 {
|
function isCoarse(): boolean {
|
||||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export interface Toast {
|
|||||||
|
|
||||||
export const app = $state<{
|
export const app = $state<{
|
||||||
ready: boolean;
|
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;
|
streamAlive: boolean;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -53,6 +55,10 @@ export const app = $state<{
|
|||||||
notifications: number;
|
notifications: number;
|
||||||
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
|
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
|
||||||
chatUnread: Record<string, number>;
|
chatUnread: Record<string, number>;
|
||||||
|
/** 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,
|
ready: false,
|
||||||
streamAlive: false,
|
streamAlive: false,
|
||||||
@@ -68,6 +74,7 @@ export const app = $state<{
|
|||||||
localeLocked: false,
|
localeLocked: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
chatUnread: {},
|
chatUnread: {},
|
||||||
|
resync: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let unsubscribeStream: (() => void) | null = null;
|
let unsubscribeStream: (() => void) | null = null;
|
||||||
@@ -102,7 +109,13 @@ function goForeground(): void {
|
|||||||
backgrounded = false;
|
backgrounded = false;
|
||||||
foregroundedAt = Date.now();
|
foregroundedAt = Date.now();
|
||||||
if (!app.session) return;
|
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();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,3 +513,14 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
telegramOnEvent('activated', goForeground);
|
telegramOnEvent('activated', goForeground);
|
||||||
telegramOnEvent('deactivated', goBackground);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
};
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// 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(),
|
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||||
|
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,14 +181,22 @@ export class MockGateway implements GatewayClient {
|
|||||||
return { matched: false, game: structuredClone(g.view) };
|
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
|
// 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.
|
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
|
||||||
private fillOpponent(id: string): void {
|
private fillOpponent(id: string): void {
|
||||||
const game = this.games.get(id);
|
const game = this.seatOpponent(id);
|
||||||
if (!game || game.view.status !== 'open') return;
|
if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
|
// 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);
|
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<MatchResult> {
|
async lobbyPoll(): Promise<MatchResult> {
|
||||||
if (this.pendingMatch) {
|
if (this.pendingMatch) {
|
||||||
const g = this.games.get(this.pendingMatch);
|
const g = this.games.get(this.pendingMatch);
|
||||||
|
|||||||
Reference in New Issue
Block a user