diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 859be04..5a2a2e5 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -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) } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 4e53f4d..8a91e34 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -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. diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 970fda5..65ee14e 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -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) diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index ce34df8..2ecc030 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -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 diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 0aec6be..7680360 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -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"}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3a545e..1ab4251 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 206678b..1affad6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 diff --git a/ui/.gitignore b/ui/.gitignore index 612bbcc..4917c73 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-e2e/ .svelte-kit/ *.tsbuildinfo test-results/ diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 4e3b388..9271dc2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -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); +}); diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index 0893b45..de2ff71 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -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); +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index cc28f70..78e5d74 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -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 diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 9c33563..cf078dc 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -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() { diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e4c928d..580d93a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,5 +1,5 @@