Compare commits

...

6 Commits

Author SHA1 Message Date
developer c7e177f911 Merge pull request 'feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board' (#56) from feature/lobby-cache-any-screen-invitation-delta into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-14 10:57:37 +00:00
Ilia Denisov abe1038333 test(ui): run e2e against the minified vite build, not the dev server
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
The e2e booted the unminified Vite dev server, so production-minification bugs
were invisible to it — exactly how a minifier dropping a bare `void recenter`
reactive read slipped through. Build the app in mock mode and serve the minified
artifact via `vite preview` instead, so the smoke exercises the same bundle the
contour ships. Build to dist-e2e/ (gitignored) so it never clobbers the dist/ the
bundle-size gate measures, and with `--base /` so the SPA-fallback also boots a
subpath like /telegram/ (the production relative base needs the gateway's path
mapping, absent under a plain preview). All 118 specs pass against the build on
both engines, including the hint-recenter spec — confirming the hardened
dependency survives minification.
2026-06-14 12:54:44 +02:00
Ilia Denisov 553152e195 refactor(ui): harden the board's recenter dependency and gate it on a real bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
The recenter effect declared its dependency with a bare `void recenter`, which a
production minifier could drop as a side-effect-free statement (the e2e runs only
the unminified dev server, so it would not catch that). Read the nonce into a
used variable and gate the pan on it actually changing (`recentered`), so the
reactive dependency is robust to minification and the board pans only on an
explicit hint recenter — never on an incidental re-run such as a viewport resize.
2026-06-14 12:40:08 +02:00
Ilia Denisov 1cc6c0d56e fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop)
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot
and direct reads) AND wrote `view` in its branches, so those reads became the
effect's own dependencies: writing `view = …` re-ran the effect, and with
app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a
tight self-invalidating loop that pinned the main thread, freezing the board and
rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the
move count (no cache → no write on the second pass); opponent_joined and game_over
have no such guard, so an opponent joining hung the whole screen. Tracking
`placement` similarly re-fired the handler on every tile the player placed after
an opponent's move (a spurious reload).

Fix: the effect must depend only on app.lastEvent and process each event once.
Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of
the effect's dependency set; the writes inside no longer re-trigger it.

