Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
4 changed files with 78 additions and 2 deletions
Showing only changes of commit 16402e64c0 - Show all commits
+4 -1
View File
@@ -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
+35
View File
@@ -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<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);
});
+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,
};
}