Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -76,3 +76,17 @@ test('quick game: a foreground resync recovers a join shed while the stream stay
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked
|
||||
// the `view` it also writes, so opponent_joined re-invalidated itself in a tight loop (opponent_moved
|
||||
// was spared only by its delta's move-count idempotency). The board must still respond afterwards.
|
||||
test('quick game: the board stays interactive after the opponent joins', async ({ page }) => {
|
||||
await enterOpenGame(page);
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
|
||||
// Place a rack tile on the centre star: a frozen screen renders no pending tile.
|
||||
await page.locator('.rack .tile').first().click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // row 7, col 7
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
});
|
||||
|
||||
+32
-23
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import TapConfirm from '../components/TapConfirm.svelte';
|
||||
@@ -222,29 +222,38 @@
|
||||
$effect(() => {
|
||||
const e = app.lastEvent;
|
||||
if (!e) return;
|
||||
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||
// the move as a delta with no fetch.
|
||||
if (placement.pending.length > 0) void load();
|
||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||
// only if we missed the move (our cached count trails the event's).
|
||||
if (view && e.moveCount > view.game.moveCount) void load();
|
||||
} else if (e.kind === 'game_over' && e.gameId === id) {
|
||||
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
||||
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
|
||||
// The opponent took the empty seat: adopt the new participants and status in place,
|
||||
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
|
||||
const r = applyOpponentJoined(cacheSnapshot(), e.state);
|
||||
if (r) {
|
||||
view = r.view;
|
||||
setCachedGame(id, r.view, r.moves);
|
||||
// Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
|
||||
// view/moves/placement, so tracking those reads would re-run this effect from its own
|
||||
// `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
|
||||
// opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
|
||||
// game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
|
||||
// likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
|
||||
// out of the effect's dependencies.
|
||||
untrack(() => {
|
||||
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||
// the move as a delta with no fetch.
|
||||
if (placement.pending.length > 0) void load();
|
||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||
// only if we missed the move (our cached count trails the event's).
|
||||
if (view && e.moveCount > view.game.moveCount) void load();
|
||||
} else if (e.kind === 'game_over' && e.gameId === id) {
|
||||
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
||||
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
|
||||
// The opponent took the empty seat: adopt the new participants and status in place,
|
||||
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
|
||||
const r = applyOpponentJoined(cacheSnapshot(), e.state);
|
||||
if (r) {
|
||||
view = r.view;
|
||||
setCachedGame(id, r.view, r.moves);
|
||||
}
|
||||
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
||||
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||
void loadFriends();
|
||||
}
|
||||
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
||||
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||
void loadFriends();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped
|
||||
|
||||
Reference in New Issue
Block a user