Stage 7 (wip): wire remaining ops (backend REST, FBS, gateway transcode) + real UI transport

backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution
pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26)
gateway: 11 new transcode ops + backendclient methods + FB encoders
ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS
This commit is contained in:
Ilia Denisov
2026-06-03 00:49:07 +02:00
parent 453ddc5e94
commit 65689b903f
64 changed files with 5151 additions and 52 deletions
+123 -5
View File
@@ -54,11 +54,12 @@ type MoveRecordResp struct {
// SeatResp is one seat's public standing.
type SeatResp struct {
Seat int `json:"seat"`
AccountID string `json:"account_id"`
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
Seat int `json:"seat"`
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
}
// GameResp is the shared game summary.
@@ -189,3 +190,120 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st
map[string]string{"body": body}, &out)
return out, err
}
// HintResultResp is the top-ranked move plus the remaining hint budget.
type HintResultResp struct {
Move MoveRecordResp `json:"move"`
HintsRemaining int `json:"hints_remaining"`
}
// EvalResultResp is an unlimited move preview.
type EvalResultResp struct {
Legal bool `json:"legal"`
Score int `json:"score"`
Words []string `json:"words"`
}
// WordCheckResp is a dictionary lookup outcome.
type WordCheckResp struct {
Word string `json:"word"`
Legal bool `json:"legal"`
}
// HistoryResp is a game's decoded move journal.
type HistoryResp struct {
GameID string `json:"game_id"`
Moves []MoveRecordResp `json:"moves"`
}
// GameListResp is the caller's games for the lobby.
type GameListResp struct {
Games []GameResp `json:"games"`
}
// ChatListResp is a game's chat history.
type ChatListResp struct {
Messages []ChatResp `json:"messages"`
}
func (c *Client) gamePath(gameID, suffix string) string {
return "/api/v1/user/games/" + url.PathEscape(gameID) + suffix
}
// Pass forfeits the player's turn.
func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/pass"), userID, "", struct{}{}, &out)
return out, err
}
// Exchange swaps the chosen rack tiles back into the bag.
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
map[string]any{"tiles": tiles}, &out)
return out, err
}
// Resign resigns the player from the game.
func (c *Client) Resign(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/resign"), userID, "", struct{}{}, &out)
return out, err
}
// Hint reveals the top-ranked move and spends a hint.
func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultResp, error) {
var out HintResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hint"), userID, "", struct{}{}, &out)
return out, err
}
// Evaluate previews a tentative play's legality and score.
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (EvalResultResp, error) {
var out EvalResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
map[string]any{"dir": dir, "tiles": tiles}, &out)
return out, err
}
// CheckWord looks a word up in the game's pinned dictionary.
func (c *Client) CheckWord(ctx context.Context, userID, gameID, word string) (WordCheckResp, error) {
var out WordCheckResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?word="+url.QueryEscape(word), userID, "", nil, &out)
return out, err
}
// Complaint disputes a word-check result.
func (c *Client) Complaint(ctx context.Context, userID, gameID, word, note string) error {
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/complaint"), userID, "",
map[string]string{"word": word, "note": note}, nil)
}
// History returns a game's decoded move journal.
func (c *Client) History(ctx context.Context, userID, gameID string) (HistoryResp, error) {
var out HistoryResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/history"), userID, "", nil, &out)
return out, err
}
// ChatList returns a game's chat history.
func (c *Client) ChatList(ctx context.Context, userID, gameID string) (ChatListResp, error) {
var out ChatListResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/chat"), userID, "", nil, &out)
return out, err
}
// Nudge posts a nudge to the player whose turn is awaited.
func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, error) {
var out ChatResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/nudge"), userID, "", struct{}{}, &out)
return out, err
}
// GamesList returns the caller's active and finished games.
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
var out GameListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/games", userID, "", nil, &out)
return out, err
}
+101 -4
View File
@@ -100,9 +100,8 @@ func encodeMatch(m backendclient.MatchResp) []byte {
return b.FinishedBytes()
}
// encodeChat builds a ChatMessage payload.
func encodeChat(c backendclient.ChatResp) []byte {
b := flatbuffers.NewBuilder(192)
// buildChatMessage builds a ChatMessage table and returns its offset.
func buildChatMessage(b *flatbuffers.Builder, c backendclient.ChatResp) flatbuffers.UOffsetT {
id := b.CreateString(c.ID)
gid := b.CreateString(c.GameID)
sid := b.CreateString(c.SenderID)
@@ -115,7 +114,103 @@ func encodeChat(c backendclient.ChatResp) []byte {
fb.ChatMessageAddKind(b, kind)
fb.ChatMessageAddBody(b, body)
fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix)
b.Finish(fb.ChatMessageEnd(b))
return fb.ChatMessageEnd(b)
}
// encodeChat builds a ChatMessage payload.
func encodeChat(c backendclient.ChatResp) []byte {
b := flatbuffers.NewBuilder(192)
b.Finish(buildChatMessage(b, c))
return b.FinishedBytes()
}
// encodeHintResult builds a HintResult payload.
func encodeHintResult(r backendclient.HintResultResp) []byte {
b := flatbuffers.NewBuilder(512)
move := buildMoveRecord(b, r.Move)
fb.HintResultStart(b)
fb.HintResultAddMove(b, move)
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
b.Finish(fb.HintResultEnd(b))
return b.FinishedBytes()
}
// encodeEvalResult builds an EvalResult payload.
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
b := flatbuffers.NewBuilder(256)
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
fb.EvalResultStart(b)
fb.EvalResultAddLegal(b, r.Legal)
fb.EvalResultAddScore(b, int32(r.Score))
fb.EvalResultAddWords(b, words)
b.Finish(fb.EvalResultEnd(b))
return b.FinishedBytes()
}
// encodeWordCheck builds a WordCheckResult payload.
func encodeWordCheck(r backendclient.WordCheckResp) []byte {
b := flatbuffers.NewBuilder(64)
word := b.CreateString(r.Word)
fb.WordCheckResultStart(b)
fb.WordCheckResultAddWord(b, word)
fb.WordCheckResultAddLegal(b, r.Legal)
b.Finish(fb.WordCheckResultEnd(b))
return b.FinishedBytes()
}
// encodeHistory builds a History payload (the decoded move journal).
func encodeHistory(r backendclient.HistoryResp) []byte {
b := flatbuffers.NewBuilder(1024)
moveOffs := make([]flatbuffers.UOffsetT, len(r.Moves))
for i, m := range r.Moves {
moveOffs[i] = buildMoveRecord(b, m)
}
fb.HistoryStartMovesVector(b, len(moveOffs))
for i := len(moveOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(moveOffs[i])
}
moves := b.EndVector(len(moveOffs))
gid := b.CreateString(r.GameID)
fb.HistoryStart(b)
fb.HistoryAddGameId(b, gid)
fb.HistoryAddMoves(b, moves)
b.Finish(fb.HistoryEnd(b))
return b.FinishedBytes()
}
// encodeGameList builds a GameList payload (the caller's games).
func encodeGameList(r backendclient.GameListResp) []byte {
b := flatbuffers.NewBuilder(1024)
gameOffs := make([]flatbuffers.UOffsetT, len(r.Games))
for i, g := range r.Games {
gameOffs[i] = buildGameView(b, g)
}
fb.GameListStartGamesVector(b, len(gameOffs))
for i := len(gameOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(gameOffs[i])
}
games := b.EndVector(len(gameOffs))
fb.GameListStart(b)
fb.GameListAddGames(b, games)
b.Finish(fb.GameListEnd(b))
return b.FinishedBytes()
}
// encodeChatList builds a ChatList payload (a game's chat history).
func encodeChatList(r backendclient.ChatListResp) []byte {
b := flatbuffers.NewBuilder(512)
msgOffs := make([]flatbuffers.UOffsetT, len(r.Messages))
for i, m := range r.Messages {
msgOffs[i] = buildChatMessage(b, m)
}
fb.ChatListStartMessagesVector(b, len(msgOffs))
for i := len(msgOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(msgOffs[i])
}
msgs := b.EndVector(len(msgOffs))
fb.ChatListStart(b)
fb.ChatListAddMessages(b, msgs)
b.Finish(fb.ChatListEnd(b))
return b.FinishedBytes()
}
@@ -124,12 +219,14 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
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))
+169
View File
@@ -26,6 +26,17 @@ const (
MsgLobbyEnqueue = "lobby.enqueue"
MsgLobbyPoll = "lobby.poll"
MsgChatPost = "chat.post"
MsgGamesList = "games.list"
MsgGamePass = "game.pass"
MsgGameExchange = "game.exchange"
MsgGameResign = "game.resign"
MsgGameHint = "game.hint"
MsgGameEvaluate = "game.evaluate"
MsgGameCheckWord = "game.check_word"
MsgGameComplaint = "game.complaint"
MsgGameHistory = "game.history"
MsgChatList = "chat.list"
MsgChatNudge = "chat.nudge"
)
// Request is one decoded Execute call.
@@ -69,6 +80,17 @@ func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Regi
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
r.ops[MsgGamePass] = Op{Handler: passHandler(backend), Auth: true}
r.ops[MsgGameExchange] = Op{Handler: exchangeHandler(backend), Auth: true}
r.ops[MsgGameResign] = Op{Handler: resignHandler(backend), Auth: true}
r.ops[MsgGameHint] = Op{Handler: hintHandler(backend), Auth: true}
r.ops[MsgGameEvaluate] = Op{Handler: evaluateHandler(backend), Auth: true}
r.ops[MsgGameCheckWord] = Op{Handler: checkWordHandler(backend), Auth: true}
r.ops[MsgGameComplaint] = Op{Handler: complaintHandler(backend), Auth: true}
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
return r
}
@@ -219,3 +241,150 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
}
return tiles
}
// decodeEvalTiles reads the tentative tiles from an EvalRequest.
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON {
n := in.TilesLength()
tiles := make([]backendclient.TileJSON, 0, n)
var t fb.TileRecord
for i := 0; i < n; i++ {
if in.Tiles(&t, i) {
tiles = append(tiles, backendclient.TileJSON{
Row: int(t.Row()),
Col: int(t.Col()),
Letter: string(t.Letter()),
Blank: t.Blank(),
})
}
}
return tiles
}
// decodeStringVector reads the exchange tiles from an ExchangeRequest.
func decodeStringVector(in *fb.ExchangeRequest) []string {
n := in.TilesLength()
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, string(in.Tiles(i)))
}
return out
}
func gamesListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.GamesList(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeGameList(res), nil
}
}
func passHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Pass(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func resignHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Resign(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func exchangeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsExchangeRequest(req.Payload, 0)
res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), decodeStringVector(in))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func hintHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Hint(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeHintResult(res), nil
}
}
func evaluateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEvalRequest(req.Payload, 0)
res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in))
if err != nil {
return nil, err
}
return encodeEvalResult(res), nil
}
}
func checkWordHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsCheckWordRequest(req.Payload, 0)
res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), string(in.Word()))
if err != nil {
return nil, err
}
return encodeWordCheck(res), nil
}
}
func complaintHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsComplaintRequest(req.Payload, 0)
if err := backend.Complaint(ctx, req.UserID, string(in.GameId()), string(in.Word()), string(in.Note())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func historyHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.History(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeHistory(res), nil
}
}
func chatListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.ChatList(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeChatList(res), nil
}
}
func nudgeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Nudge(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeChat(res), nil
}
}