Files
scrabble-game/pkg/wire/build.go
T
Ilia Denisov 9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
feat(social): hide social controls on a deleted opponent's seat
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00

324 lines
11 KiB
Go

// Package wire holds the FlatBuffers builders for the nested wire tables that both
// the backend's push-event encoder (backend/internal/notify) and the gateway's edge
// transcoder (gateway/internal/transcode) emit. The two callers resolve their own
// source types (the backend's domain payloads, the gateway's REST DTOs) into the
// neutral structs below and delegate the actual FlatBuffers construction here, so the
// shared tables (GameView, MoveRecord, StateView, AccountRef, Invitation) have a
// single encoding definition that cannot drift between the two paths.
//
// FlatBuffers is built bottom-up: every string and child vector is created before the
// table that references it, and no two tables/vectors are under construction at once.
// Each builder returns the offset of the table (or vector) it built; the caller embeds
// that offset in a parent table or finishes the buffer with it.
package wire
import (
flatbuffers "github.com/google/flatbuffers/go"
fb "scrabble/pkg/fbs/scrabblefb"
)
// SeatView is one seat's public standing in a GameView.
type SeatView struct {
Seat int
AccountID string
Score int
HintsUsed int
IsWinner bool
DisplayName string
// Deleted marks a seat whose account has been deleted (tombstoned): the client hides
// every social control aimed at it — add-friend, block, chat and nudge.
Deleted bool
}
// GameView is the shared, non-private game summary.
type GameView struct {
ID string
Variant string
DictVersion string
Status string
Players int
ToMove int
TurnTimeoutSecs int
MultipleWordsPerTurn bool
MoveCount int
EndReason string
Seats []SeatView
LastActivityUnix int64
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
VsAI bool
// UnreadChat is a per-viewer flag: the requesting player has unread chat in this game.
UnreadChat bool
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
// a nudge), so the badge can be coloured apart from the nudge-only case.
UnreadMessages bool
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
Kind int
}
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
// read from a hand).
type TileRecord struct {
Row int
Col int
Letter string
Blank bool
}
// MoveRecord is one decoded move (a committed play, a hint preview). Action and Dir are
// the already-stringified move kind and direction.
type MoveRecord struct {
Player int
Action string
Dir string
MainRow int
MainCol int
Tiles []TileRecord
Words []string
Count int
Score int
Total int
}
// AlphabetEntry is one letter of a variant's alphabet (Index is the wire alphabet-index
// byte; a value of 255 is the blank sentinel elsewhere).
type AlphabetEntry struct {
Index int
Letter string
Value int
}
// StateView is a player's view of a game: the shared summary plus their private rack
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
// recipient may not have cached the variant's display table yet.
type StateView struct {
Game GameView
Seat int
Rack []int
BagLen int
HintsRemaining int
HintUnlockLeftSeconds int
Alphabet []AlphabetEntry
}
// AccountRef is a referenced account with its display name resolved.
type AccountRef struct {
AccountID string
DisplayName string
}
// InvitationInvitee is one invited player's seat and response inside an Invitation.
type InvitationInvitee struct {
AccountID string
DisplayName string
Seat int
Response string
}
// Invitation is a friend-game invitation with its settings and invitees.
type Invitation struct {
ID string
Inviter AccountRef
Invitees []InvitationInvitee
Variant string
TurnTimeoutSecs int
HintsAllowed bool
HintsPerPlayer int
MultipleWordsPerTurn bool
DropoutTiles string
Status string
GameID string
ExpiresAtUnix int64
}
// BuildGameView builds a GameView table from g and returns its offset.
func BuildGameView(b *flatbuffers.Builder, g GameView) 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)
fb.SeatViewAddDeleted(b, s.Deleted)
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.GameViewAddMultipleWordsPerTurn(b, g.MultipleWordsPerTurn)
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
fb.GameViewAddEndReason(b, endReason)
fb.GameViewAddSeats(b, seats)
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
fb.GameViewAddVsAi(b, g.VsAI)
fb.GameViewAddUnreadChat(b, g.UnreadChat)
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
fb.GameViewAddKind(b, int32(g.Kind))
return fb.GameViewEnd(b)
}
// BuildMoveRecord builds a MoveRecord table from m and returns its offset.
func BuildMoveRecord(b *flatbuffers.Builder, m 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)
dir := b.CreateString(m.Dir)
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)
}
// BuildStateViewAlphabet builds the AlphabetEntry vector embedded in a StateView and
// returns its offset.
func BuildStateViewAlphabet(b *flatbuffers.Builder, entries []AlphabetEntry) 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 s and returns its offset. The alphabet
// table is embedded only when s.Alphabet is non-empty.
func BuildStateView(b *flatbuffers.Builder, s StateView) 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 = BuildStateViewAlphabet(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))
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
if hasAlphabet {
fb.StateViewAddAlphabet(b, alphabet)
}
return fb.StateViewEnd(b)
}
// BuildAccountRef builds an AccountRef table from a 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 inv and returns its offset.
func BuildInvitation(b *flatbuffers.Builder, inv Invitation) flatbuffers.UOffsetT {
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))
inviter := BuildAccountRef(b, inv.Inviter)
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.InvitationAddMultipleWordsPerTurn(b, inv.MultipleWordsPerTurn)
fb.InvitationAddDropoutTiles(b, dropout)
fb.InvitationAddStatus(b, status)
fb.InvitationAddGameId(b, gameID)
fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix)
return fb.InvitationEnd(b)
}