d733ce3119
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
348 lines
9.1 KiB
Go
348 lines
9.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// The handlers below extend the Stage 6 vertical slice with the remaining game and
|
|
// chat operations the UI needs (PLAN.md Stage 7). They follow the same pattern as
|
|
// handlers_user.go: X-User-ID identity, the domain service call, a JSON DTO mapped
|
|
// from the result.
|
|
|
|
// hintResultDTO is the top-ranked move plus the remaining hint budget.
|
|
type hintResultDTO struct {
|
|
Move moveRecordDTO `json:"move"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
}
|
|
|
|
// evalResultDTO is an unlimited move preview: legality, score and the words formed.
|
|
type evalResultDTO struct {
|
|
Legal bool `json:"legal"`
|
|
Score int `json:"score"`
|
|
Words []string `json:"words"`
|
|
}
|
|
|
|
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
|
|
type wordCheckDTO struct {
|
|
Word string `json:"word"`
|
|
Legal bool `json:"legal"`
|
|
}
|
|
|
|
// historyDTO is a game's decoded move journal, the source for client board replay.
|
|
type historyDTO struct {
|
|
GameID string `json:"game_id"`
|
|
Moves []moveRecordDTO `json:"moves"`
|
|
}
|
|
|
|
// gameListDTO is the caller's games (active and finished) for the lobby.
|
|
type gameListDTO struct {
|
|
Games []gameDTO `json:"games"`
|
|
}
|
|
|
|
// chatListDTO is a game's chat history.
|
|
type chatListDTO struct {
|
|
Messages []chatDTO `json:"messages"`
|
|
}
|
|
|
|
// exchangeRequest swaps the given rack tiles back into the bag.
|
|
type exchangeRequest struct {
|
|
Tiles []string `json:"tiles"`
|
|
}
|
|
|
|
// complaintRequest disputes a word-check result.
|
|
type complaintRequest struct {
|
|
Word string `json:"word"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// fillSeatNames resolves each seat's display name from the account store, memoising
|
|
// across seats and games within one request.
|
|
func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) {
|
|
for i := range g.Seats {
|
|
id := g.Seats[i].AccountID
|
|
name, ok := memo[id]
|
|
if !ok {
|
|
if uid, err := uuid.Parse(id); err == nil {
|
|
if acc, err := s.accounts.GetByID(ctx, uid); err == nil {
|
|
name = acc.DisplayName
|
|
}
|
|
}
|
|
memo[id] = name
|
|
}
|
|
g.Seats[i].DisplayName = name
|
|
}
|
|
}
|
|
|
|
// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
|
|
func moveRecordDTOFromHistory(m game.HistoryMove) 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.Seat,
|
|
Action: m.Action,
|
|
Dir: m.Dir,
|
|
MainRow: m.MainRow,
|
|
MainCol: m.MainCol,
|
|
Tiles: tiles,
|
|
Words: m.Words,
|
|
Count: len(m.Words),
|
|
Score: m.Score,
|
|
Total: m.RunningTotal,
|
|
}
|
|
}
|
|
|
|
// handlePass forfeits the player's turn.
|
|
func (s *Server) handlePass(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
res, err := s.games.Pass(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleExchange swaps the chosen rack tiles back into the bag.
|
|
func (s *Server) handleExchange(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req exchangeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.Tiles)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleResign resigns the player from the game.
|
|
func (s *Server) handleResign(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
res, err := s.games.Resign(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleHint reveals the top-ranked move and spends a hint.
|
|
func (s *Server) handleHint(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
h, err := s.games.Hint(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, hintResultDTO{
|
|
Move: moveRecordDTOFrom(h.Move),
|
|
HintsRemaining: h.HintsRemaining,
|
|
})
|
|
}
|
|
|
|
// handleEvaluate previews a tentative play's legality and score.
|
|
func (s *Server) handleEvaluate(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req submitPlayRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
dir, ok := parseDirection(req.Dir)
|
|
if !ok {
|
|
abortBadRequest(c, "dir must be H or V")
|
|
return
|
|
}
|
|
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
|
for _, t := range req.Tiles {
|
|
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words})
|
|
}
|
|
|
|
// handleCheckWord looks a word up in the game's pinned dictionary.
|
|
func (s *Server) handleCheckWord(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
word := c.Query("word")
|
|
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
|
|
}
|
|
|
|
// handleComplaint files a word-check complaint into the admin review queue.
|
|
func (s *Server) handleComplaint(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req complaintRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if _, err := s.games.FileComplaint(c.Request.Context(), gameID, uid, req.Word, req.Note); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// handleHistory returns a game's decoded move journal for board replay / history.
|
|
func (s *Server) handleHistory(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
view, err := s.games.History(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
moves := make([]moveRecordDTO, 0, len(view.Moves))
|
|
for _, m := range view.Moves {
|
|
moves = append(moves, moveRecordDTOFromHistory(m))
|
|
}
|
|
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
|
|
}
|
|
|
|
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
|
|
type gcgDTO struct {
|
|
GameID string `json:"game_id"`
|
|
Filename string `json:"filename"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// handleExportGCG returns a finished game's GCG transcript for download/share. The
|
|
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
|
|
func (s *Server) handleExportGCG(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gcgDTO{
|
|
GameID: gameID.String(),
|
|
Filename: "game-" + gameID.String() + ".gcg",
|
|
Content: gcg,
|
|
})
|
|
}
|
|
|
|
// handleListGames returns the caller's active and finished games for the lobby.
|
|
func (s *Server) handleListGames(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
games, err := s.games.ListForAccount(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
memo := map[string]string{}
|
|
out := make([]gameDTO, 0, len(games))
|
|
for _, g := range games {
|
|
dto := gameDTOFromGame(g)
|
|
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
|
out = append(out, dto)
|
|
}
|
|
c.JSON(http.StatusOK, gameListDTO{Games: out})
|
|
}
|
|
|
|
// handleChatList returns a game's chat history for the viewer.
|
|
func (s *Server) handleChatList(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
msgs, err := s.social.Messages(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
out := make([]chatDTO, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
out = append(out, chatDTOFrom(m))
|
|
}
|
|
c.JSON(http.StatusOK, chatListDTO{Messages: out})
|
|
}
|
|
|
|
// handleNudge posts a nudge to the player whose turn is awaited.
|
|
func (s *Server) handleNudge(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
msg, err := s.social.Nudge(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, chatDTOFrom(msg))
|
|
}
|
|
|
|
// userGame reads the authenticated account and the :id game param, aborting with the
|
|
// right status when either is missing. ok is false when the request was aborted.
|
|
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return uuid.UUID{}, uuid.UUID{}, false
|
|
}
|
|
gameID, ok := gameIDParam(c)
|
|
if !ok {
|
|
abortBadRequest(c, "invalid game id")
|
|
return uuid.UUID{}, uuid.UUID{}, false
|
|
}
|
|
return uid, gameID, true
|
|
}
|
|
|
|
// writeMoveResult emits a committed move with seat display names filled in.
|
|
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
|
dto := moveResultDTOFrom(res)
|
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
|
c.JSON(http.StatusOK, dto)
|
|
}
|