41a642ef97
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback. - pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS. - backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size. - gateway: MoveResult transcode carries rack+bag_len. - ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false. - docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
313 lines
9.9 KiB
Go
313 lines
9.9 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. 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"`
|
|
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 (Stage 17).
|
|
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 (R4), 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
|
|
// (Stage 13).
|
|
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 (Stage 13; 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,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
seats = append(seats, seatDTO{
|
|
Seat: s.Seat,
|
|
AccountID: s.AccountID.String(),
|
|
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()),
|
|
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 (Stage 13; R4).
|
|
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 (Stage 13). 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/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
|
|
}
|
|
}
|