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
18 changed files with 400 additions and 49 deletions
Showing only changes of commit c7e177f911 - Show all commits
+29
View File
@@ -13,6 +13,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
)
// newInvitationService builds an invitation service over the shared pool, starting
@@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
@@ -118,6 +121,16 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final)
}
// Every participant is told the invitation changed so their lobby drops the (now started) card
// from any screen, and the started-game notification lands too.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update", u)
}
}
if !pub.notified(invitees[0], notify.NotifyGameStarted) {
t.Errorf("invitee missing game_started")
}
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil {
t.Fatalf("participants: %v", err)
@@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) {
if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got)
}
// A decline kills the whole invitation: every participant is notified to drop it.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after decline", u)
}
}
// A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
@@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) {
func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -197,6 +220,12 @@ func TestInvitationCancelByInviter(t *testing.T) {
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err)
}
// The cancellation reaches every participant so an invitee's lobby drops the card from any screen.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after cancel", u)
}
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
+37 -2
View File
@@ -136,6 +136,25 @@ func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game,
svc.pub.Publish(intents...)
}
// emitInvitationUpdate publishes a changed invitation to every participant (the inviter and all
// invitees), so each one's lobby patches its invitations list without a refetch: a still-pending
// invitation is upserted (an updated response) and a terminal one (started, declined, cancelled,
// expired) removed. It is in-app only — notify.NotifyInvitationUpdate carries no out-of-app push —
// so a withdrawal or decline never becomes a platform notification.
func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invitation) {
summary := svc.invitationSummary(ctx, inv)
recipients := make([]uuid.UUID, 0, len(inv.Invitees)+1)
recipients = append(recipients, inv.InviterID)
for _, iv := range inv.Invitees {
recipients = append(recipients, iv.AccountID)
}
intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients {
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
}
svc.pub.Publish(intents...)
}
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
// resolving the inviter's and invitees' display names from the account store.
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
@@ -251,7 +270,15 @@ func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationI
return Invitation{}, err
}
}
return svc.store.loadInvitation(ctx, invitationID)
inv, err := svc.store.loadInvitation(ctx, invitationID)
if err != nil {
return Invitation{}, err
}
// Tell every participant the invitation changed, so their lobby patches its list from any
// screen: an updated response is upserted, a terminal one (declined, or started after the final
// accept) removed. startGame already emitted the started-game notification separately.
svc.emitInvitationUpdate(ctx, inv)
return inv, nil
}
// startGame creates the game for a fully-accepted invitation and marks it started.
@@ -289,7 +316,15 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
if err := svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()); err != nil {
return err
}
// The invitation is now cancelled: tell every participant so an invitee's lobby drops the card
// from any screen. Best-effort — a failed reload never fails the cancel.
if inv, err := svc.store.loadInvitation(ctx, invitationID); err == nil {
svc.emitInvitationUpdate(ctx, inv)
}
return nil
}
// GetInvitation loads an invitation with its invitees.
+14 -1
View File
@@ -182,8 +182,21 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
// so the client adds it to its lobby invitations list without a refetch.
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitation, inv)
}
// NotificationInvitationUpdate builds the NotifyInvitationUpdate notification carrying a changed
// invitation, so the client patches its lobby invitations list without a refetch — upserting a
// still-pending one (an updated response) and removing one that reached a terminal status.
func NotificationInvitationUpdate(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitationUpdate, inv)
}
// notificationInvitation builds a KindNotification intent of the given sub-kind carrying the
// invitation summary; it backs both the new-invitation and invitation-update notifications.
func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) Intent {
b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation)
k := b.CreateString(sub)
invOff := buildInvitation(b, inv)
fb.NotificationEventStart(b)
fb.NotificationEventAddKind(b, k)
+7 -1
View File
@@ -46,7 +46,13 @@ const (
// game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
// NotifyInvitationUpdate carries a changed invitation — an updated invitee response, or a
// terminal status (started, declined, cancelled, expired) — so the client patches its lobby
// invitations list without a refetch. Unlike NotifyInvitation (a brand-new invitation), it is
// in-app only: the Telegram connector renders no message for it, so a withdrawal or decline never
// becomes an out-of-app push.
NotifyInvitationUpdate = "invitation_update"
NotifyGameStarted = "game_started"
)
// Intent is one live event destined for a single user. Payload is the
+17
View File
@@ -181,6 +181,23 @@ func TestNotificationInvitationCarriesInvitation(t *testing.T) {
}
}
func TestNotificationInvitationUpdateCarriesTerminalInvitation(t *testing.T) {
uid := uuid.New()
inv := notify.InvitationSummary{ID: "inv-9", Variant: "scrabble_en", Status: "declined"}
in := notify.NotificationInvitationUpdate(uid, inv)
if in.Kind != notify.KindNotification {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyInvitationUpdate {
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitationUpdate)
}
got := ev.Invitation(nil)
if got == nil || string(got.Id()) != "inv-9" || string(got.Status()) != "declined" {
t.Fatalf("invitation wrong: %+v", got)
}
}
func TestNotificationAccountCarriesAccount(t *testing.T) {
uid := uuid.New()
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
+10 -5
View File
@@ -549,10 +549,11 @@ including the mover**, so the mover's own other devices and their lobby refresh
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
friend-added, friend-declined, invitation, invitation-update or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create or its game start). **game-over** is emitted to every
and on an invitation create (**invitation**) or any later change to it (**invitation-update**: an
updated response, a decline, a cancel, or its game start — to every participant)). **game-over** is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
@@ -566,7 +567,10 @@ refetch); **your-turn** carries that move count as a consistency check; the **ga
notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
@@ -589,8 +593,9 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
+4 -2
View File
@@ -53,8 +53,10 @@ Login uses `Screen`.
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
opponent-joined, and a match/started game (which it also adds) — and advances a not-currently-viewed
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted
opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
list (`patchLobbyInvitation`: a still-pending invitation upserted, a terminal one removed) on the
`invitation` / `invitation_update` notifications, and advances a not-currently-viewed game's own
cache from the move/over/joined delta (`advanceCached`, skipping the game in view, whose mounted
board owns its cache). The game board additionally mirrors the player's **own** move and its own
`load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
dist-e2e/
.svelte-kit/
*.tsbuildinfo
test-results/
+14
View File
@@ -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);
});
+34
View File
@@ -52,3 +52,37 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => {
expect(ob.x).toBe('none');
expect(ob.y).toBe('none');
});
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
// zoom state does not change, so without an explicit recenter the board stays parked where the
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click();
// Zoom into the top-left cell (far from the centre where the hint lands).
await page
.locator('[data-cell]:not(.filled)')
.first()
.evaluate((el: HTMLElement) => {
el.click();
el.click();
});
const viewport = page.locator('.viewport.zoomed');
await expect(viewport).toBeVisible();
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
// Take a hint (the control confirms on a second tap), which plays at the centre.
const hint = page.getByRole('button', { name: 'Hint' });
await hint.click();
await hint.click();
await page.waitForTimeout(300); // the board pans to the hint word
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
// The board panned from the top-left toward the centre word: both offsets grew.
expect(after.left).toBeGreaterThan(before.left + 20);
expect(after.top).toBeGreaterThan(before.top + 20);
});
+13 -4
View File
@@ -1,7 +1,11 @@
import { defineConfig, devices } from '@playwright/test';
// Hermetic e2e: Playwright boots the Vite dev server in `mock` mode (the in-memory
// fake transport), so the smoke needs no backend/gateway/Postgres.
// Hermetic e2e: Playwright builds the app in `mock` mode (the in-memory fake transport, so the
// smoke needs no backend/gateway/Postgres) and serves the MINIFIED artifact via `vite preview`,
// rather than the dev server. Running against the production-minified bundle is what the contour
// ships, so the e2e now catches minification-only regressions — e.g. a reactive dependency the
// minifier drops — which the unminified dev server silently hid. The build goes to dist-e2e/ so it
// never clobbers the dist/ the bundle-size gate measures.
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
@@ -13,10 +17,15 @@ export default defineConfig({
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm exec vite --mode mock --port 4173 --strictPort',
// `--base /` overrides the production relative base (`./`, which lets the gateway serve the SPA
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
// URLs, so the minified JS under test is identical to the contour's.
command:
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
timeout: 120_000,
},
// Run the same hermetic specs in Chromium and WebKit (Safari's engine) so the UI is
// exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not
+32 -4
View File
@@ -20,6 +20,7 @@
lines,
locale,
focus,
recenter,
dropTarget,
oncell,
ontogglezoom,
@@ -39,6 +40,9 @@
lines: boolean;
locale: Locale;
focus: { row: number; col: number } | null;
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
* is unchanged — the hint recentring an already-zoomed board on the word it played. */
recenter: number;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
@@ -60,10 +64,21 @@
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
// from A to B in one motion instead of chasing a per-frame target that the scroll bounds
// clamp — which made the board lurch one way and then snap back near the edges/centre.
// It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
// It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked,
// so placing a 2nd+ tile or hovering a dragged tile never jumps the board — only a zoom
// toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change
// pans straight to `focus`, since the board width is stable and the width-driven tween below
// has nothing to ride (the case the owner hit: a hint taken while already zoomed in).
// prevZoom/prevRecenter let the effect tell a zoom toggle from a recenter request. The board
// always mounts zoomed-out, so prevZoom starts false; both are updated on each effect run.
let prevZoom = false;
let prevRecenter = 0;
$effect(() => {
const on = zoomed;
// An explicit "scroll to focus now" request (the hint). Read into a variable that is USED below,
// so the reactive dependency survives minification (a bare `void recenter` could be dropped) and
// an incidental re-run never pans on its own.
const rc = recenter;
const vp = viewport;
if (!vp) return;
const f = untrack(() => focus);
@@ -72,8 +87,6 @@
if (clientW === 0) return;
const fitW = clientW; // board width at scale 1 (fills the viewport)
const fullW = clientW * Z; // board width at full zoom
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
let finalSL = 0;
let finalST = 0;
if (on && f) {
@@ -81,6 +94,21 @@
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
}
const toggled = on !== prevZoom;
const recentered = rc !== prevRecenter;
prevZoom = on;
prevRecenter = rc;
if (!toggled) {
// No zoom change: pan straight to the focused word only on an explicit recenter request (the
// hint), never on an incidental re-run such as a viewport resize.
if (recentered && on && f) {
vp.scrollLeft = finalSL;
vp.scrollTop = finalST;
}
return;
}
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
const fromW = on ? fitW : fullW; // board width when this transition begins
const toW = on ? fullW : fitW;
let raf = requestAnimationFrame(function tick() {
+42 -23
View File
@@ -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';
@@ -21,7 +21,7 @@
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
@@ -47,6 +47,10 @@
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
// Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the
// hint recentring an already-zoomed board on the word it just played (a plain focus change is
// deliberately ignored by the board so placing/dragging tiles never jumps it).
let recenter = $state(0);
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
@@ -218,28 +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).
if (view) {
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
setCachedGame(id, view, 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
@@ -628,6 +642,10 @@
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
// Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on
// the next line; this nonce also recentres a board that is already zoomed in, where the
// unchanged zoom state would otherwise leave it parked where the player was looking.
recenter++;
}
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
@@ -928,6 +946,7 @@
lines={app.boardLines}
locale={app.locale}
{focus}
{recenter}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
+17 -4
View File
@@ -27,7 +27,7 @@ import { connection, reportOffline, reportOnline, resetConnection } from './conn
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame } from './lobbycache';
import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -191,9 +191,16 @@ function openStream(): void {
if (c) setCachedGame(e.gameId, c.view, c.moves);
}
} else if (e.kind === 'opponent_joined') {
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot
// from any screen. A mounted game board adopts the change in place itself (game/Game.svelte).
if (e.state) patchLobbyGame(e.state.game);
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot,
// and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free.
// The mounted game board owns its cache (game/Game.svelte), so skip the game in view.
if (e.state) {
patchLobbyGame(e.state.game);
if (!viewingGame(e.gameId)) {
const c = advanceCached(getCachedGame(e.gameId), e);
if (c) setCachedGame(e.gameId, c.view, c.moves);
}
}
} else if (e.kind === 'match_found') {
// Seed both caches from the event's initial state so the game renders instantly on arrival
// and the new game is already in the lobby on a later return, then navigate.
@@ -209,6 +216,12 @@ function openStream(): void {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
// An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list
// fresh from any screen — a new pending one is added, a consumed (started), declined,
// cancelled or expired one is removed (see patchLobbyInvitation).
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
patchLobbyInvitation(e.invitation);
}
void refreshNotifications();
}
},
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
@@ -141,3 +141,30 @@ describe('advanceCached', () => {
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});
describe('applyOpponentJoined', () => {
// joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
// active and both seats are filled.
function joinedState(): StateView {
const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
] };
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
}
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2);
expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
expect(res?.moves).toHaveLength(1);
});
it('returns undefined when nothing is cached for the game', () => {
expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
});
});
+14
View File
@@ -82,5 +82,19 @@ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): Cac
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
}
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
return undefined;
}
/**
* applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the
* refreshed seats, status and player count from the event's StateView onto the cached game, leaving
* the board, the starter's rack and everything else intact (the join changes who is seated, not the
* position). It returns undefined when the game is not cached (the next open cold-loads it). It backs
* both the mounted game board's in-place update and the global handler's off-screen cache warming.
*/
export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined {
if (!cached) return undefined;
const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players };
return { view: { ...cached.view, game }, moves: cached.moves };
}
+62 -2
View File
@@ -1,6 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache';
import type { AccountRef, GameView } from './model';
import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache';
import type { AccountRef, GameView, Invitation } from './model';
function invitation(id: string, status: string): Invitation {
return {
id,
inviter: { accountId: 'inv', displayName: 'Inv' },
invitees: [],
variant: 'scrabble_en',
turnTimeoutSecs: 300,
hintsAllowed: true,
hintsPerPlayer: 0,
multipleWordsPerTurn: true,
dropoutTiles: 'remove',
status,
gameId: '',
expiresAtUnix: 0,
};
}
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return {
@@ -59,3 +76,46 @@ describe('patchLobbyGame', () => {
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
});
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
});
it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()).toBeNull();
});
it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
});
});
+25
View File
@@ -42,6 +42,31 @@ export function patchLobbyGame(g: GameView): void {
snapshot = { ...snapshot, games };
}
/**
* patchLobbyInvitation applies a live invitation change to the cached lobby's invitations list, so a
* change seen while the lobby is unmounted is reflected on its next render instead of a stale card
* lingering until the background refresh. A still-pending invitation is upserted (a new one
* prepended newest-first, an updated one replaced in place); an invitation that reached any terminal
* status (started, declined, cancelled, expired) is removed — the authoritative list holds only
* pending invitations. It is a no-op when there is no snapshot yet, or a terminal invitation is not
* in the list. The lobby re-renders from the snapshot, so the in-place upsert needs no re-sort.
*/
export function patchLobbyInvitation(inv: Invitation): void {
if (!snapshot) return;
const i = snapshot.invitations.findIndex((x) => x.id === inv.id);
let invitations: Invitation[];
if (inv.status !== 'pending') {
if (i === -1) return; // a terminal invitation already absent — nothing to remove
invitations = snapshot.invitations.filter((x) => x.id !== inv.id);
} else if (i === -1) {
invitations = [inv, ...snapshot.invitations];
} else {
invitations = snapshot.invitations.slice();
invitations[i] = inv;
}
snapshot = { ...snapshot, invitations };
}
/** clearLobby drops the cached lobby (called on logout). */
export function clearLobby(): void {
snapshot = null;