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
+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
}