Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -232,9 +232,10 @@
|
||||
} 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);
|
||||
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.
|
||||
|
||||
@@ -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