Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-e2e/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
test-results/
|
test-results/
|
||||||
|
|||||||
@@ -76,3 +76,17 @@ test('quick game: a foreground resync recovers a join shed while the stream stay
|
|||||||
await expect(page.getByText('Robo')).toBeVisible();
|
await expect(page.getByText('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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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; }}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user