d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
395 lines
16 KiB
Go
395 lines
16 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"`
|
|
}
|
|
|
|
// 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. PlatformKind and
|
|
// PlatformSubtype carry the session's trusted execution platform so the gateway can
|
|
// inject X-Platform; both are empty for an untrusted (pre-capture) session.
|
|
type resolveResponse struct {
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
PlatformKind string `json:"platform_kind"`
|
|
PlatformSubtype string `json:"platform_subtype"`
|
|
}
|
|
|
|
// 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"`
|
|
// VariantPreferences is the set of game variants the player allows themselves to
|
|
// be matched into (engine.Variant stable labels), Erudit-first. It gates the New
|
|
// Game picker and the matchmaker; never empty.
|
|
VariantPreferences []string `json:"variant_preferences"`
|
|
// Banner is the advertising-banner block: present only for a viewer eligible to
|
|
// see the banner (a free account with an empty hint wallet and without the
|
|
// no_banner role), absent otherwise. See banner.go.
|
|
Banner *bannerDTO `json:"banner,omitempty"`
|
|
// Ads carries the post-move interstitial config for the client's client-mirrored gate: the
|
|
// cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role).
|
|
// The client shows a VK interstitial after a confirmed move / hint when not suppressed and the
|
|
// cooldown has elapsed. Always present (the client also gates VK-only + online itself).
|
|
Ads *adsDTO `json:"ads,omitempty"`
|
|
// Email is the account's confirmed email address ("" when none); TelegramLinked and
|
|
// VkLinked report whether a platform identity is attached. They drive the profile's
|
|
// link / unlink / change-email controls, and are filled outside the pure projection
|
|
// (they read the account's identities). See Server.profileResponse.
|
|
Email string `json:"email"`
|
|
TelegramLinked bool `json:"telegram_linked"`
|
|
VkLinked bool `json:"vk_linked"`
|
|
// DictVersions lists the current dictionary version per game variant (engine.Variant
|
|
// stable labels). An installed PWA preparing for offline play reads it to preload the
|
|
// right dawg per enabled variant off this cold-start response, without an extra request.
|
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
|
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
|
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
|
|
// outside the pure projection (it reads the limits config). See Server.profileResponse.
|
|
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
|
|
}
|
|
|
|
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
|
|
// per-kind New-Game lock. profileResponse.GameLimits carries it.
|
|
type gameLimitsDTO struct {
|
|
VsAI int `json:"vs_ai"`
|
|
Random int `json:"random"`
|
|
Friends int `json:"friends"`
|
|
}
|
|
|
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
|
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
|
|
// client preloads the matching dawg per variant.
|
|
type dictVersion struct {
|
|
Variant string `json:"variant"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// 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 the seat's display-name snapshot
|
|
// (the per-game name, frozen when the seat was taken); fillSeatNames fills it from the
|
|
// account store only when a seat has no snapshot yet (a pre-snapshot legacy row).
|
|
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"`
|
|
// Deleted marks a seat whose account has been deleted: the client hides every social
|
|
// control aimed at it (add-friend, block, chat, nudge). gameDTOFromGame leaves it false
|
|
// (it does not reach the account store); fillSeatDeleted fills it.
|
|
Deleted bool `json:"deleted"`
|
|
}
|
|
|
|
// 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"`
|
|
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
|
|
// per game). The client needs it to score its local move preview the way the server does.
|
|
NoRepeatWords bool `json:"no_repeat_words"`
|
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
|
// are suppressed in the client.
|
|
VsAI bool `json:"vs_ai"`
|
|
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
|
|
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
|
|
// New-Game start.
|
|
Kind int `json:"kind"`
|
|
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"`
|
|
// UnreadChat is a per-viewer flag: the requesting player has at least one unread chat
|
|
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
|
|
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
|
|
UnreadChat bool `json:"unread_chat"`
|
|
// UnreadMessages is a per-viewer flag: at least one of the unread entries is a real chat
|
|
// message (not just a nudge). It colours the badge — set with UnreadChat it is the regular
|
|
// badge, UnreadChat alone is the softer nudge-only badge. gameDTOFromGame leaves it false.
|
|
UnreadMessages bool `json:"unread_messages"`
|
|
}
|
|
|
|
// 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"`
|
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
|
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),
|
|
// The hint balance comes from the payments benefit; profileResponse overrides this
|
|
// with the context-aware count. This zero is the fallback when that read fails.
|
|
HintBalance: 0,
|
|
BlockChat: acc.BlockChat,
|
|
BlockFriendRequests: acc.BlockFriendRequests,
|
|
IsGuest: acc.IsGuest,
|
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
|
VariantPreferences: acc.VariantPreferences,
|
|
}
|
|
}
|
|
|
|
// 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 (and an
|
|
// empty display name) so the client shows "searching for opponent" rather than the
|
|
// nil-UUID. DisplayName carries the seat's per-game snapshot; fillSeatNames fills
|
|
// only the seats still without one from the account store.
|
|
accountID := ""
|
|
if s.AccountID != uuid.Nil {
|
|
accountID = s.AccountID.String()
|
|
}
|
|
seats = append(seats, seatDTO{
|
|
Seat: s.Seat,
|
|
AccountID: accountID,
|
|
DisplayName: s.DisplayName,
|
|
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,
|
|
NoRepeatWords: g.NoRepeatWords,
|
|
VsAI: g.VsAI,
|
|
Kind: int(g.Kind),
|
|
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,
|
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
|
}
|
|
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(),
|
|
}
|
|
}
|