408da3f201
New public ingress and the first network edge. Framework + a vertical slice of operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7. Contracts (new module scrabble/pkg): - push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen). Backend: - REST handlers on the /api/v1 groups: internal session endpoints (telegram/guest/email login -> mint, resolve, revoke) and the user slice (profile, submit_play, state, lobby enqueue/poll, chat). - internal/notify in-process Publisher hub + internal/pushgrpc gRPC server (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found; emission in game.commit, social, matchmaker. - migration 00005 accounts.is_guest; guests are durable rows excluded from stats; ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode). Gateway (new module scrabble/gateway): - Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON transcode registry, Telegram initData HMAC validator (seam), session cache, token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push gRPC client, admin Basic-Auth reverse proxy. go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/** path filters; unit build/vet/test span all three modules. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests + guest/email-login integration tests.
251 lines
7.1 KiB
Go
251 lines
7.1 KiB
Go
package server
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// The JSON DTOs below are the gateway<->backend REST contract. They are explicit
|
|
// (the domain/engine structs are never serialised directly) and mirror the
|
|
// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from.
|
|
|
|
// sessionResponse is the credential returned by every auth endpoint.
|
|
type sessionResponse struct {
|
|
Token string `json:"token"`
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// okResponse is a simple success acknowledgement.
|
|
type okResponse struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// resolveResponse maps a session token to its account.
|
|
type resolveResponse struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
// profileResponse is the authenticated account's own profile.
|
|
type profileResponse struct {
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
HintBalance int `json:"hint_balance"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
IsGuest bool `json:"is_guest"`
|
|
}
|
|
|
|
// tileDTO is one placed (or to-place) tile.
|
|
type tileDTO struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter string `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// moveRecordDTO is a decoded move (a committed play, or a hint preview).
|
|
type moveRecordDTO struct {
|
|
Player int `json:"player"`
|
|
Action string `json:"action"`
|
|
Dir string `json:"dir"`
|
|
MainRow int `json:"main_row"`
|
|
MainCol int `json:"main_col"`
|
|
Tiles []tileDTO `json:"tiles"`
|
|
Words []string `json:"words"`
|
|
Count int `json:"count"`
|
|
Score int `json:"score"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// seatDTO is one seat's public standing.
|
|
type seatDTO struct {
|
|
Seat int `json:"seat"`
|
|
AccountID string `json:"account_id"`
|
|
Score int `json:"score"`
|
|
HintsUsed int `json:"hints_used"`
|
|
IsWinner bool `json:"is_winner"`
|
|
}
|
|
|
|
// gameDTO is the shared game summary.
|
|
type gameDTO struct {
|
|
ID string `json:"id"`
|
|
Variant string `json:"variant"`
|
|
DictVersion string `json:"dict_version"`
|
|
Status string `json:"status"`
|
|
Players int `json:"players"`
|
|
ToMove int `json:"to_move"`
|
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
|
MoveCount int `json:"move_count"`
|
|
EndReason string `json:"end_reason"`
|
|
Seats []seatDTO `json:"seats"`
|
|
}
|
|
|
|
// moveResultDTO is the outcome of a committed move.
|
|
type moveResultDTO struct {
|
|
Move moveRecordDTO `json:"move"`
|
|
Game gameDTO `json:"game"`
|
|
}
|
|
|
|
// stateDTO is a player's view of a game.
|
|
type stateDTO struct {
|
|
Game gameDTO `json:"game"`
|
|
Seat int `json:"seat"`
|
|
Rack []string `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
}
|
|
|
|
// matchDTO reports whether the caller has been paired into a game.
|
|
type matchDTO struct {
|
|
Matched bool `json:"matched"`
|
|
Game *gameDTO `json:"game,omitempty"`
|
|
}
|
|
|
|
// chatDTO is one stored chat message or nudge.
|
|
type chatDTO struct {
|
|
ID string `json:"id"`
|
|
GameID string `json:"game_id"`
|
|
SenderID string `json:"sender_id"`
|
|
Kind string `json:"kind"`
|
|
Body string `json:"body"`
|
|
CreatedAtUnix int64 `json:"created_at_unix"`
|
|
}
|
|
|
|
// errorResponse is the uniform error envelope.
|
|
type errorResponse struct {
|
|
Error errorBody `json:"error"`
|
|
}
|
|
|
|
type errorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// sessionResponseFor builds the credential payload for a minted session.
|
|
func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
|
return sessionResponse{
|
|
Token: token,
|
|
UserID: acc.ID.String(),
|
|
IsGuest: acc.IsGuest,
|
|
DisplayName: acc.DisplayName,
|
|
}
|
|
}
|
|
|
|
// profileResponseFor projects an account into its profile DTO.
|
|
func profileResponseFor(acc account.Account) profileResponse {
|
|
return profileResponse{
|
|
UserID: acc.ID.String(),
|
|
DisplayName: acc.DisplayName,
|
|
PreferredLanguage: acc.PreferredLanguage,
|
|
TimeZone: acc.TimeZone,
|
|
HintBalance: acc.HintBalance,
|
|
BlockChat: acc.BlockChat,
|
|
BlockFriendRequests: acc.BlockFriendRequests,
|
|
IsGuest: acc.IsGuest,
|
|
}
|
|
}
|
|
|
|
// gameDTOFromGame projects a game.Game into its DTO.
|
|
func gameDTOFromGame(g game.Game) gameDTO {
|
|
seats := make([]seatDTO, 0, len(g.Seats))
|
|
for _, s := range g.Seats {
|
|
seats = append(seats, seatDTO{
|
|
Seat: s.Seat,
|
|
AccountID: s.AccountID.String(),
|
|
Score: s.Score,
|
|
HintsUsed: s.HintsUsed,
|
|
IsWinner: s.IsWinner,
|
|
})
|
|
}
|
|
return gameDTO{
|
|
ID: g.ID.String(),
|
|
Variant: g.Variant.String(),
|
|
DictVersion: g.DictVersion,
|
|
Status: g.Status,
|
|
Players: g.Players,
|
|
ToMove: g.ToMove,
|
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
|
MoveCount: g.MoveCount,
|
|
EndReason: g.EndReason,
|
|
Seats: seats,
|
|
}
|
|
}
|
|
|
|
// moveRecordDTOFrom projects an engine move record into its DTO.
|
|
func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
|
tiles := make([]tileDTO, 0, len(m.Tiles))
|
|
for _, t := range m.Tiles {
|
|
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
return moveRecordDTO{
|
|
Player: m.Player,
|
|
Action: m.Action.String(),
|
|
Dir: m.Dir.String(),
|
|
MainRow: m.MainRow,
|
|
MainCol: m.MainCol,
|
|
Tiles: tiles,
|
|
Words: m.Words,
|
|
Count: m.Count,
|
|
Score: m.Score,
|
|
Total: m.Total,
|
|
}
|
|
}
|
|
|
|
// moveResultDTOFrom projects a committed move result into its DTO.
|
|
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
|
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
|
}
|
|
|
|
// stateDTOFrom projects a player's state view into its DTO.
|
|
func stateDTOFrom(v game.StateView) stateDTO {
|
|
return stateDTO{
|
|
Game: gameDTOFromGame(v.Game),
|
|
Seat: v.Seat,
|
|
Rack: v.Rack,
|
|
BagLen: v.BagLen,
|
|
HintsRemaining: v.HintsRemaining,
|
|
}
|
|
}
|
|
|
|
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
|
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
|
|
if !r.Matched {
|
|
return matchDTO{Matched: false}
|
|
}
|
|
g := gameDTOFromGame(r.Game)
|
|
return matchDTO{Matched: true, Game: &g}
|
|
}
|
|
|
|
// chatDTOFrom projects a chat message into its DTO.
|
|
func chatDTOFrom(m social.Message) chatDTO {
|
|
return chatDTO{
|
|
ID: m.ID.String(),
|
|
GameID: m.GameID.String(),
|
|
SenderID: m.SenderID.String(),
|
|
Kind: m.Kind,
|
|
Body: m.Body,
|
|
CreatedAtUnix: m.CreatedAt.Unix(),
|
|
}
|
|
}
|
|
|
|
// parseDirection maps the wire direction string to an engine.Direction.
|
|
func parseDirection(s string) (engine.Direction, bool) {
|
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
|
case "H":
|
|
return engine.Horizontal, true
|
|
case "V":
|
|
return engine.Vertical, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|