6679260d0a
Thread the Telegram bot's service language (en/ru) from the session mint response through the gateway into the FlatBuffers Session, so the UI knows which bot the player signed in through. handleTelegramAuth refreshes the account's service language onto the response before minting (it was set after the fetched copy). Empty for a non-Telegram login.
312 lines
10 KiB
Go
312 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"github.com/google/uuid"
|
|
|
|
"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"`
|
|
ServiceLanguage string `json:"service_language"`
|
|
}
|
|
|
|
// okResponse is a simple success acknowledgement.
|
|
type okResponse struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// resolveResponse maps a session token to its account. IsGuest lets the gateway
|
|
// gate guest-forbidden operations without an extra round-trip.
|
|
type resolveResponse struct {
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
}
|
|
|
|
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
|
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
|
|
type profileResponse struct {
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
AwayStart string `json:"away_start"`
|
|
AwayEnd string `json:"away_end"`
|
|
HintBalance int `json:"hint_balance"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
IsGuest bool `json:"is_guest"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// 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. DisplayName is resolved from the account
|
|
// store by the handler (the game domain keys seats by account id only).
|
|
type seatDTO struct {
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
|
MoveCount int `json:"move_count"`
|
|
EndReason string `json:"end_reason"`
|
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
|
// game, the finish time once finished.
|
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
|
Seats []seatDTO `json:"seats"`
|
|
}
|
|
|
|
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
|
// wire alphabet indices and BagLen the bag size after the draw, so the mover renders the
|
|
// next state from the response without a follow-up state fetch.
|
|
type moveResultDTO struct {
|
|
Move moveRecordDTO `json:"move"`
|
|
Game gameDTO `json:"game"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
}
|
|
|
|
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
|
// tile value), embedded in the state view for display only when the client requests it.
|
|
type alphabetEntryDTO struct {
|
|
Index int `json:"index"`
|
|
Letter string `json:"letter"`
|
|
Value int `json:"value"`
|
|
}
|
|
|
|
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
|
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
|
type stateDTO struct {
|
|
Game gameDTO `json:"game"`
|
|
Seat int `json:"seat"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
|
}
|
|
|
|
// 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,
|
|
ServiceLanguage: acc.ServiceLanguage,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
|
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
|
HintBalance: acc.HintBalance,
|
|
BlockChat: acc.BlockChat,
|
|
BlockFriendRequests: acc.BlockFriendRequests,
|
|
IsGuest: acc.IsGuest,
|
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
|
}
|
|
}
|
|
|
|
// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds.
|
|
const awayTimeLayout = "15:04"
|
|
|
|
// 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 {
|
|
// An open game's still-empty opponent seat has no account: emit an empty id (the
|
|
// display name is left empty by fillSeatNames) so the client shows "searching for
|
|
// opponent" rather than the nil-UUID.
|
|
accountID := ""
|
|
if s.AccountID != uuid.Nil {
|
|
accountID = s.AccountID.String()
|
|
}
|
|
seats = append(seats, seatDTO{
|
|
Seat: s.Seat,
|
|
AccountID: accountID,
|
|
Score: s.Score,
|
|
HintsUsed: s.HintsUsed,
|
|
IsWinner: s.IsWinner,
|
|
})
|
|
}
|
|
last := g.TurnStartedAt
|
|
if g.FinishedAt != nil {
|
|
last = *g.FinishedAt
|
|
}
|
|
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()),
|
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
|
MoveCount: g.MoveCount,
|
|
EndReason: g.EndReason,
|
|
LastActivityUnix: last.Unix(),
|
|
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, encoding the actor's rack as
|
|
// wire alphabet indices.
|
|
func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
|
|
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
|
|
if err != nil {
|
|
return moveResultDTO{}, err
|
|
}
|
|
return moveResultDTO{
|
|
Move: moveRecordDTOFrom(r.Move),
|
|
Game: gameDTOFromGame(r.Game),
|
|
Rack: rack,
|
|
BagLen: r.BagLen,
|
|
}, nil
|
|
}
|
|
|
|
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
|
// alphabet indices. When includeAlphabet is set it also embeds the variant's
|
|
// display table, which the client caches per variant and renders the rack with.
|
|
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
|
if err != nil {
|
|
return stateDTO{}, err
|
|
}
|
|
dto := stateDTO{
|
|
Game: gameDTOFromGame(v.Game),
|
|
Seat: v.Seat,
|
|
Rack: rack,
|
|
BagLen: v.BagLen,
|
|
HintsRemaining: v.HintsRemaining,
|
|
}
|
|
if includeAlphabet {
|
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
|
if err != nil {
|
|
return stateDTO{}, err
|
|
}
|
|
dto.Alphabet = make([]alphabetEntryDTO, len(tab))
|
|
for i, e := range tab {
|
|
dto.Alphabet[i] = alphabetEntryDTO{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
|
|
}
|
|
}
|
|
return dto, nil
|
|
}
|
|
|
|
// matchDTOFrom projects an enqueue result into its DTO. Enqueue always lands the
|
|
// caller in a game (freshly opened or joined), so the game is always present; Matched
|
|
// reports whether it already had an opponent.
|
|
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
|
|
g := gameDTOFromGame(r.Game)
|
|
return matchDTO{Matched: r.Matched, 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(),
|
|
}
|
|
}
|