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