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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user