d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
328 lines
11 KiB
Go
328 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
|
|
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule). Pinned
|
|
// per game, so a game started before the rule existed keeps playing without it.
|
|
NoRepeatWords bool
|
|
}
|
|
|
|
// 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))
|
|
fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords)
|
|
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)
|
|
}
|