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.
This commit is contained in:
Ilia Denisov
2026-06-14 11:22:47 +02:00
parent 5312b11f0e
commit 56dbf86472
13 changed files with 269 additions and 22 deletions
+29
View File
@@ -13,6 +13,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
)
// newInvitationService builds an invitation service over the shared pool, starting
@@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
@@ -118,6 +121,16 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final)
}
// Every participant is told the invitation changed so their lobby drops the (now started) card
// from any screen, and the started-game notification lands too.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update", u)
}
}
if !pub.notified(invitees[0], notify.NotifyGameStarted) {
t.Errorf("invitee missing game_started")
}
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil {
t.Fatalf("participants: %v", err)
@@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) {
if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got)
}
// A decline kills the whole invitation: every participant is notified to drop it.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after decline", u)
}
}
// A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
@@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) {
func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -197,6 +220,12 @@ func TestInvitationCancelByInviter(t *testing.T) {
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err)
}
// The cancellation reaches every participant so an invitee's lobby drops the card from any screen.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after cancel", u)
}
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
+37 -2
View File
@@ -136,6 +136,25 @@ func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game,
svc.pub.Publish(intents...)
}
// emitInvitationUpdate publishes a changed invitation to every participant (the inviter and all
// invitees), so each one's lobby patches its invitations list without a refetch: a still-pending
// invitation is upserted (an updated response) and a terminal one (started, declined, cancelled,
// expired) removed. It is in-app only — notify.NotifyInvitationUpdate carries no out-of-app push —
// so a withdrawal or decline never becomes a platform notification.
func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invitation) {
summary := svc.invitationSummary(ctx, inv)
recipients := make([]uuid.UUID, 0, len(inv.Invitees)+1)
recipients = append(recipients, inv.InviterID)
for _, iv := range inv.Invitees {
recipients = append(recipients, iv.AccountID)
}
intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients {
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
}
svc.pub.Publish(intents...)
}
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
// resolving the inviter's and invitees' display names from the account store.
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
@@ -251,7 +270,15 @@ func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationI
return Invitation{}, err
}
}
return svc.store.loadInvitation(ctx, invitationID)
inv, err := svc.store.loadInvitation(ctx, invitationID)
if err != nil {
return Invitation{}, err
}
// Tell every participant the invitation changed, so their lobby patches its list from any
// screen: an updated response is upserted, a terminal one (declined, or started after the final
// accept) removed. startGame already emitted the started-game notification separately.
svc.emitInvitationUpdate(ctx, inv)
return inv, nil
}
// startGame creates the game for a fully-accepted invitation and marks it started.
@@ -289,7 +316,15 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
if err := svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()); err != nil {
return err
}
// The invitation is now cancelled: tell every participant so an invitee's lobby drops the card
// from any screen. Best-effort — a failed reload never fails the cancel.
if inv, err := svc.store.loadInvitation(ctx, invitationID); err == nil {
svc.emitInvitationUpdate(ctx, inv)
}
return nil
}
// GetInvitation loads an invitation with its invitees.
+14 -1
View File
@@ -182,8 +182,21 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
// so the client adds it to its lobby invitations list without a refetch.
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitation, inv)
}
// NotificationInvitationUpdate builds the NotifyInvitationUpdate notification carrying a changed
// invitation, so the client patches its lobby invitations list without a refetch — upserting a
// still-pending one (an updated response) and removing one that reached a terminal status.
func NotificationInvitationUpdate(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitationUpdate, inv)
}
// notificationInvitation builds a KindNotification intent of the given sub-kind carrying the
// invitation summary; it backs both the new-invitation and invitation-update notifications.
func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) Intent {
b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation)
k := b.CreateString(sub)
invOff := buildInvitation(b, inv)
fb.NotificationEventStart(b)
fb.NotificationEventAddKind(b, k)
+7 -1
View File
@@ -46,7 +46,13 @@ const (
// game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
// NotifyInvitationUpdate carries a changed invitation — an updated invitee response, or a
// terminal status (started, declined, cancelled, expired) — so the client patches its lobby
// invitations list without a refetch. Unlike NotifyInvitation (a brand-new invitation), it is
// in-app only: the Telegram connector renders no message for it, so a withdrawal or decline never
// becomes an out-of-app push.
NotifyInvitationUpdate = "invitation_update"
NotifyGameStarted = "game_started"
)
// Intent is one live event destined for a single user. Payload is the
+17
View File
@@ -181,6 +181,23 @@ func TestNotificationInvitationCarriesInvitation(t *testing.T) {
}
}
func TestNotificationInvitationUpdateCarriesTerminalInvitation(t *testing.T) {
uid := uuid.New()
inv := notify.InvitationSummary{ID: "inv-9", Variant: "scrabble_en", Status: "declined"}
in := notify.NotificationInvitationUpdate(uid, inv)
if in.Kind != notify.KindNotification {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyInvitationUpdate {
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitationUpdate)
}
got := ev.Invitation(nil)
if got == nil || string(got.Id()) != "inv-9" || string(got.Status()) != "declined" {
t.Fatalf("invitation wrong: %+v", got)
}
}
func TestNotificationAccountCarriesAccount(t *testing.T) {
uid := uuid.New()
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})