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
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user