v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -2,43 +2,59 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/replay"
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidListQuery reports a malformed cursor or filter.
|
||||
ErrInvalidListQuery = errors.New("invalid list query")
|
||||
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
|
||||
ErrPlyOutOfRange = replay.ErrPlyOutOfRange
|
||||
// ErrNotation reports a stored move the rules core cannot notate.
|
||||
ErrNotation = replay.ErrNotation
|
||||
)
|
||||
|
||||
// GetGameHistory returns a durable replay even after the live game has been
|
||||
// evicted from memory or the server has restarted.
|
||||
// evicted from memory or the server has restarted. SAN is derived on read.
|
||||
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
record, moves, err := s.store.GetGameHistory(gameID)
|
||||
record, moves, err := s.loadHistory(gameID)
|
||||
if err != nil {
|
||||
if storage.IsGameNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, fmt.Errorf("get game history: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
history := &core.GameHistoryResponse{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
PGNResult: chess.ResultToken(record.Result),
|
||||
Termination: record.Termination,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
Players: playersResponse(*record),
|
||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||
}
|
||||
for _, move := range moves {
|
||||
san, err := replay.Notate(record.InitialFEN, moves)
|
||||
if err != nil {
|
||||
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
|
||||
}
|
||||
for i, move := range moves {
|
||||
history.Moves = append(history.Moves, core.HistoryMove{
|
||||
MoveNumber: move.MoveNumber,
|
||||
MoveUCI: move.MoveUCI,
|
||||
SAN: san[i],
|
||||
FENAfterMove: move.FENAfterMove,
|
||||
PlayerColor: move.PlayerColor,
|
||||
MoveTimeUTC: move.MoveTimeUTC,
|
||||
@@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetGamePGN exports the stored game in PGN; see replay.BuildPGN for ply.
|
||||
func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
|
||||
record, moves, err := s.loadHistory(gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pgn, err := replay.BuildPGN(record, moves, ply)
|
||||
if errors.Is(err, replay.ErrNotation) {
|
||||
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
|
||||
}
|
||||
return pgn, err
|
||||
}
|
||||
|
||||
func (s *Service) loadHistory(gameID string) (*storage.GameRecord, []storage.MoveRecord, error) {
|
||||
if s.store == nil {
|
||||
return nil, nil, ErrStorageDisabled
|
||||
}
|
||||
record, moves, err := s.store.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
if storage.IsGameNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("get game history: %w", err)
|
||||
}
|
||||
return record, moves, nil
|
||||
}
|
||||
|
||||
// UserGamesOptions selects a page of the caller's games. Cursor, when set,
|
||||
// continues a previous page and excludes Offset.
|
||||
type UserGamesOptions struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Cursor string
|
||||
Color string // "", "white", or "black"
|
||||
Status string // "", "ongoing", or "finished"
|
||||
}
|
||||
|
||||
// GetUserGames returns a bounded page of games associated either at creation
|
||||
// or by a later slot claim.
|
||||
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
|
||||
func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.GameListResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
|
||||
query := storage.UserGamesQuery{Limit: opts.Limit + 1, Offset: opts.Offset, Status: opts.Status}
|
||||
switch opts.Color {
|
||||
case "":
|
||||
case "white":
|
||||
query.Color = "w"
|
||||
case "black":
|
||||
query.Color = "b"
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: color must be white or black", ErrInvalidListQuery)
|
||||
}
|
||||
switch opts.Status {
|
||||
case "", "ongoing", "finished":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: status must be ongoing or finished", ErrInvalidListQuery)
|
||||
}
|
||||
if opts.Cursor != "" {
|
||||
if opts.Offset != 0 {
|
||||
return nil, fmt.Errorf("%w: cursor and offset are exclusive", ErrInvalidListQuery)
|
||||
}
|
||||
after, err := decodeCursor(opts.Cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.After = after
|
||||
}
|
||||
|
||||
records, err := s.store.QueryGamesForUser(userID, query)
|
||||
if err != nil {
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
@@ -61,24 +143,29 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
||||
return nil, fmt.Errorf("get user games: %w", err)
|
||||
}
|
||||
|
||||
hasNext := len(records) > limit
|
||||
hasNext := len(records) > opts.Limit
|
||||
if hasNext {
|
||||
records = records[:limit]
|
||||
records = records[:opts.Limit]
|
||||
}
|
||||
response := &core.GameListResponse{
|
||||
Games: make([]core.GameSummary, 0, len(records)),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Limit: opts.Limit,
|
||||
Offset: opts.Offset,
|
||||
}
|
||||
if hasNext {
|
||||
next := offset + limit
|
||||
response.NextOffset = &next
|
||||
last := records[len(records)-1]
|
||||
response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID)
|
||||
if opts.Cursor == "" {
|
||||
next := opts.Offset + opts.Limit
|
||||
response.NextOffset = &next
|
||||
}
|
||||
}
|
||||
for _, record := range records {
|
||||
response.Games = append(response.Games, core.GameSummary{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
PGNResult: chess.ResultToken(record.Result),
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
@@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Cursors are opaque to clients: base64url of "<start µs>.<game ID>". The
|
||||
// microsecond value round-trips PostgreSQL timestamptz exactly.
|
||||
func encodeCursor(start time.Time, gameID string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(start.UnixMicro(), 10) + "." + gameID))
|
||||
}
|
||||
|
||||
func decodeCursor(cursor string) (*storage.GameCursor, error) {
|
||||
invalid := fmt.Errorf("%w: malformed cursor", ErrInvalidListQuery)
|
||||
if len(cursor) > 128 {
|
||||
return nil, invalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return nil, invalid
|
||||
}
|
||||
micros, id, ok := strings.Cut(string(raw), ".")
|
||||
if !ok {
|
||||
return nil, invalid
|
||||
}
|
||||
us, err := strconv.ParseInt(micros, 10, 64)
|
||||
if err != nil || uuid.Validate(id) != nil {
|
||||
return nil, invalid
|
||||
}
|
||||
return &storage.GameCursor{StartTimeUTC: time.UnixMicro(us).UTC(), GameID: id}, nil
|
||||
}
|
||||
|
||||
func isStorageUnavailable(err error) bool {
|
||||
return errors.Is(err, storage.ErrStorageDegraded) ||
|
||||
errors.Is(err, storage.ErrStoreClosed) ||
|
||||
|
||||
Reference in new issue
Block a user