v0.11.0 harden persistence and prepare game replays

This commit is contained in:
2026-09-07 14:39:00 -04:00
parent 21dea47694
commit 5d2be4abb2
42 changed files with 3591 additions and 693 deletions
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"errors"
"fmt"
"chess/internal/server/core"
"chess/internal/server/storage"
)
// GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(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)
}
history := &core.GameHistoryResponse{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
for _, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI,
FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC,
})
}
return history, nil
}
// 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) {
if s.store == nil {
return nil, ErrStorageDisabled
}
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
if err != nil {
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get user games: %w", err)
}
hasNext := len(records) > limit
if hasNext {
records = records[:limit]
}
response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)),
Limit: limit,
Offset: offset,
}
if hasNext {
next := offset + limit
response.NextOffset = &next
}
for _, record := range records {
response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
Players: playersResponse(record.GameRecord),
})
}
return response, nil
}
func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) ||
errors.Is(err, storage.ErrWriteQueueFull) ||
errors.Is(err, context.DeadlineExceeded)
}
func playersResponse(record storage.GameRecord) core.PlayersResponse {
return core.PlayersResponse{
White: &core.Player{
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
},
Black: &core.Player{
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
},
}
}