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
+237
-73
@@ -8,13 +8,15 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const gameSelectColumns = `
|
||||
g.game_id, g.initial_fen,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
||||
g.result, g.start_time_utc, g.end_time_utc`
|
||||
g.result, g.termination, g.start_time_utc, g.end_time_utc`
|
||||
|
||||
// claimantName snapshots a claimant's username inside the writing
|
||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||
@@ -33,7 +35,7 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
||||
return errors.New("game ID, initial FEN, and player IDs are required")
|
||||
}
|
||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
||||
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,9 +44,9 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
|
||||
start_time_utc, result, end_time_utc
|
||||
start_time_utc, result, termination, end_time_utc
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
|
||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15, $16)`
|
||||
|
||||
_, err := tx.ExecContext(ctx, query,
|
||||
record.GameID, record.InitialFEN,
|
||||
@@ -52,7 +54,8 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
nullableString(record.WhiteClaimedBy),
|
||||
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
||||
nullableString(record.BlackClaimedBy),
|
||||
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
|
||||
record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination),
|
||||
record.EndTimeUTC,
|
||||
)
|
||||
return err
|
||||
})
|
||||
@@ -74,7 +77,7 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
|
||||
return errors.New("claim color and claimant must be provided together")
|
||||
}
|
||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
||||
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -90,65 +93,88 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
record.Move.PlayerColor,
|
||||
record.Move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return err
|
||||
return gameMissingOr(err, record.Move.GameID)
|
||||
}
|
||||
|
||||
if record.ClaimedBy == "" && record.Result == "" {
|
||||
return nil
|
||||
}
|
||||
// One UPDATE applies the optional claim and optional result. A claim may
|
||||
// only fill an empty slot or repeat the same claimant.
|
||||
claimColumn, nameColumn := "white_claimed_by", "white_name"
|
||||
if record.ClaimColor == "b" {
|
||||
claimColumn, nameColumn = "black_claimed_by", "black_name"
|
||||
}
|
||||
var set []string
|
||||
args := []any{record.Move.GameID}
|
||||
where := "game_id = $1"
|
||||
if record.ClaimedBy != "" {
|
||||
args = append(args, record.ClaimedBy)
|
||||
param := fmt.Sprintf("$%d", len(args))
|
||||
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
|
||||
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
|
||||
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
|
||||
}
|
||||
if record.Result != "" {
|
||||
args = append(args, record.Result, record.EndTimeUTC)
|
||||
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
|
||||
}
|
||||
result, err := tx.ExecContext(ctx,
|
||||
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy,
|
||||
record.Result, record.Termination, record.EndTimeUTC)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordGameResult persists a terminal transition not accompanied by a move,
|
||||
// such as a no-legal-moves engine response.
|
||||
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
||||
if gameID == "" {
|
||||
// GameEnd is a terminal transition not accompanied by a move: a resignation,
|
||||
// a draw by agreement, or an engine that found no legal move. ClaimColor and
|
||||
// ClaimedBy optionally record the acting user's claim of an empty slot.
|
||||
type GameEnd struct {
|
||||
GameID string
|
||||
Result string
|
||||
Termination string
|
||||
EndTimeUTC time.Time
|
||||
ClaimColor string
|
||||
ClaimedBy string
|
||||
}
|
||||
|
||||
// RecordGameEnd persists the result, how it was reached, and any claim in one
|
||||
// transaction.
|
||||
func (s *Store) RecordGameEnd(end GameEnd) error {
|
||||
if end.GameID == "" {
|
||||
return errors.New("game ID is required")
|
||||
}
|
||||
if !isValidResult(result) {
|
||||
return fmt.Errorf("invalid game result %q", result)
|
||||
if end.EndTimeUTC.IsZero() {
|
||||
return errors.New("game end time is required")
|
||||
}
|
||||
if at.IsZero() {
|
||||
return errors.New("game result time is required")
|
||||
ended := end.EndTimeUTC.UTC()
|
||||
if err := validateOutcome(end.Result, end.Termination, &ended); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
|
||||
gameID, result, at.UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
if end.Result == "" {
|
||||
return errors.New("game result is required")
|
||||
}
|
||||
if end.ClaimColor != "" && end.ClaimColor != "w" && end.ClaimColor != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", end.ClaimColor)
|
||||
}
|
||||
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
|
||||
return errors.New("claim color and claimant must be provided together")
|
||||
}
|
||||
return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
|
||||
end.Result, end.Termination, &ended)
|
||||
})
|
||||
}
|
||||
|
||||
// updateGameEnd applies an optional claim and an optional result in one
|
||||
// UPDATE. A claim may only fill an empty slot or repeat the same claimant.
|
||||
func updateGameEnd(ctx context.Context, tx *sql.Tx, gameID, claimColor, claimedBy,
|
||||
result, termination string, ended *time.Time) error {
|
||||
claimColumn, nameColumn := "white_claimed_by", "white_name"
|
||||
if claimColor == "b" {
|
||||
claimColumn, nameColumn = "black_claimed_by", "black_name"
|
||||
}
|
||||
var set []string
|
||||
args := []any{gameID}
|
||||
where := "game_id = $1"
|
||||
if claimedBy != "" {
|
||||
args = append(args, claimedBy)
|
||||
param := fmt.Sprintf("$%d", len(args))
|
||||
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
|
||||
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
|
||||
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
|
||||
}
|
||||
if result != "" {
|
||||
args = append(args, result, termination, ended)
|
||||
set = append(set, fmt.Sprintf("result = $%d, termination = $%d, end_time_utc = $%d",
|
||||
len(args)-2, len(args)-1, len(args)))
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(ctx, tx, res, gameID)
|
||||
}
|
||||
|
||||
// PlayerRecord is the persistence subset of a player configuration.
|
||||
type PlayerRecord struct {
|
||||
PlayerID string
|
||||
@@ -177,7 +203,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
return requireOneGame(ctx, tx, res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,13 +221,13 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
return err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||
`UPDATE games SET result = NULL, termination = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
return requireOneGame(ctx, tx, res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,23 +365,70 @@ func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error)
|
||||
return &record, moves, nil
|
||||
}
|
||||
|
||||
// QueryGamesForUser returns a page of games claimed by userID in either color,
|
||||
// newest first. The last move is read through a LATERAL probe of the moves
|
||||
// primary key, so the summary costs one index lookup per game rather than a
|
||||
// move count over the whole line.
|
||||
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
|
||||
// UserGamesQuery selects a page of a user's games, newest first. After, when
|
||||
// set, continues strictly below that (start time, game ID) key; Offset
|
||||
// remains for clients that page by position and must be 0 with After.
|
||||
type UserGamesQuery struct {
|
||||
Limit int
|
||||
Offset int
|
||||
After *GameCursor
|
||||
Color string // "" either, "w" or "b": the color the user claimed
|
||||
Status string // "" any, "ongoing", or "finished"
|
||||
}
|
||||
|
||||
// GameCursor is a keyset position in a newest-first game listing.
|
||||
type GameCursor struct {
|
||||
StartTimeUTC time.Time
|
||||
GameID string
|
||||
}
|
||||
|
||||
// QueryGamesForUser returns a page of games claimed by userID, newest first.
|
||||
// The last move is read through a LATERAL probe of the moves primary key, so
|
||||
// the summary costs one index lookup per game rather than a move count over
|
||||
// the whole line.
|
||||
func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || q.Limit < 1 || q.Limit > MaxUserGamesPage || q.Offset < 0 {
|
||||
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
||||
MaxUserGamesPage)
|
||||
}
|
||||
if q.After != nil && (q.Offset != 0 || !validUUID(q.After.GameID)) {
|
||||
return nil, errors.New("a cursor requires a valid game ID and offset 0")
|
||||
}
|
||||
if !validUUID(userID) {
|
||||
return []GameSummaryRecord{}, nil
|
||||
}
|
||||
|
||||
args := []any{userID, q.Limit, q.Offset}
|
||||
var where string
|
||||
switch q.Color {
|
||||
case "":
|
||||
where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)"
|
||||
case "w":
|
||||
where = "g.white_claimed_by = $1"
|
||||
case "b":
|
||||
where = "g.black_claimed_by = $1"
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid color filter %q", q.Color)
|
||||
}
|
||||
switch q.Status {
|
||||
case "":
|
||||
case "ongoing":
|
||||
where += " AND g.result IS NULL"
|
||||
case "finished":
|
||||
where += " AND g.result IS NOT NULL"
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid status filter %q", q.Status)
|
||||
}
|
||||
if q.After != nil {
|
||||
args = append(args, q.After.StartTimeUTC, q.After.GameID)
|
||||
where += " AND (g.start_time_utc, g.game_id) < ($4, $5)"
|
||||
}
|
||||
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
const query = `SELECT ` + gameSelectColumns + `,
|
||||
query := `SELECT ` + gameSelectColumns + `,
|
||||
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
||||
FROM games g
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -364,12 +437,12 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
||||
ORDER BY m.move_number DESC
|
||||
LIMIT 1
|
||||
) last ON true
|
||||
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
||||
WHERE ` + where + `
|
||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user games: %w", err)
|
||||
}
|
||||
@@ -389,20 +462,63 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
||||
slog.Debug("storage user games queried",
|
||||
"user_id", userID,
|
||||
"count", len(games),
|
||||
"limit", limit,
|
||||
"offset", offset,
|
||||
"limit", q.Limit,
|
||||
"offset", q.Offset,
|
||||
"cursor", q.After != nil,
|
||||
"duration", time.Since(started),
|
||||
)
|
||||
return games, nil
|
||||
}
|
||||
|
||||
// ResolveGameID expands a game ID prefix of at least 8 hexadecimal digits,
|
||||
// as printed by administrative listings, to the unique full ID.
|
||||
func (s *Store) ResolveGameID(prefix string) (string, error) {
|
||||
prefix = strings.ToLower(prefix)
|
||||
if validUUID(prefix) {
|
||||
return prefix, nil
|
||||
}
|
||||
if len(prefix) < 8 || len(prefix) > 36 || strings.Trim(prefix, "0123456789abcdef-") != "" {
|
||||
return "", fmt.Errorf("game ID or a prefix of at least 8 hex digits required, got %q", prefix)
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT game_id FROM games WHERE game_id::text LIKE $1 || '%' ORDER BY game_id LIMIT 2`, prefix)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("resolve game ID: %w", err)
|
||||
}
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
return "", sql.ErrNoRows
|
||||
case 1:
|
||||
return ids[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("game ID prefix %q is ambiguous", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
|
||||
var endTime sql.NullTime
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
@@ -410,7 +526,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
&whiteClaimed, &whiteName,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &record.StartTimeUTC, &endTime,
|
||||
&result, &termination, &record.StartTimeUTC, &endTime,
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
@@ -420,20 +536,47 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
record.BlackClaimedBy = blackClaimed.String
|
||||
record.BlackName = blackName.String
|
||||
record.Result = result.String
|
||||
record.Termination = termination.String
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireOneGame(result sql.Result, gameID string) error {
|
||||
// ErrGameMissing reports a write for a game whose row no longer exists,
|
||||
// typically deleted by hand while the server still held the game. The writer
|
||||
// drops such a write without degrading storage; see SetGameMissingHandler.
|
||||
var ErrGameMissing = errors.New("game row no longer exists")
|
||||
|
||||
// requireOneGame checks that an UPDATE matched its game, telling a missing
|
||||
// row apart from a row whose guard (such as a claim) did not match.
|
||||
func requireOneGame(ctx context.Context, tx *sql.Tx, result sql.Result, gameID string) error {
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return fmt.Errorf("game %s was not updated", gameID)
|
||||
if rows == 1 {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID,
|
||||
).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
|
||||
}
|
||||
return fmt.Errorf("game %s was not updated", gameID)
|
||||
}
|
||||
|
||||
// gameMissingOr maps the moves-to-games foreign-key violation, raised when a
|
||||
// move is inserted for a deleted game, to ErrGameMissing.
|
||||
func gameMissingOr(err error, gameID string) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "moves_game_id_fkey" {
|
||||
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isValidResult(result string) bool {
|
||||
@@ -445,16 +588,37 @@ func isValidResult(result string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func validateResultTime(result string, ended *time.Time) error {
|
||||
// validTermination mirrors the games_termination_check constraint.
|
||||
func validTermination(result, termination string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins":
|
||||
return termination == "checkmate" || termination == "resignation"
|
||||
case "stalemate":
|
||||
return termination == "stalemate"
|
||||
case "draw":
|
||||
switch termination {
|
||||
case "insufficient_material", "threefold_repetition", "fifty_move_rule", "agreement":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateOutcome checks that a result, its termination, and the end time are
|
||||
// all present and consistent, or all absent.
|
||||
func validateOutcome(result, termination string, ended *time.Time) error {
|
||||
if result == "" {
|
||||
if ended != nil {
|
||||
return errors.New("end time requires a game result")
|
||||
if termination != "" || ended != nil {
|
||||
return errors.New("termination and end time require a game result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isValidResult(result) {
|
||||
return fmt.Errorf("invalid game result %q", result)
|
||||
}
|
||||
if !validTermination(result, termination) {
|
||||
return fmt.Errorf("termination %q cannot produce result %q", termination, result)
|
||||
}
|
||||
if ended == nil || ended.IsZero() {
|
||||
return errors.New("terminal game result requires an end time")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user