R4: push enrichment — events carry a state delta, kill the last poll #35

Merged
developer merged 1 commits from feature/r4-push-enrichment into development 2026-06-10 10:30:49 +00:00
47 changed files with 1514 additions and 180 deletions
+37 -1
View File
@@ -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.
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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 {
+79
View File
@@ -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
}
+26 -6
View File
@@ -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
+7 -3
View File
@@ -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
+62 -8
View File
@@ -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
}
+6 -1
View File
@@ -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
+12 -4
View File
@@ -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)
}
}
@@ -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 {
+197
View File
@@ -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)
}
+80 -21
View File
@@ -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 {
+96 -4
View File
@@ -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) {
+89
View File
@@ -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
}
+20 -6
View File
@@ -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
+5 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+15 -4
View File
@@ -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
}
+16 -5
View File
@@ -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
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -43,7 +43,8 @@ nudge) приходят от бота **этой партии** — по язы
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
+3 -1
View File
@@ -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`,
+6 -3
View File
@@ -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
+7
View File
@@ -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()
}
+35 -7
View File
@@ -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;
}
+17 -1
View File
@@ -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()
}
+17 -1
View File
@@ -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()
}
+56 -1
View File
@@ -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()
}
+49 -1
View File
@@ -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()
}
+48 -1
View File
@@ -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()
}
+16 -1
View File
@@ -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()
}
+3 -1
View File
@@ -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`.
+52 -17
View File
@@ -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 {
+13 -8
View File
@@ -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);
}
}
+13 -6
View File
@@ -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);
}
}
+41 -1
View File
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
+12 -2
View File
@@ -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);
}
}
+15 -7
View File
@@ -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<string, number>;
}>({
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<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | 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<void> {
+51 -9
View File
@@ -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' };
+1 -1
View File
@@ -7,7 +7,7 @@
import type { MoveRecord, StateView } from './model';
interface CachedGame {
export interface CachedGame {
view: StateView;
moves: MoveRecord[];
}
+99
View File
@@ -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);
});
});
+69
View File
@@ -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 };
}
+4 -4
View File
@@ -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<MoveResult> {
@@ -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);
}
+15 -5
View File
@@ -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' };
+43 -15
View File
@@ -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<typeof setInterval> | 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<AccountRef[]>([]);
let selected = $state<string[]>([]);
@@ -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(() => {});
});
</script>