Files
scrabble-game/backend/internal/server/dto.go
T
Ilia Denisov 9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
feat(social): hide social controls on a deleted opponent's seat
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00

391 lines
15 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"`
// 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,
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(),
}
}