fix(ui): poll/refetch fallback for a missed opponent_joined in the open-game wait
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

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.
This commit is contained in:
Ilia Denisov
2026-06-14 00:02:57 +02:00
parent 315bcf75ae
commit 16402e64c0
4 changed files with 78 additions and 2 deletions
+25
View File
@@ -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;
}
+14 -1
View File
@@ -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,
};
}