From 41a642ef974dd6ca6c78c20ce38c3c05ecb9e7fe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 10 Jun 2026 08:01:50 +0200 Subject: [PATCH] =?UTF-8?q?R4:=20push=20enrichment=20=E2=80=94=20events=20?= =?UTF-8?q?carry=20a=20state=20delta,=20kill=20the=20last=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback. - pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS. - backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size. - gateway: MoveResult transcode carries rack+bag_len. - ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false. - docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements. --- PRERELEASE.md | 38 +++- backend/README.md | 5 +- backend/internal/game/emit_test.go | 4 +- backend/internal/game/eventwire.go | 79 +++++++ backend/internal/game/service.go | 32 ++- backend/internal/game/types.go | 10 +- backend/internal/lobby/invitations.go | 70 ++++++- backend/internal/lobby/lobby.go | 7 +- backend/internal/lobby/matchmaker.go | 16 +- backend/internal/lobby/matchmaker_test.go | 7 + backend/internal/notify/encode.go | 197 ++++++++++++++++++ backend/internal/notify/events.go | 101 +++++++-- backend/internal/notify/notify_test.go | 100 ++++++++- backend/internal/notify/payload.go | 89 ++++++++ backend/internal/server/dto.go | 26 ++- backend/internal/server/handlers_game.go | 6 +- backend/internal/social/friendcodes.go | 2 +- backend/internal/social/friends.go | 19 +- docs/ARCHITECTURE.md | 21 +- docs/FUNCTIONAL.md | 3 +- docs/FUNCTIONAL_ru.md | 3 +- gateway/README.md | 4 +- gateway/internal/backendclient/api.go | 9 +- gateway/internal/transcode/encode.go | 7 + pkg/fbs/scrabble.fbs | 42 +++- pkg/fbs/scrabblefb/GameOverEvent.go | 18 +- pkg/fbs/scrabblefb/MatchFoundEvent.go | 18 +- pkg/fbs/scrabblefb/MoveResult.go | 57 ++++- pkg/fbs/scrabblefb/NotificationEvent.go | 50 ++++- pkg/fbs/scrabblefb/OpponentMovedEvent.go | 49 ++++- pkg/fbs/scrabblefb/YourTurnEvent.go | 17 +- ui/README.md | 4 +- ui/src/game/Game.svelte | 69 ++++-- ui/src/gen/fbs/scrabblefb/game-over-event.ts | 21 +- .../gen/fbs/scrabblefb/match-found-event.ts | 19 +- ui/src/gen/fbs/scrabblefb/move-result.ts | 42 +++- .../gen/fbs/scrabblefb/notification-event.ts | 39 +++- .../fbs/scrabblefb/opponent-moved-event.ts | 42 +++- ui/src/gen/fbs/scrabblefb/your-turn-event.ts | 14 +- ui/src/lib/app.svelte.ts | 22 +- ui/src/lib/codec.ts | 60 +++++- ui/src/lib/gamecache.ts | 2 +- ui/src/lib/gamedelta.test.ts | 99 +++++++++ ui/src/lib/gamedelta.ts | 69 ++++++ ui/src/lib/mock/client.ts | 8 +- ui/src/lib/model.ts | 20 +- ui/src/screens/NewGame.svelte | 58 ++++-- 47 files changed, 1514 insertions(+), 180 deletions(-) create mode 100644 backend/internal/game/eventwire.go create mode 100644 backend/internal/notify/encode.go create mode 100644 backend/internal/notify/payload.go create mode 100644 ui/src/lib/gamedelta.test.ts create mode 100644 ui/src/lib/gamedelta.ts diff --git a/PRERELEASE.md b/PRERELEASE.md index 51a2c75..4940155 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -20,7 +20,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R1 | Schema & naming reset | 1 + 10 | **done** | | R2 | Stress harness + contour observability + early run | 9a | **done** | | R3 | Edge hardening | 2 + 8 + 3 | **done** | -| R4 | Push enrichment + kill the last poll | 4 + 5 | todo | +| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** | | R5 | Bundle slimming | 6 | todo | | R6 | Refactor + docs reconciliation + de-staging | 7 | todo | | R7 | Final stress run + tuning | 9b | todo | @@ -281,3 +281,39 @@ Then Stage 18. queue) and the flag badge / clear action on the user list / card. - The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models (their tables were added after the last jetgen run; no behaviour change). + +- **R4** (interview + implementation): + - **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and + the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the + actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` / + `game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback + refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while + the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`). + - **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only + the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found` + (+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4 + `opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant + with `move`/`game` — slated for the R6 de-stage. + - **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new + `encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs + + `engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the + wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders + (different source types) — a candidate consolidation. + - **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign` + **response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the + next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`. + - **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`, + and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the + variant alphabet embedded for a first-seen variant). + - **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`, + unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte` + applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack); + `NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered + match is not cancelled. + - **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the + wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight + **authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather + than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the + goal. Deeper lobby-cache consumption is an easy follow-up. + - **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips + + `emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta. diff --git a/backend/README.md b/backend/README.md index 724642f..e968a21 100644 --- a/backend/README.md +++ b/backend/README.md @@ -53,8 +53,9 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and -exposes `Poll` so a waiting player can collect the started game (the live -match-found notification arrives with the `gateway`). +exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down +fallback**: while the client is streaming, the live match-found push (enriched with the recipient's +initial game state) drives it instead. Stage 6 opens the backend to the edge. The route groups gain their first handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go index c9f7acf..db953de 100644 --- a/backend/internal/game/emit_test.go +++ b/backend/internal/game/emit_test.go @@ -33,7 +33,7 @@ func TestEmitMoveNotifiesActor(t *testing.T) { TurnTimeout: time.Hour, Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}}, } - svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}) + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80) kinds := map[uuid.UUID][]string{} var yourTurn notify.Intent @@ -87,7 +87,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) { EndReason: "out_of_tiles", Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}}, } - svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}) + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0) over := map[uuid.UUID]notify.Intent{} for _, in := range pub.intents { diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go new file mode 100644 index 0000000..5ee04ca --- /dev/null +++ b/backend/internal/game/eventwire.go @@ -0,0 +1,79 @@ +package game + +import ( + "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" +) + +// The mappers below project the game domain into the wire-agnostic notify.* input +// structs the enriched live events carry (R4). They keep the wire schema out of the +// game package: notify owns the FlatBuffers encoding, this file only resolves the +// values (seat display names, last-activity sort key) into its input shapes. + +// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched +// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix +// mirrors the gateway view (the current turn's start while active, the finish time once +// finished). +func gameSummary(g Game, names []string) notify.GameSummary { + seats := make([]notify.SeatStanding, 0, len(g.Seats)) + for _, s := range g.Seats { + name := "" + if s.Seat >= 0 && s.Seat < len(names) { + name = names[s.Seat] + } + seats = append(seats, notify.SeatStanding{ + Seat: s.Seat, + AccountID: s.AccountID.String(), + DisplayName: name, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, + }) + } + last := g.TurnStartedAt + if g.FinishedAt != nil { + last = *g.FinishedAt + } + return notify.GameSummary{ + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: last.Unix(), + } +} + +// playerState projects a StateView into the notify.PlayerState carried by the +// match_found / game_started events. The rack is re-encoded to wire alphabet indices; +// the variant alphabet display table is embedded when includeAlphabet is set (an +// initial view whose recipient may not have cached the variant yet). +func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) { + rack, err := engine.EncodeRack(v.Game.Variant, v.Rack) + if err != nil { + return notify.PlayerState{}, err + } + ps := notify.PlayerState{ + Game: gameSummary(v.Game, names), + Seat: v.Seat, + Rack: rack, + BagLen: v.BagLen, + HintsRemaining: v.HintsRemaining, + } + if includeAlphabet { + tab, err := engine.AlphabetTable(v.Game.Variant) + if err != nil { + return notify.PlayerState{}, err + } + ps.Alphabet = make([]notify.AlphabetLetter, len(tab)) + for i, e := range tab { + ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value} + } + } + return ps, nil +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index bf1e068..90859dc 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -291,7 +291,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, // Record the seat's think time (turn start to commit) for the move-duration // metric; the timeout path commits separately and is excluded by design. svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt)) - return MoveResult{Move: rec, Game: post}, nil + return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil } // afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own @@ -362,7 +362,7 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game if err != nil { return Game{}, err } - svc.emitMove(ctx, post, rec) + svc.emitMove(ctx, post, rec, g.BagLen()) return post, nil } @@ -373,10 +373,13 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game // out-of-app push), so the actor is not notified out of band about their own move. // Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each // event out to all of the recipient's live streams. -func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord) { +func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) { + // Resolve the seat names once and reuse them for every recipient's enriched summary. + names := svc.seatNames(ctx, post) + summary := gameSummary(post, names) intents := make([]notify.Intent, 0, 2*len(post.Seats)) for _, s := range post.Seats { - intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) + intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) } // Game pushes are routed out-of-app by the game's own language, not the recipient's // last-login bot (Stage 17). @@ -391,7 +394,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco word = rec.Words[0] } opponent := svc.displayName(ctx, post.Seats, rec.Player) - yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove)) + yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn.Language = lang intents = append(intents, yourTurn) } @@ -400,7 +403,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco // seat, each with their own perspective + recipient-first score, so an offline player gets // an out-of-app "game over" push (online players take it from the in-app refresh). for _, s := range post.Seats { - over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat)) + over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) over.Language = lang intents = append(intents, over) } @@ -785,6 +788,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) }, nil } +// InitialState returns accountID's full initial view of game gameID as the notify +// PlayerState carried by the match_found / game_started events (R4), so a client can +// render a freshly started game from the event without a follow-up fetch. The variant +// alphabet table is always embedded (the recipient may be seeing the variant for the +// first time). It satisfies lobby.GameCreator. +func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) { + v, err := svc.GameState(ctx, gameID, accountID) + if err != nil { + return notify.PlayerState{}, err + } + names := svc.seatNames(ctx, v.Game) + return playerState(v, names, true) +} + // Participants returns the seated account IDs in seat order, the seat index whose // turn it is, and the game status. It is a snapshot read (no engine, no lock) that // lets the social package gate per-game chat and nudges without importing the @@ -1009,6 +1026,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er // seatNames resolves each seat's display name for GCG export. func (svc *Service) seatNames(ctx context.Context, g Game) []string { names := make([]string, g.Players) + if svc.accounts == nil { + return names + } for _, s := range g.Seats { if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil { names[s.Seat] = acc.DisplayName diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index fc2e031..e523899 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -124,10 +124,14 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) { } // MoveResult is the outcome of a committed transition: the decoded move and the -// post-move game. +// post-move game, plus the actor's own refilled rack and the bag size after the draw +// (Rack/BagLen, R4), so the mover renders the next state from the response without a +// follow-up game.state. type MoveResult struct { - Move engine.MoveRecord - Game Game + Move engine.MoveRecord + Game Game + Rack []string + BagLen int } // HintResult is a revealed hint and the requesting player's remaining hint diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 92043dc..f9b762a 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -105,18 +105,72 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) { } } -// notify publishes a re-poll Notification of the given sub-kind to each user. -func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) { - if len(userIDs) == 0 { +// emitInvitation publishes the invitation notification to each invitee, carrying the invitation +// itself so the client adds it to its lobby list without a refetch (R4). +func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) { + if len(inviteeIDs) == 0 { return } - intents := make([]notify.Intent, 0, len(userIDs)) - for _, id := range userIDs { - intents = append(intents, notify.Notification(id, kind)) + summary := svc.invitationSummary(ctx, inv) + intents := make([]notify.Intent, 0, len(inviteeIDs)) + for _, id := range inviteeIDs { + intents = append(intents, notify.NotificationInvitation(id, summary)) } svc.pub.Publish(intents...) } +// emitGameStarted publishes the game_started notification to each seated player, carrying their +// initial view of the started game so the client seeds its game cache without a refetch (R4). A +// seat whose state cannot be read is skipped (it still sees the game on the next lobby load). +func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) { + intents := make([]notify.Intent, 0, len(seats)) + for _, id := range seats { + state, err := svc.games.InitialState(ctx, g.ID, id) + if err != nil { + continue + } + intents = append(intents, notify.NotificationGameStarted(id, state)) + } + 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 { + name := func(id uuid.UUID) string { + if acc, err := svc.accounts.GetByID(ctx, id); err == nil { + return acc.DisplayName + } + return "" + } + invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees)) + for _, iv := range inv.Invitees { + invitees = append(invitees, notify.InvitationInvitee{ + AccountID: iv.AccountID.String(), + DisplayName: name(iv.AccountID), + Seat: iv.Seat, + Response: iv.Response, + }) + } + gameID := "" + if inv.GameID != nil { + gameID = inv.GameID.String() + } + return notify.InvitationSummary{ + ID: inv.ID.String(), + Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, + Invitees: invitees, + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles.String(), + Status: inv.Status, + GameID: gameID, + ExpiresAtUnix: inv.ExpiresAt.Unix(), + } +} + // CreateInvitation records a pending invitation from inviterID to inviteeIDs (in // seat order, 1..N) with the given settings. The total seat count must be 2-4, // invitees distinct and not the inviter, every invitee an existing account with no @@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu if err != nil { return Invitation{}, err } - svc.notify(notify.NotifyInvitation, inviteeIDs...) + svc.emitInvitation(ctx, inv, inviteeIDs) return inv, nil } @@ -224,7 +278,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil { return err } - svc.notify(notify.NotifyGameStarted, seats...) + svc.emitGameStarted(ctx, g, seats) return nil } diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 8908a38..10065ea 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -14,12 +14,17 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" ) // GameCreator is the slice of the game domain the lobby needs: starting a seated -// game. game.Service satisfies it. +// game and reading a player's initial view of it. game.Service satisfies it. type GameCreator interface { Create(ctx context.Context, params game.CreateParams) (game.Game, error) + // InitialState returns a seated player's full initial view of a started game, used + // to enrich the match_found / game_started events so the client renders the new game + // without a follow-up fetch (R4). + InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } // RobotProvider supplies a robot account to substitute for a missing human in diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 56bcd11..dc334c2 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -75,11 +75,19 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) { // emitMatchFound pushes match_found to every seat of a freshly started game. // Emitting to a robot seat is harmless (no client subscription exists for it). -func (m *Matchmaker) emitMatchFound(g game.Game) { +func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17) intents := make([]notify.Intent, 0, len(g.Seats)) for _, s := range g.Seats { - mf := notify.MatchFound(s.AccountID, g.ID) + state, err := m.games.InitialState(ctx, g.ID, s.AccountID) + if err != nil { + // A waiter still discovers the game through Poll (the ws-down fallback), so skip the + // enriched push for this seat rather than failing the match. + m.log.Warn("match_found initial state", + zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err)) + continue + } + mf := notify.MatchFound(s.AccountID, g.ID, state) mf.Language = lang intents = append(intents, mf) } @@ -128,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e m.mu.Lock() m.results[opponent] = g m.mu.Unlock() - m.emitMatchFound(g) + m.emitMatchFound(ctx, g) return EnqueueResult{Matched: true, Game: g}, nil } @@ -227,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { m.mu.Lock() m.results[s.human] = g m.mu.Unlock() - m.emitMatchFound(g) + m.emitMatchFound(ctx, g) } } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 4092e57..e6403ef 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -11,6 +11,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" ) // fakeCreator records the games a matchmaker asks it to start. @@ -27,6 +28,12 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil } +// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing +// tests assert on matching behaviour, not the payload, so an empty state is enough. +func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { + return notify.PlayerState{}, nil +} + // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model // an empty pool. It records the variant of the last substitution request. type fakeRobots struct { diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go new file mode 100644 index 0000000..076d2ef --- /dev/null +++ b/backend/internal/notify/encode.go @@ -0,0 +1,197 @@ +package notify + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/backend/internal/engine" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// The builders below encode the nested wire tables embedded in enriched event +// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's +// already-resolved values (notify.* input structs and the decoded engine.MoveRecord) +// rather than the gateway's REST DTOs. Each returns the offset of the table it built; +// callers must build every nested table before opening the parent event table. + +// buildGameView builds a GameView table from a GameSummary and returns its offset. +func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT { + seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) + for i, s := range g.Seats { + aid := b.CreateString(s.AccountID) + dname := b.CreateString(s.DisplayName) + fb.SeatViewStart(b) + fb.SeatViewAddSeat(b, int32(s.Seat)) + fb.SeatViewAddAccountId(b, aid) + fb.SeatViewAddScore(b, int32(s.Score)) + fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) + fb.SeatViewAddIsWinner(b, s.IsWinner) + fb.SeatViewAddDisplayName(b, dname) + seatOffs[i] = fb.SeatViewEnd(b) + } + fb.GameViewStartSeatsVector(b, len(seatOffs)) + for i := len(seatOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(seatOffs[i]) + } + seats := b.EndVector(len(seatOffs)) + + id := b.CreateString(g.ID) + variant := b.CreateString(g.Variant) + dictVer := b.CreateString(g.DictVersion) + status := b.CreateString(g.Status) + endReason := b.CreateString(g.EndReason) + + fb.GameViewStart(b) + fb.GameViewAddId(b, id) + fb.GameViewAddVariant(b, variant) + fb.GameViewAddDictVersion(b, dictVer) + fb.GameViewAddStatus(b, status) + fb.GameViewAddPlayers(b, int32(g.Players)) + fb.GameViewAddToMove(b, int32(g.ToMove)) + fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) + fb.GameViewAddMoveCount(b, int32(g.MoveCount)) + fb.GameViewAddEndReason(b, endReason) + fb.GameViewAddSeats(b, seats) + fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) + return fb.GameViewEnd(b) +} + +// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns +// its offset. The values match the move-result DTO (Count is the engine count: the +// number of tiles swapped on an exchange, zero otherwise). +func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT { + tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles)) + for i, t := range m.Tiles { + letter := b.CreateString(t.Letter) + fb.TileRecordStart(b) + fb.TileRecordAddRow(b, int32(t.Row)) + fb.TileRecordAddCol(b, int32(t.Col)) + fb.TileRecordAddLetter(b, letter) + fb.TileRecordAddBlank(b, t.Blank) + tileOffs[i] = fb.TileRecordEnd(b) + } + fb.MoveRecordStartTilesVector(b, len(tileOffs)) + for i := len(tileOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(tileOffs[i]) + } + tiles := b.EndVector(len(tileOffs)) + + wordOffs := make([]flatbuffers.UOffsetT, len(m.Words)) + for i, w := range m.Words { + wordOffs[i] = b.CreateString(w) + } + fb.MoveRecordStartWordsVector(b, len(wordOffs)) + for i := len(wordOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(wordOffs[i]) + } + words := b.EndVector(len(wordOffs)) + + action := b.CreateString(m.Action.String()) + dir := b.CreateString(m.Dir.String()) + fb.MoveRecordStart(b) + fb.MoveRecordAddPlayer(b, int32(m.Player)) + fb.MoveRecordAddAction(b, action) + fb.MoveRecordAddDir(b, dir) + fb.MoveRecordAddMainRow(b, int32(m.MainRow)) + fb.MoveRecordAddMainCol(b, int32(m.MainCol)) + fb.MoveRecordAddTiles(b, tiles) + fb.MoveRecordAddWords(b, words) + fb.MoveRecordAddCount(b, int32(m.Count)) + fb.MoveRecordAddScore(b, int32(m.Score)) + fb.MoveRecordAddTotal(b, int32(m.Total)) + return fb.MoveRecordEnd(b) +} + +// buildAlphabet builds the AlphabetEntry vector embedded in a StateView and returns +// its offset. +func buildAlphabet(b *flatbuffers.Builder, entries []AlphabetLetter) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(entries)) + for i, e := range entries { + letter := b.CreateString(e.Letter) + fb.AlphabetEntryStart(b) + fb.AlphabetEntryAddIndex(b, byte(e.Index)) + fb.AlphabetEntryAddLetter(b, letter) + fb.AlphabetEntryAddValue(b, int32(e.Value)) + offs[i] = fb.AlphabetEntryEnd(b) + } + fb.StateViewStartAlphabetVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + +// buildStateView builds a StateView table from a PlayerState and returns its offset. +func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT { + game := buildGameView(b, s.Game) + rackBytes := make([]byte, len(s.Rack)) + for i, v := range s.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) + hasAlphabet := len(s.Alphabet) > 0 + var alphabet flatbuffers.UOffsetT + if hasAlphabet { + alphabet = buildAlphabet(b, s.Alphabet) + } + fb.StateViewStart(b) + fb.StateViewAddGame(b, game) + fb.StateViewAddSeat(b, int32(s.Seat)) + fb.StateViewAddRack(b, rack) + fb.StateViewAddBagLen(b, int32(s.BagLen)) + fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) + if hasAlphabet { + fb.StateViewAddAlphabet(b, alphabet) + } + return fb.StateViewEnd(b) +} + +// buildAccountRef builds an AccountRef table and returns its offset. +func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT { + aid := b.CreateString(a.AccountID) + name := b.CreateString(a.DisplayName) + fb.AccountRefStart(b) + fb.AccountRefAddAccountId(b, aid) + fb.AccountRefAddDisplayName(b, name) + return fb.AccountRefEnd(b) +} + +// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset. +func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT { + inviter := buildAccountRef(b, inv.Inviter) + inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + for i, iv := range inv.Invitees { + aid := b.CreateString(iv.AccountID) + name := b.CreateString(iv.DisplayName) + resp := b.CreateString(iv.Response) + fb.InvitationInviteeStart(b) + fb.InvitationInviteeAddAccountId(b, aid) + fb.InvitationInviteeAddDisplayName(b, name) + fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) + fb.InvitationInviteeAddResponse(b, resp) + inviteeOffs[i] = fb.InvitationInviteeEnd(b) + } + fb.InvitationStartInviteesVector(b, len(inviteeOffs)) + for i := len(inviteeOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(inviteeOffs[i]) + } + invitees := b.EndVector(len(inviteeOffs)) + + id := b.CreateString(inv.ID) + variant := b.CreateString(inv.Variant) + dropout := b.CreateString(inv.DropoutTiles) + status := b.CreateString(inv.Status) + gameID := b.CreateString(inv.GameID) + fb.InvitationStart(b) + fb.InvitationAddId(b, id) + fb.InvitationAddInviter(b, inviter) + fb.InvitationAddInvitees(b, invitees) + fb.InvitationAddVariant(b, variant) + fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) + fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) + fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddDropoutTiles(b, dropout) + fb.InvitationAddStatus(b, status) + fb.InvitationAddGameId(b, gameID) + fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) + return fb.InvitationEnd(b) +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 7813cce..b88f4ab 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -6,6 +6,7 @@ import ( flatbuffers "github.com/google/flatbuffers/go" "github.com/google/uuid" + "scrabble/backend/internal/engine" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -17,8 +18,9 @@ import ( // deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage // 17): the player who just moved, their move kind, the main word of a scoring play (empty // otherwise) and the recipient-first running score line. Empty strings render the plain "your -// turn" text. -func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string) Intent { +// turn" text. moveCount is the post-move count, which the client compares against its cached +// game to detect a missed in-app move and fall back to a refetch (R4). +func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent { b := flatbuffers.NewBuilder(128) gid := b.CreateString(gameID.String()) name := b.CreateString(opponentName) @@ -32,38 +34,51 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc fb.YourTurnEventAddLastAction(b, action) fb.YourTurnEventAddLastWord(b, word) fb.YourTurnEventAddScoreLine(b, score) + fb.YourTurnEventAddMoveCount(b, int32(moveCount)) b.Finish(fb.YourTurnEventEnd(b)) return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()} } // GameOver announces to userID that game gameID finished. result is the outcome from userID's // own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both -// feed the out-of-app "game over" push (Stage 17). -func GameOver(userID, gameID uuid.UUID, result, scoreLine string) Intent { - b := flatbuffers.NewBuilder(64) +// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the +// adjusted scores after rack penalties and the winner flag), so an in-app client settles the +// finished game from the event without a refetch (R4). +func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) res := b.CreateString(result) score := b.CreateString(scoreLine) + gameOff := buildGameView(b, game) fb.GameOverEventStart(b) fb.GameOverEventAddGameId(b, gid) fb.GameOverEventAddResult(b, res) fb.GameOverEventAddScoreLine(b, score) + fb.GameOverEventAddGame(b, gameOff) b.Finish(fb.GameOverEventEnd(b)) return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()} } -// OpponentMoved tells userID that seat just committed a move in game gameID, -// summarising it (the client refetches the full state). -func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent { - b := flatbuffers.NewBuilder(64) +// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta +// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/ +// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and +// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's +// summary for pre-R4 wire back-compat. +func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) - act := b.CreateString(action) + act := b.CreateString(move.Action.String()) + moveOff := buildMoveRecord(b, move) + gameOff := buildGameView(b, game) fb.OpponentMovedEventStart(b) fb.OpponentMovedEventAddGameId(b, gid) - fb.OpponentMovedEventAddSeat(b, int32(seat)) + fb.OpponentMovedEventAddSeat(b, int32(move.Player)) fb.OpponentMovedEventAddAction(b, act) - fb.OpponentMovedEventAddScore(b, int32(score)) - fb.OpponentMovedEventAddTotal(b, int32(total)) + fb.OpponentMovedEventAddScore(b, int32(move.Score)) + fb.OpponentMovedEventAddTotal(b, int32(move.Total)) + fb.OpponentMovedEventAddMove(b, moveOff) + fb.OpponentMovedEventAddGame(b, gameOff) + fb.OpponentMovedEventAddBagLen(b, int32(bagLen)) b.Finish(fb.OpponentMovedEventEnd(b)) return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()} } @@ -99,21 +114,24 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()} } -// MatchFound tells userID that game gameID, which they are seated in, has -// started (an auto-match pairing or a robot substitution). -func MatchFound(userID, gameID uuid.UUID) Intent { - b := flatbuffers.NewBuilder(64) +// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match +// pairing or a robot substitution). state is the recipient's full initial view of the new game, +// so the client navigates straight in from the event with no follow-up fetch (R4). +func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) + stateOff := buildStateView(b, state) fb.MatchFoundEventStart(b) fb.MatchFoundEventAddGameId(b, gid) + fb.MatchFoundEventAddState(b, stateOff) b.Finish(fb.MatchFoundEventEnd(b)) return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} } -// Notification is a lightweight "re-poll" signal to userID that a friend request or -// invitation changed. kind is a sub-discriminator (NotifyFriendRequest, -// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the -// client may use to scope its refresh. +// Notification is a lightweight "re-poll" signal to userID that something in their lobby +// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, +// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the +// enriched constructors below, which let the client update its lobby without a refetch (R4). func Notification(userID uuid.UUID, kind string) Intent { b := flatbuffers.NewBuilder(32) k := b.CreateString(kind) @@ -123,6 +141,47 @@ func Notification(userID uuid.UUID, kind string) Intent { return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} } +// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the +// account it concerns (the requester, the new friend or the decliner), so the client updates its +// requests/friends lists and the in-game "add friend" state without a refetch (R4). +func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent { + b := flatbuffers.NewBuilder(128) + k := b.CreateString(kind) + accOff := buildAccountRef(b, acc) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddAccount(b, accOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's +// initial view of the just-started invited game, so the client seeds its game cache and the +// lobby list without a refetch (R4). +func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent { + b := flatbuffers.NewBuilder(512) + k := b.CreateString(NotifyGameStarted) + stateOff := buildStateView(b, state) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddState(b, stateOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation, +// so the client adds it to its lobby invitations list without a refetch (R4). +func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent { + b := flatbuffers.NewBuilder(512) + k := b.CreateString(NotifyInvitation) + invOff := buildInvitation(b, inv) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddInvitation(b, invOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 70a7427..e17b1b2 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" + "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) { func TestYourTurnPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95") + in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7) if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" { t.Fatalf("intent metadata wrong: %+v", in) } @@ -72,6 +73,9 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { if got := ev.DeadlineUnix(); got != 1717000000 { t.Fatalf("deadline = %d, want 1717000000", got) } + if got := ev.MoveCount(); got != 7 { + t.Fatalf("move_count = %d, want 7", got) + } if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" || string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" { t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q", @@ -81,7 +85,8 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { func TestGameOverPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.GameOver(uid, gid, "won", "120:95:80") + summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}} + in := notify.GameOver(uid, gid, "won", "120:95:80", summary) if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" { t.Fatalf("intent metadata wrong: %+v", in) } @@ -89,19 +94,106 @@ func TestGameOverPayloadRoundTrips(t *testing.T) { if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" { t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine()) } + g := ev.Game(nil) + if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 { + t.Fatalf("final game summary wrong: %+v", g) + } } func TestOpponentMovedPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130) + move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} + summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} + in := notify.OpponentMoved(uid, gid, move, summary, 42) if in.Kind != notify.KindOpponentMoved { t.Fatalf("kind = %q", in.Kind) } ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0) + // The pre-R4 summary scalars repeat the move. if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 { - t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d", + t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d", ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total()) } + // The R4 delta: the move, the post-move summary and the bag size. + if ev.BagLen() != 42 { + t.Fatalf("bag_len = %d, want 42", ev.BagLen()) + } + m := ev.Move(nil) + if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 { + t.Fatalf("move wrong: %+v", m) + } + if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 { + t.Fatalf("game summary wrong: %+v", ev.Game(nil)) + } +} + +func TestMatchFoundCarriesInitialState(t *testing.T) { + uid, gid := uuid.New(), uuid.New() + state := notify.PlayerState{ + Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}}, + Seat: 0, + Rack: []int{0, 1, 2, 255}, + BagLen: 86, + } + in := notify.MatchFound(uid, gid, state) + if in.UserID != uid || in.Kind != notify.KindMatchFound { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0) + if string(ev.GameId()) != gid.String() { + t.Fatalf("game id = %q", ev.GameId()) + } + st := ev.State(nil) + if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 { + t.Fatalf("initial state wrong: %+v", st) + } + if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" { + t.Fatalf("state game wrong: %+v", st.Game(nil)) + } +} + +func TestNotificationInvitationCarriesInvitation(t *testing.T) { + uid := uuid.New() + inv := notify.InvitationSummary{ + ID: "inv-1", + Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}, + Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}}, + Variant: "erudit_ru", + TurnTimeoutSecs: 86400, + Status: "pending", + } + in := notify.NotificationInvitation(uid, inv) + if in.Kind != notify.KindNotification { + t.Fatalf("kind = %q", in.Kind) + } + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if string(ev.Kind()) != notify.NotifyInvitation { + t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation) + } + got := ev.Invitation(nil) + if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 { + t.Fatalf("invitation wrong: %+v", got) + } + var iv fb.InvitationInvitee + if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 { + t.Fatalf("invitee wrong") + } + if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" { + t.Fatalf("inviter wrong") + } +} + +func TestNotificationAccountCarriesAccount(t *testing.T) { + uid := uuid.New() + in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}) + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if string(ev.Kind()) != notify.NotifyFriendRequest { + t.Fatalf("sub-kind = %q", ev.Kind()) + } + acc := ev.Account(nil) + if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" { + t.Fatalf("account wrong: %+v", acc) + } } func TestChatMessagePayloadRoundTrips(t *testing.T) { diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go new file mode 100644 index 0000000..3177b5a --- /dev/null +++ b/backend/internal/notify/payload.go @@ -0,0 +1,89 @@ +package notify + +// The structs below are the wire-agnostic inputs the domain services hand to the +// enriched event constructors. Keeping them here — rather than importing the wire +// schema into game/lobby/social — preserves the package boundary: notify owns the +// FlatBuffers encoding, while the domain only fills in already-resolved values (seat +// display names, alphabet-index racks). Each mirrors the matching scrabblefb table. + +// SeatStanding is one seat's public standing inside a GameSummary (mirrors +// scrabblefb.SeatView). +type SeatStanding struct { + Seat int + AccountID string + DisplayName string + Score int + HintsUsed int + IsWinner bool +} + +// GameSummary is the shared, non-private game state embedded in enriched events +// (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current +// turn's start for an active game, the finish time once finished. +type GameSummary struct { + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MoveCount int + EndReason string + Seats []SeatStanding + LastActivityUnix int64 +} + +// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an +// initial PlayerState so a client seeing a variant for the first time can render its +// rack (mirrors scrabblefb.AlphabetEntry). +type AlphabetLetter struct { + Index int + Letter string + Value int +} + +// PlayerState is a player's full initial view of a game — the shared summary plus +// their private rack and budgets (mirrors scrabblefb.StateView). Rack carries wire +// alphabet indices (a blank is the sentinel index 255). Alphabet is set only when the +// recipient may not have cached the variant yet (match_found / game_started). +type PlayerState struct { + Game GameSummary + Seat int + Rack []int + BagLen int + HintsRemaining int + Alphabet []AlphabetLetter +} + +// AccountRef is a referenced account with its display name resolved (mirrors +// scrabblefb.AccountRef). +type AccountRef struct { + AccountID string + DisplayName string +} + +// InvitationInvitee is one invited player's seat and response inside an +// InvitationSummary (mirrors scrabblefb.InvitationInvitee). +type InvitationInvitee struct { + AccountID string + DisplayName string + Seat int + Response string +} + +// InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so +// the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation). +type InvitationSummary struct { + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 +} diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 83eb07a..f362cc2 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -98,10 +98,14 @@ type gameDTO struct { Seats []seatDTO `json:"seats"` } -// moveResultDTO is the outcome of a committed move. +// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as +// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the +// next state from the response without a follow-up state fetch. type moveResultDTO struct { - Move moveRecordDTO `json:"move"` - Game gameDTO `json:"game"` + Move moveRecordDTO `json:"move"` + Game gameDTO `json:"game"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` } // alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and @@ -231,9 +235,19 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO { } } -// moveResultDTOFrom projects a committed move result into its DTO. -func moveResultDTOFrom(r game.MoveResult) moveResultDTO { - return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)} +// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as +// wire alphabet indices (Stage 13; R4). +func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) { + rack, err := engine.EncodeRack(r.Game.Variant, r.Rack) + if err != nil { + return moveResultDTO{}, err + } + return moveResultDTO{ + Move: moveRecordDTOFrom(r.Move), + Game: gameDTOFromGame(r.Game), + Rack: rack, + BagLen: r.BagLen, + }, nil } // stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 5617b0b..0235d01 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -458,7 +458,11 @@ func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) { // writeMoveResult emits a committed move with seat display names filled in. func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) { - dto := moveResultDTOFrom(res) + dto, err := moveResultDTOFrom(res) + if err != nil { + s.abortErr(c, err) + return + } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) c.JSON(http.StatusOK, dto) } diff --git a/backend/internal/social/friendcodes.go b/backend/internal/social/friendcodes.go index 56fd4b4..2d9f4ca 100644 --- a/backend/internal/social/friendcodes.go +++ b/backend/internal/social/friendcodes.go @@ -82,7 +82,7 @@ func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, if err := svc.store.redeemFriendCode(ctx, codeID, issuerID, redeemerID, svc.now()); err != nil { return uuid.UUID{}, err } - svc.pub.Publish(notify.Notification(issuerID, notify.NotifyFriendAdded)) + svc.pub.Publish(notify.NotificationAccount(issuerID, notify.NotifyFriendAdded, svc.accountRef(ctx, redeemerID))) return issuerID, nil } diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index 7c7406a..d15257e 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -29,6 +29,17 @@ const ( // window; a one-time friend code from the addressee bypasses a decline. const friendRequestTTL = 30 * 24 * time.Hour +// accountRef resolves accountID into a notify.AccountRef (the display name from the account +// store, empty on a lookup failure), for enriching the friend_* live events so the client +// updates its requests/friends state without a refetch (R4). +func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef { + ref := notify.AccountRef{AccountID: accountID.String()} + if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil { + ref.DisplayName = acc.DisplayName + } + return ref +} + // SendFriendRequest records a pending friend request from requesterID to // addresseeID — the "befriend an opponent" path. It requires the two to share a // game (active or finished) and refuses a self-request, a request across a block or @@ -91,7 +102,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil { return err } - svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) return nil } } @@ -101,7 +112,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse } return err } - svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) return nil } @@ -128,9 +139,9 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque // this opponent re-derives its "add to friends" state (accepted -> friends, declined // -> stays "request sent"). if accept { - svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded)) + svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendAdded, svc.accountRef(ctx, addresseeID))) } else { - svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined)) + svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendDeclined, svc.accountRef(ctx, addresseeID))) } return nil } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1546a5..fcd7cf8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -510,11 +510,22 @@ seat from the same game commit when a game finishes — any path: a closing play resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now also carries the mover's display name, their last action and the main word of a scoring play, and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first). -Event payloads are FlatBuffers-encoded by -the backend and forwarded verbatim. A client that is not currently streaming falls -back to the matchmaker's `Poll` for match-found and, for the lobby **notification -badge** (incoming friend requests + open invitations), the client polls on lobby -open and on focus as well as re-polling on the `notify` event — covering a push +**R4 enriched the in-app stream into a delta channel** so the client renders from the event +without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move +summary (per-seat scores, whose turn, move count, status) and the bag size, which the client +applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move +echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history` +refetch); **your-turn** carries that move count as a consistency check; **match-found** and the +**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly +started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds +carry the changed account / invitation. 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 +verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for +match-found — the client polls **only while the stream is down**, since a live stream delivers +match-found itself; for the lobby **notification badge** (incoming friend requests + open +invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback the **gateway** routes from the same firehose: for an event whose recipient has **no live in-app stream** it resolves the backend `/internal/push-target` (their Telegram diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 3399b89..bdbf102 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -42,7 +42,8 @@ language, not whichever bot the player signed in through last. Guests are sessio (auto-match only; no friends, stats or history); an abandoned guest that never joined a game and has been idle past the retention window is garbage-collected. While the app is open the client keeps a live stream and receives in-app updates in real time — the opponent's move, -your turn, chat, nudges and a found match. When the app is **closed**, the chosen +your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place +with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen out-of-app events (your turn, game over, nudge, a found match, an invitation or friend request) arrive as a **Telegram notification** instead — unless the player keeps notifications in the app only (a profile setting, **on by default**). The "your turn" diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee0e7b3..57f1e19 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -43,7 +43,8 @@ nudge) приходят от бота **этой партии** — по язы авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, -чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие +чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без +перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья) приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления только в приложении (настройка профиля, **включена по умолчанию**). diff --git a/gateway/README.md b/gateway/README.md index d162bc0..656102c 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -53,7 +53,9 @@ out-of-app push to that connector for recipients with no live in-app stream The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events -`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Stage 7 +`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events — +and `game_over`/`notify` — to carry the state delta the client applies without a `game.state` +refetch). Stage 7 added the play-loop ops; **Stage 8** added the social/account/history ops — `friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e31bd91..fde7358 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -106,10 +106,13 @@ type GameResp struct { Seats []SeatResp `json:"seats"` } -// MoveResultResp is the outcome of a committed move. +// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as +// wire alphabet indices and BagLen the bag size after the draw (R4). type MoveResultResp struct { - Move MoveRecordResp `json:"move"` - Game GameResp `json:"game"` + Move MoveRecordResp `json:"move"` + Game GameResp `json:"game"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` } // AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 8c61144..cd2b249 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -123,9 +123,16 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte { b := flatbuffers.NewBuilder(512) move := buildMoveRecord(b, r.Move) game := buildGameView(b, r.Game) + rackBytes := make([]byte, len(r.Rack)) + for i, v := range r.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) fb.MoveResultStart(b) fb.MoveResultAddMove(b, move) fb.MoveResultAddGame(b, game) + fb.MoveResultAddRack(b, rack) + fb.MoveResultAddBagLen(b, int32(r.BagLen)) b.Finish(fb.MoveResultEnd(b)) return b.FinishedBytes() } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 1814920..462bcd0 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -159,10 +159,16 @@ table SubmitPlayRequest { tiles:[PlayTile]; } -// MoveResult is the outcome of a committed move: the move and the post-move game. +// MoveResult is the outcome of a committed move: the move and the post-move game. rack and +// bag_len carry the actor's own post-move private state — their refilled rack (alphabet indices, +// Stage 13; a blank is the sentinel index 255) and the bag size after drawing — so the mover +// renders the next state straight from this response without a follow-up game.state (R4; added +// trailing — backward-compatible). table MoveResult { move:MoveRecord; game:GameView; + rack:[ubyte]; + bag_len:int; } // StateRequest asks for the requesting player's view of a game. include_alphabet asks the @@ -477,6 +483,8 @@ table GcgExport { // move kind ("play"/"pass"/"exchange"/...), last_word is the main word of a scoring play (empty // otherwise), and score_line is the recipient-first running score (e.g. "120:95:80"). They are // appended (FlatBuffers-optional), so an older reader that only needs game_id/deadline is unaffected. +// move_count is the post-move count (matching the opponent_moved GameView): the client uses it to +// tell whether its cached game already reflects the move, falling back to a refetch on a gap (R4). table YourTurnEvent { game_id:string; deadline_unix:long; @@ -484,25 +492,35 @@ table YourTurnEvent { last_action:string; last_word:string; score_line:string; + move_count:int; } // GameOverEvent signals that a game the recipient is seated in has finished, driving the // out-of-app "game over" push (Stage 17). result is the outcome from the recipient's own -// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. +// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. game is the +// final post-game summary (adjusted scores after rack penalties + the winner flag), so the client +// settles the finished game from the event without a refetch (R4; added trailing). table GameOverEvent { game_id:string; result:string; score_line:string; + game:GameView; } -// OpponentMovedEvent summarises a move another seat just committed; the client -// refetches the full state. +// OpponentMovedEvent carries a move another seat just committed as a delta the client applies to +// its cached game without a refetch (R4): move is the decoded play/pass/exchange (the same record +// game.history returns), game is the post-move summary (per-seat scores, to_move, move_count, +// status) and bag_len is the bag size after the draw. The leading seat/action/score/total scalars +// are the pre-R4 summary, now redundant with move/game and kept only for wire back-compat. table OpponentMovedEvent { game_id:string; seat:int; action:string; score:int; total:int; + move:MoveRecord; + game:GameView; + bag_len:int; } // NudgeEvent signals that a player nudged the recipient. @@ -511,17 +529,27 @@ table NudgeEvent { from_user_id:string; } -// MatchFoundEvent signals that an auto-match pairing (or robot substitution) -// started a game the recipient is seated in. +// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the +// recipient is seated in. state is the recipient's full initial view of the new game (empty board, +// dealt rack), so the client navigates straight in from the event with no follow-up fetch (R4; +// added trailing — an older reader still reads just game_id). table MatchFoundEvent { game_id:string; + state:StateView; } // NotificationEvent is a lightweight "something changed, re-poll" signal that // drives the lobby badge (incoming friend requests, invitations). kind is a sub- // discriminator ("friend_request", "friend_added", "friend_declined", "invitation", // "game_started"); the client re-fetches its lobby counters (and, for a requester -// watching a game, its friend state) on any of them. +// watching a game, its friend state) on any of them. To let the client update its lobby without a +// follow-up fetch (R4), each event also carries the payload its kind changed: account for the +// friend_* kinds (the requester/friend), invitation for "invitation" (the new invitation) and +// state for "game_started" (the started game's initial view, like match_found). Only the field +// matching kind is set (all added trailing — backward-compatible). table NotificationEvent { kind:string; + account:AccountRef; + invitation:Invitation; + state:StateView; } diff --git a/pkg/fbs/scrabblefb/GameOverEvent.go b/pkg/fbs/scrabblefb/GameOverEvent.go index e77f00f..ad80145 100644 --- a/pkg/fbs/scrabblefb/GameOverEvent.go +++ b/pkg/fbs/scrabblefb/GameOverEvent.go @@ -65,8 +65,21 @@ func (rcv *GameOverEvent) ScoreLine() []byte { return nil } +func (rcv *GameOverEvent) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func GameOverEventStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(4) } func GameOverEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -77,6 +90,9 @@ func GameOverEventAddResult(builder *flatbuffers.Builder, result flatbuffers.UOf func GameOverEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(scoreLine), 0) } +func GameOverEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(game), 0) +} func GameOverEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/MatchFoundEvent.go b/pkg/fbs/scrabblefb/MatchFoundEvent.go index fc7c0f8..a93b0d7 100644 --- a/pkg/fbs/scrabblefb/MatchFoundEvent.go +++ b/pkg/fbs/scrabblefb/MatchFoundEvent.go @@ -49,12 +49,28 @@ func (rcv *MatchFoundEvent) GameId() []byte { return nil } +func (rcv *MatchFoundEvent) State(obj *StateView) *StateView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(StateView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func MatchFoundEventStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func MatchFoundEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) } +func MatchFoundEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(state), 0) +} func MatchFoundEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/MoveResult.go b/pkg/fbs/scrabblefb/MoveResult.go index a8e37f4..98dbd5f 100644 --- a/pkg/fbs/scrabblefb/MoveResult.go +++ b/pkg/fbs/scrabblefb/MoveResult.go @@ -67,8 +67,54 @@ func (rcv *MoveResult) Game(obj *GameView) *GameView { return nil } +func (rcv *MoveResult) Rack(j int) byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) + } + return 0 +} + +func (rcv *MoveResult) RackLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *MoveResult) RackBytes() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MoveResult) MutateRack(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + +func (rcv *MoveResult) BagLen() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveResult) MutateBagLen(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + func MoveResultStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(4) } func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0) @@ -76,6 +122,15 @@ func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) func MoveResultAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(game), 0) } +func MoveResultAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0) +} +func MoveResultStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(1, numElems, 1) +} +func MoveResultAddBagLen(builder *flatbuffers.Builder, bagLen int32) { + builder.PrependInt32Slot(3, bagLen, 0) +} func MoveResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/NotificationEvent.go b/pkg/fbs/scrabblefb/NotificationEvent.go index 89314bb..74f4eaf 100644 --- a/pkg/fbs/scrabblefb/NotificationEvent.go +++ b/pkg/fbs/scrabblefb/NotificationEvent.go @@ -49,12 +49,60 @@ func (rcv *NotificationEvent) Kind() []byte { return nil } +func (rcv *NotificationEvent) Account(obj *AccountRef) *AccountRef { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AccountRef) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *NotificationEvent) Invitation(obj *Invitation) *Invitation { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(Invitation) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *NotificationEvent) State(obj *StateView) *StateView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(StateView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func NotificationEventStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(4) } func NotificationEventAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0) } +func NotificationEventAddAccount(builder *flatbuffers.Builder, account flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(account), 0) +} +func NotificationEventAddInvitation(builder *flatbuffers.Builder, invitation flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(invitation), 0) +} +func NotificationEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(state), 0) +} func NotificationEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/OpponentMovedEvent.go b/pkg/fbs/scrabblefb/OpponentMovedEvent.go index 6c9fa81..8b0e5c0 100644 --- a/pkg/fbs/scrabblefb/OpponentMovedEvent.go +++ b/pkg/fbs/scrabblefb/OpponentMovedEvent.go @@ -93,8 +93,46 @@ func (rcv *OpponentMovedEvent) MutateTotal(n int32) bool { return rcv._tab.MutateInt32Slot(12, n) } +func (rcv *OpponentMovedEvent) Move(obj *MoveRecord) *MoveRecord { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MoveRecord) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *OpponentMovedEvent) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *OpponentMovedEvent) BagLen() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *OpponentMovedEvent) MutateBagLen(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + func OpponentMovedEventStart(builder *flatbuffers.Builder) { - builder.StartObject(5) + builder.StartObject(8) } func OpponentMovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -111,6 +149,15 @@ func OpponentMovedEventAddScore(builder *flatbuffers.Builder, score int32) { func OpponentMovedEventAddTotal(builder *flatbuffers.Builder, total int32) { builder.PrependInt32Slot(4, total, 0) } +func OpponentMovedEventAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(move), 0) +} +func OpponentMovedEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(game), 0) +} +func OpponentMovedEventAddBagLen(builder *flatbuffers.Builder, bagLen int32) { + builder.PrependInt32Slot(7, bagLen, 0) +} func OpponentMovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/YourTurnEvent.go b/pkg/fbs/scrabblefb/YourTurnEvent.go index 9534d59..5998f85 100644 --- a/pkg/fbs/scrabblefb/YourTurnEvent.go +++ b/pkg/fbs/scrabblefb/YourTurnEvent.go @@ -93,8 +93,20 @@ func (rcv *YourTurnEvent) ScoreLine() []byte { return nil } +func (rcv *YourTurnEvent) MoveCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *YourTurnEvent) MutateMoveCount(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + func YourTurnEventStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(7) } func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -114,6 +126,9 @@ func YourTurnEventAddLastWord(builder *flatbuffers.Builder, lastWord flatbuffers func YourTurnEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(scoreLine), 0) } +func YourTurnEventAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { + builder.PrependInt32Slot(6, moveCount, 0) +} func YourTurnEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/README.md b/ui/README.md index faa051a..494edc2 100644 --- a/ui/README.md +++ b/ui/README.md @@ -40,7 +40,9 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and A single Connect `Execute(message_type, payload)` carries every unary op; the request and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`. The session token rides in `Authorization: Bearer`; a domain failure comes back in -`result_code`. `Subscribe` is the live event stream. `lib/transport.ts` is the real +`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta** +that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without +a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index b31b234..1c45789 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -13,13 +13,14 @@ import { connection } from '../lib/connection.svelte'; import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; - import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model'; + import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { replay } from '../lib/board'; import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { shareOrDownloadGcg } from '../lib/share'; - import { getCachedGame, setCachedGame } from '../lib/gamecache'; + import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; + import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { BLANK, @@ -154,17 +155,42 @@ void loadFriends(); }); + // cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers. + function cacheSnapshot(): CachedGame | undefined { + return view ? { view, moves } : undefined; + } + // applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a + // flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing + // payload — see lib/gamedelta). + function applyDelta(res: DeltaResult): void { + if (res.cache) { + view = res.cache.view; + moves = res.cache.moves; + setCachedGame(id, view, moves); + recompute(); + } else if (res.refetch) { + void load(); + } + } + $effect(() => { const e = app.lastEvent; if (!e) return; if (e.kind === 'opponent_moved' && e.gameId === id) { - // Skip the echo of my own move (the backend now notifies the actor too, for the - // player's other devices): this device already reloaded after the submit. - if (e.seat !== view?.seat) void load(); - } else if (e.kind === 'your_turn' && e.gameId === id) void load(); - // A request the player sent was answered (accepted -> now friends; declined -> stays - // "request sent"): re-derive the in-game friend state. - else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends(); + // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply + // the move as a delta with no fetch (R4). + if (placement.pending.length > 0) void load(); + else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); + } else if (e.kind === 'your_turn' && e.gameId === id) { + // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch + // only if we missed the move (our cached count trails the event's). + if (view && e.moveCount > view.game.moveCount) void load(); + } else if (e.kind === 'game_over' && e.gameId === id) { + applyDelta(applyGameOver(cacheSnapshot(), e.game)); + } else if (e.kind === '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(); + } }); function isCoarse(): boolean { @@ -446,15 +472,27 @@ }, 250); } + // applyMoveResult renders the actor's own just-committed move from the response — the move, the + // post-move game and the refilled rack — without a follow-up game.state + game.history (R4). + function applyMoveResult(r: MoveResult) { + view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; + moves = [...moves, r.move]; + setCachedGame(id, view, moves); + rackIds = r.rack.map((_, i) => i); + placement = newPlacement(r.rack); + selected = null; + dirOverride = undefined; + recompute(); + } + async function commit() { const sub = toSubmit(placement, dirOverride); if (!sub) return; busy = true; try { - await gateway.submitPlay(id, sub.dir, sub.tiles, variant); + applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant)); telegramHaptic('success'); zoomed = false; - await load(); } catch (e) { handleError(e); } finally { @@ -472,8 +510,7 @@ async function doPass() { busy = true; try { - await gateway.pass(id); - await load(); + applyMoveResult(await gateway.pass(id)); } catch (e) { handleError(e); } finally { @@ -484,8 +521,7 @@ resignOpen = false; busy = true; try { - await gateway.resign(id); - await load(); + applyMoveResult(await gateway.resign(id)); } catch (e) { handleError(e); } finally { @@ -553,8 +589,7 @@ exchangeOpen = false; busy = true; try { - await gateway.exchange(id, tiles, variant); - await load(); + applyMoveResult(await gateway.exchange(id, tiles, variant)); } catch (e) { handleError(e); } finally { diff --git a/ui/src/gen/fbs/scrabblefb/game-over-event.ts b/ui/src/gen/fbs/scrabblefb/game-over-event.ts index 60c6bb7..08e1831 100644 --- a/ui/src/gen/fbs/scrabblefb/game-over-event.ts +++ b/ui/src/gen/fbs/scrabblefb/game-over-event.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { GameView } from '../scrabblefb/game-view.js'; + + export class GameOverEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -41,8 +44,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +game(obj?:GameView):GameView|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startGameOverEvent(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(4); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -57,16 +65,13 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off builder.addFieldOffset(2, scoreLineOffset, 0); } +static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, gameOffset, 0); +} + static endGameOverEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGameOverEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, resultOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { - GameOverEvent.startGameOverEvent(builder); - GameOverEvent.addGameId(builder, gameIdOffset); - GameOverEvent.addResult(builder, resultOffset); - GameOverEvent.addScoreLine(builder, scoreLineOffset); - return GameOverEvent.endGameOverEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/match-found-event.ts b/ui/src/gen/fbs/scrabblefb/match-found-event.ts index 8a0f9f7..54c931f 100644 --- a/ui/src/gen/fbs/scrabblefb/match-found-event.ts +++ b/ui/src/gen/fbs/scrabblefb/match-found-event.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { StateView } from '../scrabblefb/state-view.js'; + + export class MatchFoundEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -27,22 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +state(obj?:StateView):StateView|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startMatchFoundEvent(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { builder.addFieldOffset(0, gameIdOffset, 0); } +static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, stateOffset, 0); +} + static endMatchFoundEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createMatchFoundEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset { - MatchFoundEvent.startMatchFoundEvent(builder); - MatchFoundEvent.addGameId(builder, gameIdOffset); - return MatchFoundEvent.endMatchFoundEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/move-result.ts b/ui/src/gen/fbs/scrabblefb/move-result.ts index 623950e..cdca742 100644 --- a/ui/src/gen/fbs/scrabblefb/move-result.ts +++ b/ui/src/gen/fbs/scrabblefb/move-result.ts @@ -34,8 +34,28 @@ game(obj?:GameView):GameView|null { return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } +rack(index: number):number|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0; +} + +rackLength():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +rackArray():Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null; +} + +bagLen():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startMoveResult(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(4); } static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { @@ -46,6 +66,26 @@ static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { builder.addFieldOffset(1, gameOffset, 0); } +static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, rackOffset, 0); +} + +static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) { + builder.addInt8(data[i]!); + } + return builder.endVector(); +} + +static startRackVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(1, numElems, 1); +} + +static addBagLen(builder:flatbuffers.Builder, bagLen:number) { + builder.addFieldInt32(3, bagLen, 0); +} + static endMoveResult(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/ui/src/gen/fbs/scrabblefb/notification-event.ts b/ui/src/gen/fbs/scrabblefb/notification-event.ts index d3532a6..532ee12 100644 --- a/ui/src/gen/fbs/scrabblefb/notification-event.ts +++ b/ui/src/gen/fbs/scrabblefb/notification-event.ts @@ -2,6 +2,11 @@ import * as flatbuffers from 'flatbuffers'; +import { AccountRef } from '../scrabblefb/account-ref.js'; +import { Invitation } from '../scrabblefb/invitation.js'; +import { StateView } from '../scrabblefb/state-view.js'; + + export class NotificationEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -27,22 +32,44 @@ kind(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +account(obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +invitation(obj?:Invitation):Invitation|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +state(obj?:StateView):StateView|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startNotificationEvent(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(4); } static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) { builder.addFieldOffset(0, kindOffset, 0); } +static addAccount(builder:flatbuffers.Builder, accountOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, accountOffset, 0); +} + +static addInvitation(builder:flatbuffers.Builder, invitationOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, invitationOffset, 0); +} + +static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, stateOffset, 0); +} + static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset { - NotificationEvent.startNotificationEvent(builder); - NotificationEvent.addKind(builder, kindOffset); - return NotificationEvent.endNotificationEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts index 654a3fd..810700d 100644 --- a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts +++ b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts @@ -2,6 +2,10 @@ import * as flatbuffers from 'flatbuffers'; +import { GameView } from '../scrabblefb/game-view.js'; +import { MoveRecord } from '../scrabblefb/move-record.js'; + + export class OpponentMovedEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -49,8 +53,23 @@ total():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +move(obj?:MoveRecord):MoveRecord|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +game(obj?:GameView):GameView|null { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +bagLen():number { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startOpponentMovedEvent(builder:flatbuffers.Builder) { - builder.startObject(5); + builder.startObject(8); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -73,18 +92,21 @@ static addTotal(builder:flatbuffers.Builder, total:number) { builder.addFieldInt32(4, total, 0); } +static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, moveOffset, 0); +} + +static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { + builder.addFieldOffset(6, gameOffset, 0); +} + +static addBagLen(builder:flatbuffers.Builder, bagLen:number) { + builder.addFieldInt32(7, bagLen, 0); +} + static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createOpponentMovedEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, seat:number, actionOffset:flatbuffers.Offset, score:number, total:number):flatbuffers.Offset { - OpponentMovedEvent.startOpponentMovedEvent(builder); - OpponentMovedEvent.addGameId(builder, gameIdOffset); - OpponentMovedEvent.addSeat(builder, seat); - OpponentMovedEvent.addAction(builder, actionOffset); - OpponentMovedEvent.addScore(builder, score); - OpponentMovedEvent.addTotal(builder, total); - return OpponentMovedEvent.endOpponentMovedEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts index cb14d04..d7934b5 100644 --- a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts +++ b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts @@ -60,8 +60,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +moveCount():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startYourTurnEvent(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -88,12 +93,16 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off builder.addFieldOffset(5, scoreLineOffset, 0); } +static addMoveCount(builder:flatbuffers.Builder, moveCount:number) { + builder.addFieldInt32(6, moveCount, 0); +} + static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { +static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset, moveCount:number):flatbuffers.Offset { YourTurnEvent.startYourTurnEvent(builder); YourTurnEvent.addGameId(builder, gameIdOffset); YourTurnEvent.addDeadlineUnix(builder, deadlineUnix); @@ -101,6 +110,7 @@ static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers YourTurnEvent.addLastAction(builder, lastActionOffset); YourTurnEvent.addLastWord(builder, lastWordOffset); YourTurnEvent.addScoreLine(builder, scoreLineOffset); + YourTurnEvent.addMoveCount(builder, moveCount); return YourTurnEvent.endYourTurnEvent(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 793dcc9..6fd32f7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -25,7 +25,7 @@ import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; -import { clearGameCache } from './gamecache'; +import { clearGameCache, setCachedGame } from './gamecache'; import { clearLobby } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; @@ -36,6 +36,8 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */ + streamAlive: boolean; session: Session | null; profile: Profile | null; toast: Toast | null; @@ -53,6 +55,7 @@ export const app = $state<{ chatUnread: Record; }>({ ready: false, + streamAlive: false, session: null, profile: null, toast: null, @@ -68,7 +71,6 @@ export const app = $state<{ }); let unsubscribeStream: (() => void) | null = null; -let streamAlive = false; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; @@ -100,7 +102,7 @@ function goForeground(): void { backgrounded = false; foregroundedAt = Date.now(); if (!app.session) return; - if (!streamAlive) openStream(); // silently re-establish a stream dropped while away + if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away void refreshNotifications(); } @@ -131,7 +133,7 @@ export function handleError(err: unknown): void { function openStream(): void { closeStream(); - streamAlive = true; + app.streamAlive = true; unsubscribeStream = gateway.subscribe( (e) => { reportOnline(); // a delivered event proves the gateway is reachable @@ -151,13 +153,19 @@ function openStream(): void { } else if (e.kind === 'your_turn') { showToast(t('game.yourTurn'), 'info'); } else if (e.kind === 'match_found') { + // Seed the cache from the event's initial state so the game renders instantly on arrival, + // then navigate (R4). + if (e.state) setCachedGame(e.state.game.id, e.state, []); navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { + // A started invited game seeds its cache so opening it is instant; the lobby badge stays + // on the authoritative refresh (R4). + if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []); void refreshNotifications(); } }, () => { - streamAlive = false; + app.streamAlive = false; // A background suspend drops the single-shot stream. Keep the indicator hidden while // backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the // reachability watcher and this scheduled retry recover it). Always schedule a retry. @@ -173,7 +181,7 @@ function scheduleReconnect(): void { if (reconnectTimer || !app.session) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; - if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream(); + if (app.session && !app.streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } @@ -205,7 +213,7 @@ function closeStream(): void { } unsubscribeStream?.(); unsubscribeStream = null; - streamAlive = false; + app.streamAlive = false; } async function adoptSession(s: Session): Promise { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index a65c151..41ce3b4 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -322,12 +322,12 @@ export function decodeProfile(buf: Uint8Array): Profile { }; } -export function decodeStateView(buf: Uint8Array): StateView { - const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf)); +// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the +// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index +// rack to display letters with it (Stage 13). +function decodeStateViewTable(v: fb.StateView): StateView { const g = v.game(); const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; - // Cache the alphabet table when the server included it (a per-variant cache miss), then - // decode the index rack to display letters with it (Stage 13). if (v.alphabetLength() > 0) { const entries: AlphabetEntryWire[] = []; for (let i = 0; i < v.alphabetLength(); i++) { @@ -347,11 +347,24 @@ export function decodeStateView(buf: Uint8Array): StateView { }; } +export function decodeStateView(buf: Uint8Array): StateView { + return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf))); +} + export function decodeMoveResult(buf: Uint8Array): MoveResult { const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf)); const m = r.move(); const g = r.game(); - return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() }; + // The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant. + const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; + const rack: string[] = []; + for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0)); + return { + move: m ? decodeMove(m) : emptyMove(), + game: g ? decodeGameView(g) : emptyGame(), + rack, + bagLen: r.bagLen(), + }; } export function decodeHintResult(buf: Uint8Array): HintResult { @@ -424,11 +437,30 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null switch (kind) { case 'your_turn': { const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb); - return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) }; + return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() }; } case 'opponent_moved': { const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb); - return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() }; + const m = e.move(); + const g = e.game(); + return { + kind: 'opponent_moved', + gameId: s(e.gameId()), + move: m ? decodeMove(m) : undefined, + game: g ? decodeGameView(g) : undefined, + bagLen: e.bagLen(), + }; + } + case 'game_over': { + const e = fb.GameOverEvent.getRootAsGameOverEvent(bb); + const g = e.game(); + return { + kind: 'game_over', + gameId: s(e.gameId()), + result: s(e.result()), + scoreLine: s(e.scoreLine()), + game: g ? decodeGameView(g) : undefined, + }; } case 'chat_message': return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) }; @@ -438,11 +470,21 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null } case 'match_found': { const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb); - return { kind: 'match_found', gameId: s(e.gameId()) }; + const st = e.state(); + return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined }; } case 'notify': { const e = fb.NotificationEvent.getRootAsNotificationEvent(bb); - return { kind: 'notify', sub: s(e.kind()) }; + const acc = e.account(); + const inv = e.invitation(); + const st = e.state(); + return { + kind: 'notify', + sub: s(e.kind()), + account: acc ? decodeAccountRef(acc) : undefined, + invitation: inv ? decodeInvitationTable(inv) : undefined, + state: st ? decodeStateViewTable(st) : undefined, + }; } case 'heartbeat': return { kind: 'heartbeat' }; diff --git a/ui/src/lib/gamecache.ts b/ui/src/lib/gamecache.ts index 878d294..03671c8 100644 --- a/ui/src/lib/gamecache.ts +++ b/ui/src/lib/gamecache.ts @@ -7,7 +7,7 @@ import type { MoveRecord, StateView } from './model'; -interface CachedGame { +export interface CachedGame { view: StateView; moves: MoveRecord[]; } diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts new file mode 100644 index 0000000..67de394 --- /dev/null +++ b/ui/src/lib/gamedelta.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta'; +import type { CachedGame } from './gamecache'; +import type { GameView, MoveRecord, StateView } from './model'; + +function gameView(moveCount: number, over = false): GameView { + return { + id: 'g1', + variant: 'scrabble_en', + dictVersion: 'v1', + status: over ? 'finished' : 'active', + players: 2, + toMove: 1, + turnTimeoutSecs: 300, + moveCount, + endReason: over ? 'standard' : '', + lastActivityUnix: 0, + seats: [], + }; +} + +function move(player: number): MoveRecord { + return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 }; +} + +function cache(moveCount: number, seat = 0, over = false): CachedGame { + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }; + return { view, moves: [] }; +} + +function delta(moveCount: number, player: number, bagLen = 47): MoveDelta { + return { move: move(player), game: gameView(moveCount), bagLen }; +} + +describe('seedInitialState', () => { + it('wraps an initial view with an empty journal', () => { + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 }; + expect(seedInitialState(view)).toEqual({ view, moves: [] }); + }); +}); + +describe('applyMoveDelta', () => { + it('ignores a delta for a game not in the cache', () => { + expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false }); + }); + + it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => { + expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true }); + }); + + it('is a no-op for an already-applied move count (idempotent / own echo)', () => { + expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false }); + expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false }); + }); + + it('refetches on a gap (an intermediate move was missed)', () => { + expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true }); + }); + + it("refetches the actor's own move (the new rack is not in the delta)", () => { + // seat 0 is this device; the move's player is 0 -> our own move, our rack changed. + expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true }); + }); + + it("applies an opponent's next move, preserving the rack and appending the move", () => { + const before = cache(3, 0); + const res = applyMoveDelta(before, delta(4, 1, 45)); + expect(res.refetch).toBe(false); + expect(res.cache?.view.game.moveCount).toBe(4); + expect(res.cache?.view.bagLen).toBe(45); + expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move + expect(res.cache?.view.seat).toBe(0); + expect(res.cache?.moves).toHaveLength(1); + expect(res.cache?.moves[0].player).toBe(1); + expect(before.moves).toHaveLength(0); // input not mutated + }); +}); + +describe('applyGameOver', () => { + it('ignores a finished game not in the cache', () => { + expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false }); + }); + + it('refetches a missing final summary only when the game is not already finished', () => { + expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true }); + expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false }); + }); + + it('refetches when the cached board is behind the final move count', () => { + expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true }); + }); + + it('settles the final summary when the board is current', () => { + const res = applyGameOver(cache(10), gameView(10, true)); + expect(res.refetch).toBe(false); + expect(res.cache?.view.game.status).toBe('finished'); + expect(res.cache?.view.game.moveCount).toBe(10); + }); +}); diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts new file mode 100644 index 0000000..208b4a8 --- /dev/null +++ b/ui/src/lib/gamedelta.ts @@ -0,0 +1,69 @@ +// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move +// from the event without a follow-up game.state + game.history fetch. They never touch the network +// or the cache store — the stream handler applies the returned cache and the game screen acts on +// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation. + +import type { CachedGame } from './gamecache'; +import type { GameView, MoveRecord, StateView } from './model'; + +/** The fields an opponent_moved delta carries that advance a cached game. */ +export interface MoveDelta { + move?: MoveRecord; + game?: GameView; + bagLen: number; +} + +/** + * DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed) + * and whether the caller must fall back to a full game.state + game.history fetch — a gap, a + * missing payload, or the actor's own move on a device that has not drawn its new rack yet. + */ +export interface DeltaResult { + cache?: CachedGame; + refetch: boolean; +} + +/** + * seedInitialState builds a fresh cache entry from a started game's initial view (match_found / + * game_started). A freshly started game has no moves, so the board replays from an empty journal. + */ +export function seedInitialState(state: StateView): CachedGame { + return { view: state, moves: [] }; +} + +/** + * applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move + * count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and + * gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack + * unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so + * a device still behind on its own move refetches to pick it up. + */ +export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult { + // Nothing cached to advance (the game was never opened on this device): ignore it; the next open + // cold-loads the game. + if (!cached) return { refetch: false }; + // A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch. + if (!d.move || !d.game) return { refetch: true }; + const have = cached.view.game.moveCount; + const next = d.game.moveCount; + if (next <= have) return { refetch: false }; // already applied (idempotent / own echo) + if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed + // The actor's own move changed their rack (a draw), which opponent_moved does not carry. + if (d.move.player === cached.view.seat) return { refetch: true }; + const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; + return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; +} + +/** + * applyGameOver settles a finished game from a game_over event's final summary (the adjusted + * scores after rack penalties and the winner). It refetches when the cached board is behind the + * final move count — the closing move was missed — so history is repaired, and is a harmless + * re-settle when the closing opponent_moved already finished the game. + */ +export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult { + if (!cached) return { refetch: false }; + if (!game) return { refetch: cached.view.game.status !== 'finished' }; + if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; + const view: StateView = { ...cached.view, game }; + return { cache: { view, moves: cached.moves }, refetch: false }; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 76cdbd2..7e8a7b7 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -235,7 +235,7 @@ export class MockGateway implements GatewayClient { g.view.toMove = (seat + 1) % g.view.players; this.drafts.delete(gameId); this.scheduleOpponentReply(gameId); - return { move: structuredClone(move), game: structuredClone(g.view) }; + return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen }; } private async simpleAction( @@ -276,7 +276,7 @@ export class MockGateway implements GatewayClient { g.view.toMove = (seat + 1) % g.view.players; this.scheduleOpponentReply(gameId); } - return { move: structuredClone(move), game: structuredClone(g.view) }; + return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen }; } pass(gameId: string): Promise { @@ -546,8 +546,8 @@ export class MockGateway implements GatewayClient { g.view.seats[opp].score = move.total; g.view.moveCount += 1; g.view.toMove = this.mySeat(g); - this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total }); - this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 }); + this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount }); }, 1600); } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index a0d98fa..fd4378a 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -70,6 +70,9 @@ export interface StateView { export interface MoveResult { move: MoveRecord; game: GameView; + /** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */ + rack: string[]; + bagLen: number; } export interface HintResult { @@ -223,12 +226,19 @@ export interface GameList { games: GameView[]; } -/** A live event delivered over the Subscribe stream. */ +/** + * A live event delivered over the Subscribe stream. The game events carry the move as a + * delta — move plus the post-move summary (and the bag size) — the client applies to its + * cached game without a refetch; match_found / game_started carry the recipient's initial + * StateView; notify carries the changed lobby payload (R4). The enriched fields are optional + * so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer). + */ export type PushEvent = - | { kind: 'your_turn'; gameId: string; deadlineUnix: number } - | { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number } + | { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number } + | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } + | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage } | { kind: 'nudge'; gameId: string; fromUserId: string } - | { kind: 'match_found'; gameId: string } - | { kind: 'notify'; sub: string } + | { kind: 'match_found'; gameId: string; state?: StateView } + | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView } | { kind: 'heartbeat' }; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 3d5f4a6..4fbc0ff 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -27,6 +27,9 @@ // --- auto-match --- let searching = $state(false); + // matched guards the teardown: once a match arrives (immediately, via the match_found push, or + // via the fallback poll) onDestroy must not dequeue the game we just got. + let matched = $state(false); let poll: ReturnType | null = null; function stop() { @@ -35,6 +38,24 @@ poll = null; } } + // startPoll is the matchmaking fallback used only while the live stream is down: with the stream + // up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s. + function startPoll() { + if (poll) return; + poll = setInterval(async () => { + try { + const p = await gateway.lobbyPoll(); + if (p.matched && p.game) { + matched = true; + searching = false; + stop(); + navigate(`/game/${p.game.id}`); + } + } catch (e) { + handleError(e); + } + }, 2500); + } // cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match // is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the // reaper later substitutes a robot for a game the player abandoned (Stage 17 fix). @@ -47,31 +68,37 @@ async function find(v: Variant) { searching = true; + matched = false; try { const r = await gateway.lobbyEnqueue(v); if (r.matched && r.game) { + matched = true; searching = false; navigate(`/game/${r.game.id}`); - return; } - poll = setInterval(async () => { - try { - const p = await gateway.lobbyPoll(); - if (p.matched && p.game) { - stop(); - searching = false; - navigate(`/game/${p.game.id}`); - } - } catch (e) { - handleError(e); - } - }, 2500); } catch (e) { searching = false; handleError(e); } + // No immediate match: wait for the match_found push; the effect below polls only when the + // stream is down. } + // Poll for the match only while searching and the stream is down (the push cannot reach us); + // stop once the stream is back or the search ends. + $effect(() => { + if (searching && !app.streamAlive) startPoll(); + else stop(); + }); + // match_found is handled globally (app.svelte navigates); end the search here too so onDestroy + // does not cancel the match we just received. + $effect(() => { + if (app.lastEvent?.kind === 'match_found' && searching) { + matched = true; + searching = false; + } + }); + // --- friend game --- let friends = $state([]); let selected = $state([]); @@ -120,8 +147,9 @@ onDestroy(() => { stop(); - // Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger. - if (searching) void gateway.lobbyCancel().catch(() => {}); + // Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't + // linger. A received match (matched) must not be cancelled. + if (searching && !matched) void gateway.lobbyCancel().catch(() => {}); });