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