Files
scrabble-game/gateway/internal/transcode/encode_social.go
T
Ilia Denisov 6d1d8030e3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
feat(stats): per-game hints used + lifetime moves and hint share
Store the hints a player used in each game, and add two lifetime tiles —
Moves and Hint share — to the statistics screen.

- per-game: game_players.hints_used now counts EVERY hint (allowance + wallet),
  not just the free allowance, so it is the seat's true total hints used this
  game. The allowance decision (used < HintsPerPlayer) and the hint badge values
  (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does
  NOT regress; only the admin "hints used" column, which silently under-counted
  wallet hints, becomes accurate.
- account_stats gains two summed counters: moves (the player's plays — passes and
  exchanges excluded) and hints_used (every hint). Computed at game finish in
  buildStats over the same non-guest, non-honest-AI games as the rest of the stats.
- wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model;
  regen.
- ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active
  locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate.
- docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN.

Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats
(moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
2026-06-17 23:44:53 +02:00

220 lines
7.5 KiB
Go

package transcode
import (
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
"scrabble/pkg/wire"
)
// Social encoders: friends, blocks, invitations, statistics and GCG. They follow
// encode.go's bottom-up rule (build every string/child vector before the table).
// buildAccountRef builds an AccountRef table and returns its offset.
func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT {
return wire.BuildAccountRef(b, wire.AccountRef{AccountID: r.AccountID, DisplayName: r.DisplayName})
}
// buildAccountRefVector builds a [AccountRef] vector using the table-specific
// StartXVector function and returns the vector offset.
func buildAccountRefVector(b *flatbuffers.Builder, refs []backendclient.AccountRefResp, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT {
offs := make([]flatbuffers.UOffsetT, len(refs))
for i, r := range refs {
offs[i] = buildAccountRef(b, r)
}
start(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
return b.EndVector(len(offs))
}
// encodeFriendList builds a FriendList payload.
func encodeFriendList(r backendclient.FriendListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Friends, fb.FriendListStartFriendsVector)
fb.FriendListStart(b)
fb.FriendListAddFriends(b, v)
b.Finish(fb.FriendListEnd(b))
return b.FinishedBytes()
}
// encodeIncomingList builds an IncomingRequestList payload.
func encodeIncomingList(r backendclient.IncomingListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Requests, fb.IncomingRequestListStartRequestsVector)
fb.IncomingRequestListStart(b)
fb.IncomingRequestListAddRequests(b, v)
b.Finish(fb.IncomingRequestListEnd(b))
return b.FinishedBytes()
}
// encodeOutgoingList builds an OutgoingRequestList payload.
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
fb.OutgoingRequestListStart(b)
fb.OutgoingRequestListAddRequests(b, v)
b.Finish(fb.OutgoingRequestListEnd(b))
return b.FinishedBytes()
}
// encodeBlockList builds a BlockList payload.
func encodeBlockList(r backendclient.BlockListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
fb.BlockListStart(b)
fb.BlockListAddBlocked(b, v)
b.Finish(fb.BlockListEnd(b))
return b.FinishedBytes()
}
// encodeFriendCode builds a FriendCode payload.
func encodeFriendCode(r backendclient.FriendCodeResp) []byte {
b := flatbuffers.NewBuilder(64)
code := b.CreateString(r.Code)
fb.FriendCodeStart(b)
fb.FriendCodeAddCode(b, code)
fb.FriendCodeAddExpiresAtUnix(b, r.ExpiresAtUnix)
b.Finish(fb.FriendCodeEnd(b))
return b.FinishedBytes()
}
// encodeRedeemResult builds a RedeemResult payload.
func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
b := flatbuffers.NewBuilder(128)
friend := buildAccountRef(b, r.Friend)
fb.RedeemResultStart(b)
fb.RedeemResultAddFriend(b, friend)
b.Finish(fb.RedeemResultEnd(b))
return b.FinishedBytes()
}
// buildBestMoveTile builds a BestMoveTile table and returns its offset.
func buildBestMoveTile(b *flatbuffers.Builder, t backendclient.BestMoveTileResp) flatbuffers.UOffsetT {
letter := b.CreateString(t.Letter)
fb.BestMoveTileStart(b)
fb.BestMoveTileAddLetter(b, letter)
fb.BestMoveTileAddValue(b, int32(t.Value))
fb.BestMoveTileAddBlank(b, t.Blank)
return fb.BestMoveTileEnd(b)
}
// buildBestMove builds a BestMoveView table — its word tile vector first, per the
// bottom-up rule — and returns its offset.
func buildBestMove(b *flatbuffers.Builder, m backendclient.BestMoveResp) flatbuffers.UOffsetT {
tiles := make([]flatbuffers.UOffsetT, len(m.Word))
for i, t := range m.Word {
tiles[i] = buildBestMoveTile(b, t)
}
fb.BestMoveViewStartWordVector(b, len(tiles))
for i := len(tiles) - 1; i >= 0; i-- {
b.PrependUOffsetT(tiles[i])
}
word := b.EndVector(len(tiles))
variant := b.CreateString(m.Variant)
fb.BestMoveViewStart(b)
fb.BestMoveViewAddVariant(b, variant)
fb.BestMoveViewAddScore(b, int32(m.Score))
fb.BestMoveViewAddWord(b, word)
return fb.BestMoveViewEnd(b)
}
// encodeStats builds a StatsView payload, embedding the per-variant best moves (each a
// BestMoveView with its word tiles) when present.
func encodeStats(r backendclient.StatsResp) []byte {
b := flatbuffers.NewBuilder(256)
var bestMoves flatbuffers.UOffsetT
if len(r.BestMoves) > 0 {
offs := make([]flatbuffers.UOffsetT, len(r.BestMoves))
for i, m := range r.BestMoves {
offs[i] = buildBestMove(b, m)
}
fb.StatsViewStartBestMovesVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
bestMoves = b.EndVector(len(offs))
}
fb.StatsViewStart(b)
fb.StatsViewAddWins(b, int32(r.Wins))
fb.StatsViewAddLosses(b, int32(r.Losses))
fb.StatsViewAddDraws(b, int32(r.Draws))
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints))
fb.StatsViewAddMoves(b, int32(r.Moves))
fb.StatsViewAddHintsUsed(b, int32(r.HintsUsed))
if len(r.BestMoves) > 0 {
fb.StatsViewAddBestMoves(b, bestMoves)
}
b.Finish(fb.StatsViewEnd(b))
return b.FinishedBytes()
}
// buildInvitation builds an Invitation table and returns its offset.
func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT {
invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
for i, iv := range inv.Invitees {
invitees[i] = wire.InvitationInvitee{
AccountID: iv.AccountID,
DisplayName: iv.DisplayName,
Seat: iv.Seat,
Response: iv.Response,
}
}
return wire.BuildInvitation(b, wire.Invitation{
ID: inv.ID,
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
Invitees: invitees,
Variant: inv.Variant,
TurnTimeoutSecs: inv.TurnTimeoutSecs,
HintsAllowed: inv.HintsAllowed,
HintsPerPlayer: inv.HintsPerPlayer,
MultipleWordsPerTurn: inv.MultipleWordsPerTurn,
DropoutTiles: inv.DropoutTiles,
Status: inv.Status,
GameID: inv.GameID,
ExpiresAtUnix: inv.ExpiresAtUnix,
})
}
// encodeInvitation builds an Invitation payload.
func encodeInvitation(inv backendclient.InvitationResp) []byte {
b := flatbuffers.NewBuilder(512)
b.Finish(buildInvitation(b, inv))
return b.FinishedBytes()
}
// encodeInvitationList builds an InvitationList payload.
func encodeInvitationList(r backendclient.InvitationListResp) []byte {
b := flatbuffers.NewBuilder(1024)
offs := make([]flatbuffers.UOffsetT, len(r.Invitations))
for i, inv := range r.Invitations {
offs[i] = buildInvitation(b, inv)
}
fb.InvitationListStartInvitationsVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
v := b.EndVector(len(offs))
fb.InvitationListStart(b)
fb.InvitationListAddInvitations(b, v)
b.Finish(fb.InvitationListEnd(b))
return b.FinishedBytes()
}
// encodeGcg builds a GcgExport payload.
func encodeGcg(r backendclient.GcgResp) []byte {
b := flatbuffers.NewBuilder(1024)
gid := b.CreateString(r.GameID)
fn := b.CreateString(r.Filename)
content := b.CreateString(r.Content)
fb.GcgExportStart(b)
fb.GcgExportAddGameId(b, gid)
fb.GcgExportAddFilename(b, fn)
fb.GcgExportAddContent(b, content)
b.Finish(fb.GcgExportEnd(b))
return b.FinishedBytes()
}