d5fbaa3034
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
279 lines
9.5 KiB
Go
279 lines
9.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()
|
|
}
|
|
|
|
// buildRobotFriendVector builds the OutgoingRequestList robots vector (per-game
|
|
// disguised-robot friend requests).
|
|
func buildRobotFriendVector(b *flatbuffers.Builder, robots []backendclient.RobotFriendReqResp) flatbuffers.UOffsetT {
|
|
offs := make([]flatbuffers.UOffsetT, len(robots))
|
|
for i, r := range robots {
|
|
id := b.CreateString(r.ID)
|
|
name := b.CreateString(r.DisplayName)
|
|
gid := b.CreateString(r.GameID)
|
|
fb.RobotFriendRefStart(b)
|
|
fb.RobotFriendRefAddId(b, id)
|
|
fb.RobotFriendRefAddDisplayName(b, name)
|
|
fb.RobotFriendRefAddGameId(b, gid)
|
|
fb.RobotFriendRefAddSeat(b, int32(r.Seat))
|
|
offs[i] = fb.RobotFriendRefEnd(b)
|
|
}
|
|
fb.OutgoingRequestListStartRobotsVector(b, len(offs))
|
|
for i := len(offs) - 1; i >= 0; i-- {
|
|
b.PrependUOffsetT(offs[i])
|
|
}
|
|
return b.EndVector(len(offs))
|
|
}
|
|
|
|
// encodeOutgoingList builds an OutgoingRequestList payload.
|
|
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
|
|
b := flatbuffers.NewBuilder(256)
|
|
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
|
|
rv := buildRobotFriendVector(b, r.Robots)
|
|
fb.OutgoingRequestListStart(b)
|
|
fb.OutgoingRequestListAddRequests(b, v)
|
|
fb.OutgoingRequestListAddRobots(b, rv)
|
|
b.Finish(fb.OutgoingRequestListEnd(b))
|
|
return b.FinishedBytes()
|
|
}
|
|
|
|
// buildRobotBlockVector builds the BlockList robots vector (per-game disguised-robot blocks).
|
|
func buildRobotBlockVector(b *flatbuffers.Builder, robots []backendclient.RobotBlockResp) flatbuffers.UOffsetT {
|
|
offs := make([]flatbuffers.UOffsetT, len(robots))
|
|
for i, r := range robots {
|
|
id := b.CreateString(r.ID)
|
|
name := b.CreateString(r.DisplayName)
|
|
gid := b.CreateString(r.GameID)
|
|
fb.RobotBlockRefStart(b)
|
|
fb.RobotBlockRefAddId(b, id)
|
|
fb.RobotBlockRefAddDisplayName(b, name)
|
|
fb.RobotBlockRefAddGameId(b, gid)
|
|
fb.RobotBlockRefAddSeat(b, int32(r.Seat))
|
|
offs[i] = fb.RobotBlockRefEnd(b)
|
|
}
|
|
fb.BlockListStartRobotsVector(b, len(offs))
|
|
for i := len(offs) - 1; i >= 0; i-- {
|
|
b.PrependUOffsetT(offs[i])
|
|
}
|
|
return b.EndVector(len(offs))
|
|
}
|
|
|
|
// encodeBlockList builds a BlockList payload.
|
|
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
|
b := flatbuffers.NewBuilder(256)
|
|
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
|
|
rv := buildRobotBlockVector(b, r.Robots)
|
|
fb.BlockListStart(b)
|
|
fb.BlockListAddBlocked(b, v)
|
|
fb.BlockListAddRobots(b, rv)
|
|
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()
|
|
}
|
|
|
|
// encodeExportURL builds an ExportUrl payload.
|
|
func encodeExportURL(r backendclient.ExportURLResp) []byte {
|
|
b := flatbuffers.NewBuilder(256)
|
|
path := b.CreateString(r.Path)
|
|
fn := b.CreateString(r.Filename)
|
|
fb.ExportUrlStart(b)
|
|
fb.ExportUrlAddPath(b, path)
|
|
fb.ExportUrlAddFilename(b, fn)
|
|
b.Finish(fb.ExportUrlEnd(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()
|
|
}
|