R4: push enrichment — events carry a state delta, kill the last poll
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.

- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
This commit is contained in:
Ilia Denisov
2026-06-10 08:01:50 +02:00
parent e3b08461f0
commit 41a642ef97
47 changed files with 1514 additions and 180 deletions
+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