feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board #56
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-e2e/
|
||||
.svelte-kit/
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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; }}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user