e2e: after the opponent joins, placing a rack tile must render a pending tile —
verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after.
2026-06-14 12:39:57 +02:00
Ilia Denisov f3914af793 fix(ui): recentre the board on a hint taken while already zoomed in
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
The board only scrolled to the hint's word when the hint also toggled zoom (the
zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked,
so a hint taken while ALREADY zoomed in — no zoom change — never recentred and
the board stayed parked where the player was looking.

Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and,
when it fires without a zoom toggle, pans straight to `focus` (the board width is
stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile
or hovering a dragged tile still set `focus` without the nonce, so the board
never jumps on those — the original no-jump intent is preserved.

e2e: zoom into the corner, then hint (the mock plays at the centre) — the board
pans toward the centre. Verified RED without the fix (both engines), GREEN with.
2026-06-14 11:44:04 +02:00
Ilia Denisov 56dbf86472 feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Builds on the cross-screen cache work: the global stream handler now keeps both
caches current no matter which screen is mounted, and invitations become a live
delta channel so the lobby's invitations list is fresh from any screen too.

Client (boundary already started):
- advanceCached now also folds opponent_joined into a not-currently-viewed game's
  cache via a new pure reducer applyOpponentJoined (extracted and reused by the
  mounted game board), so opening an open game that filled while you were elsewhere
  is flash-free.
- patchLobbyInvitation upserts a still-pending invitation and removes a terminal
  one (started/declined/cancelled/expired); the global notify handler calls it on
  the invitation / invitation_update sub-kinds.

Invitations delta channel (no wire/gateway/connector change — the notification
already carries the full invitation with id/status/game_id end to end):
- notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate
  constructor (shares encoding with NotificationInvitation). The Telegram connector
  renders no message for it, so a decline/cancel never becomes an out-of-app push.
- lobby: emit the changed invitation to every participant on respond (accept/decline),
  on the final accept's game start, and on cancel — so each participant's lobby patches
  its list in place. The authoritative list holds only pending invitations, so the
  client's pending-vs-terminal rule matches it exactly.

Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
2026-06-14 11:22:47 +02:00
18 changed files with 400 additions and 49 deletions
+29
View File
@@ -13,6 +13,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
) )
// newInvitationService builds an invitation service over the shared pool, starting // newInvitationService builds an invitation service over the shared pool, starting
@@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
func TestInvitationAllAcceptStartsGame(t *testing.T) { func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newInvitationService() svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t) inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), 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 { if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final) 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) seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil { if err != nil {
t.Fatalf("participants: %v", err) t.Fatalf("participants: %v", err)
@@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
func TestInvitationDeclineCancels(t *testing.T) { func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newInvitationService() svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t) inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)} invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) {
if got.Status != "declined" || got.GameID != nil { if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got) 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. // A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) { if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err) t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
@@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) {
func TestInvitationCancelByInviter(t *testing.T) { func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newInvitationService() svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t) inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)} invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) 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 { if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err) 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) { if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err) 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...) 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, // invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
// resolving the inviter's and invitees' display names from the account store. // resolving the inviter's and invitees' display names from the account store.
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary { 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 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. // 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. // CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error { 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. // 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, // NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
// so the client adds it to its lobby invitations list without a refetch. // so the client adds it to its lobby invitations list without a refetch.
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent { 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) b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation) k := b.CreateString(sub)
invOff := buildInvitation(b, inv) invOff := buildInvitation(b, inv)
fb.NotificationEventStart(b) fb.NotificationEventStart(b)
fb.NotificationEventAddKind(b, k) 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. // game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined" NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation" 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 // 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) { func TestNotificationAccountCarriesAccount(t *testing.T) {
uid := uuid.New() uid := uuid.New()
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}) 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** 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** (from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request, (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 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), 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, 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 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, 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 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 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 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 `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 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 (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 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 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 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; 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. Operator broadcasts 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 (`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 **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). 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 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` 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, 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 opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted 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 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. `load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour - **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
dist-e2e/
.svelte-kit/ .svelte-kit/
*.tsbuildinfo *.tsbuildinfo
test-results/ 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('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); 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.x).toBe('none');
expect(ob.y).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'; import { defineConfig, devices } from '@playwright/test';
// Hermetic e2e: Playwright boots the Vite dev server in `mock` mode (the in-memory // Hermetic e2e: Playwright builds the app in `mock` mode (the in-memory fake transport, so the
// fake transport), so the smoke needs no backend/gateway/Postgres. // 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({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: true, fullyParallel: true,
@@ -13,10 +17,15 @@ export default defineConfig({
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
webServer: { 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', url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI, 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 // 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 // exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not
+32 -4
View File
@@ -20,6 +20,7 @@
lines, lines,
locale, locale,
focus, focus,
recenter,
dropTarget, dropTarget,
oncell, oncell,
ontogglezoom, ontogglezoom,
@@ -39,6 +40,9 @@
lines: boolean; lines: boolean;
locale: Locale; locale: Locale;
focus: { row: number; col: number } | null; 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. */ /** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null; dropTarget: { row: number; col: number } | null;
oncell: (row: number, col: number) => void; 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 // 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 // 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. // 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 // It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked,
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board. // 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(() => { $effect(() => {
const on = zoomed; 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; const vp = viewport;
if (!vp) return; if (!vp) return;
const f = untrack(() => focus); const f = untrack(() => focus);
@@ -72,8 +87,6 @@
if (clientW === 0) return; if (clientW === 0) return;
const fitW = clientW; // board width at scale 1 (fills the viewport) const fitW = clientW; // board width at scale 1 (fills the viewport)
const fullW = clientW * Z; // board width at full zoom const fullW = clientW * Z; // board width at full zoom
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
let finalSL = 0; let finalSL = 0;
let finalST = 0; let finalST = 0;
if (on && f) { if (on && f) {
@@ -81,6 +94,21 @@
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW)); 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)); 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 fromW = on ? fitW : fullW; // board width when this transition begins
const toW = on ? fullW : fitW; const toW = on ? fullW : fitW;
let raf = requestAnimationFrame(function tick() { let raf = requestAnimationFrame(function tick() {
+42 -23
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte'; import TapConfirm from '../components/TapConfirm.svelte';
@@ -21,7 +21,7 @@
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; 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 { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
@@ -47,6 +47,10 @@
let zoomed = $state(false); let zoomed = $state(false);
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: 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 // 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. // tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]); let rackIds = $state<number[]>([]);
@@ -218,28 +222,38 @@
$effect(() => { $effect(() => {
const e = app.lastEvent; const e = app.lastEvent;
if (!e) return; if (!e) return;
if (e.kind === 'opponent_moved' && e.gameId === id) { // Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // view/moves/placement, so tracking those reads would re-run this effect from its own
// the move as a delta with no fetch. // `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
if (placement.pending.length > 0) void load(); // opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); // game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
} else if (e.kind === 'your_turn' && e.gameId === id) { // likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch // out of the effect's dependencies.
// only if we missed the move (our cached count trails the event's). untrack(() => {
if (view && e.moveCount > view.game.moveCount) void load(); if (e.kind === 'opponent_moved' && e.gameId === id) {
} else if (e.kind === 'game_over' && e.gameId === id) { // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
applyDelta(applyGameOver(cacheSnapshot(), e.game)); // the move as a delta with no fetch.
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) { if (placement.pending.length > 0) void load();
// The opponent took the empty seat: adopt the new participants and status in place, else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
// leaving the board, rack and any pending placement untouched (no refetch, no flicker). } else if (e.kind === 'your_turn' && e.gameId === id) {
if (view) { // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } }; // only if we missed the move (our cached count trails the event's).
setCachedGame(id, view, moves); 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 // 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), row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 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; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining }; view = { ...view, hintsRemaining: h.hintsRemaining };
@@ -928,6 +946,7 @@
lines={app.boardLines} lines={app.boardLines}
locale={app.locale} locale={app.locale}
{focus} {focus}
{recenter}
{dropTarget} {dropTarget}
oncell={onCell} oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} 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 { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta'; import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame } from './lobbycache'; import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
import type { BoardLabelMode } from './boardlabels'; import type { BoardLabelMode } from './boardlabels';
export interface Toast { export interface Toast {
@@ -191,9 +191,16 @@ function openStream(): void {
if (c) setCachedGame(e.gameId, c.view, c.moves); if (c) setCachedGame(e.gameId, c.view, c.moves);
} }
} else if (e.kind === 'opponent_joined') { } else if (e.kind === 'opponent_joined') {
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot // 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). // and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free.
if (e.state) patchLobbyGame(e.state.game); // 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') { } else if (e.kind === 'match_found') {
// Seed both caches from the event's initial state so the game renders instantly on arrival // 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. // 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, []); setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game); 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(); void refreshNotifications();
} }
}, },
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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 { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model'; import type { GameView, MoveRecord, PushEvent, StateView } from './model';
@@ -141,3 +141,30 @@ describe('advanceCached', () => {
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined(); 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; 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 === 'game_over') return applyGameOver(cached, e.game).cache;
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
return undefined; 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 { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache'; import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache';
import type { AccountRef, GameView } from './model'; 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 { function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return { return {
@@ -59,3 +76,46 @@ describe('patchLobbyGame', () => {
expect(games[0].toMove).toBe(0); // the original array/object is left intact 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 }; 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). */ /** clearLobby drops the cached lobby (called on logout). */
export function clearLobby(): void { export function clearLobby(): void {
snapshot = null; snapshot = null;