fix(ui): also resync an open game on a foreground regain without a stream drop
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<MatchResult> {
|
||||
if (this.pendingMatch) {
|
||||
const g = this.games.get(this.pendingMatch);
|
||||
|
||||
Reference in New Issue
Block a user