v0.13.1 concession management and record, clients update, version report
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f1ac4cbbe7
commit
6e8d80c788
30 files changed
+704
-81
No files matched your search
@@ -16,7 +16,8 @@ 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.termination, g.start_time_utc, g.end_time_utc`
|
||||
g.result, g.termination, g.start_time_utc, g.end_time_utc,
|
||||
g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc`
|
||||
|
||||
// claimantName snapshots a claimant's username inside the writing
|
||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||
@@ -114,6 +115,9 @@ type GameEnd struct {
|
||||
EndTimeUTC time.Time
|
||||
ClaimColor string
|
||||
ClaimedBy string
|
||||
// Ply is the number of moves played; it is recorded with the first
|
||||
// resignation or agreed draw (see GameRecord.ConcessionResult).
|
||||
Ply int
|
||||
}
|
||||
|
||||
// RecordGameEnd persists the result, how it was reached, and any claim in one
|
||||
@@ -138,9 +142,25 @@ func (s *Store) RecordGameEnd(end GameEnd) error {
|
||||
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
|
||||
return errors.New("claim color and claimant must be provided together")
|
||||
}
|
||||
if end.Ply < 0 {
|
||||
return errors.New("ply must not be negative")
|
||||
}
|
||||
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)
|
||||
if err := updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
|
||||
end.Result, end.Termination, &ended); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isConcession(end.Termination) {
|
||||
return nil
|
||||
}
|
||||
// The first concession stays; a later one after an undo is only the
|
||||
// current result.
|
||||
_, err := tx.ExecContext(ctx, `UPDATE games SET
|
||||
concession_result = $2, concession_termination = $3,
|
||||
concession_ply = $4, concession_time_utc = $5
|
||||
WHERE game_id = $1 AND concession_result IS NULL`,
|
||||
end.GameID, end.Result, end.Termination, end.Ply, ended)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,6 +229,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
|
||||
// RewindGame atomically removes undone moves and clears a previously terminal
|
||||
// result so replay readers never observe an ongoing line with a stale outcome.
|
||||
// A recorded concession is kept.
|
||||
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
if gameID == "" || afterMoveNumber < 0 {
|
||||
return errors.New("game ID and a non-negative move number are required")
|
||||
@@ -519,7 +540,9 @@ type rowScanner interface {
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
|
||||
var endTime sql.NullTime
|
||||
var concessionResult, concessionTermination sql.NullString
|
||||
var concessionPly sql.NullInt64
|
||||
var endTime, concessionTime sql.NullTime
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
|
||||
@@ -527,6 +550,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &termination, &record.StartTimeUTC, &endTime,
|
||||
&concessionResult, &concessionTermination, &concessionPly, &concessionTime,
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
@@ -539,6 +563,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
record.Termination = termination.String
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
record.ConcessionResult = concessionResult.String
|
||||
record.ConcessionTermination = concessionTermination.String
|
||||
record.ConcessionPly = int(concessionPly.Int64)
|
||||
record.ConcessionTimeUTC = utcPointer(concessionTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -589,6 +617,11 @@ func isValidResult(result string) bool {
|
||||
}
|
||||
|
||||
// validTermination mirrors the games_termination_check constraint.
|
||||
// isConcession reports a result the players chose (core.Termination.IsConcession).
|
||||
func isConcession(termination string) bool {
|
||||
return termination == "resignation" || termination == "agreement"
|
||||
}
|
||||
|
||||
func validTermination(result, termination string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins":
|
||||
|
||||
@@ -41,6 +41,12 @@ type GameRecord struct {
|
||||
Termination string // how Result was reached; empty while unfinished
|
||||
StartTimeUTC time.Time
|
||||
EndTimeUTC *time.Time
|
||||
// The first resignation or agreed draw, kept when an undo continues play;
|
||||
// ConcessionResult is empty when there was none.
|
||||
ConcessionResult string
|
||||
ConcessionTermination string
|
||||
ConcessionPly int
|
||||
ConcessionTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// GameSummaryRecord is a game row plus its replay extent. MoveCount and
|
||||
@@ -75,7 +81,7 @@ type MovePersistence struct {
|
||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||
// a failed upgrade leaves the previous version intact.
|
||||
const schemaVersion = 2
|
||||
const schemaVersion = 3
|
||||
|
||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||
// released migration; append a new one.
|
||||
@@ -182,6 +188,31 @@ ALTER TABLE games
|
||||
OR (result = 'draw' AND termination IN
|
||||
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
|
||||
`,
|
||||
|
||||
// v3: the first resignation or agreed draw. An undo against the computer
|
||||
// may reopen such a game, which clears result and termination; these
|
||||
// columns keep the concession on record. Ply is the number of moves
|
||||
// played when it was made. Existing concessions are backfilled from the
|
||||
// current result and move count.
|
||||
`
|
||||
ALTER TABLE games
|
||||
ADD COLUMN concession_result text,
|
||||
ADD COLUMN concession_termination text,
|
||||
ADD COLUMN concession_ply integer,
|
||||
ADD COLUMN concession_time_utc timestamptz;
|
||||
UPDATE games g SET
|
||||
concession_result = g.result,
|
||||
concession_termination = g.termination,
|
||||
concession_ply = (SELECT count(*) FROM moves m WHERE m.game_id = g.game_id),
|
||||
concession_time_utc = g.end_time_utc
|
||||
WHERE g.termination IN ('resignation', 'agreement');
|
||||
ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK (
|
||||
(concession_result IS NULL AND concession_termination IS NULL
|
||||
AND concession_ply IS NULL AND concession_time_utc IS NULL)
|
||||
OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND (
|
||||
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
|
||||
OR (concession_result = 'draw' AND concession_termination = 'agreement'))));
|
||||
`,
|
||||
}
|
||||
|
||||
// ownedTables lists every table created by migrations, children first.
|
||||
|
||||
@@ -945,6 +945,81 @@ func TestMigrationV2BackfillsTermination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV3BackfillsConcessions(t *testing.T) {
|
||||
dsn := pgtest.DSN(t)
|
||||
store, err := NewStore(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
// A version 2 database: a resignation after two moves, an agreed draw at
|
||||
// the start, and a checkmate.
|
||||
for _, migration := range migrations[:2] {
|
||||
if _, err := store.db.Exec(migration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (2)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, row := range [][2]string{
|
||||
{"black_wins", "resignation"}, {"draw", "agreement"}, {"white_wins", "checkmate"},
|
||||
} {
|
||||
id := uuid.NewString()
|
||||
ids[row[1]] = id
|
||||
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
||||
black_player_id, black_type, result, termination, end_time_utc)
|
||||
VALUES ($1, 'initial', $2, 1, $3, 2, $4, $5, now())`,
|
||||
id, uuid.NewString(), uuid.NewString(), row[0], row[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i, uci := range []string{"e2e4", "e7e5"} {
|
||||
if _, err := store.db.Exec(`INSERT INTO moves (game_id, move_number, move_uci, fen_after_move,
|
||||
player_color) VALUES ($1, $2, $3, 'fen', $4)`,
|
||||
ids["resignation"], i+1, uci, []string{"w", "b"}[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for termination, want := range map[string]GameRecord{
|
||||
"resignation": {ConcessionResult: "black_wins", ConcessionTermination: "resignation", ConcessionPly: 2},
|
||||
"agreement": {ConcessionResult: "draw", ConcessionTermination: "agreement", ConcessionPly: 0},
|
||||
"checkmate": {},
|
||||
} {
|
||||
records, err := store.QueryGames(ids[termination], "")
|
||||
if err != nil || len(records) != 1 {
|
||||
t.Fatalf("%s: %v, %d records", termination, err, len(records))
|
||||
}
|
||||
got := records[0]
|
||||
if got.ConcessionResult != want.ConcessionResult ||
|
||||
got.ConcessionTermination != want.ConcessionTermination ||
|
||||
got.ConcessionPly != want.ConcessionPly ||
|
||||
(want.ConcessionResult != "") != (got.ConcessionTimeUTC != nil) {
|
||||
t.Errorf("%s: concession %q %q %d %v", termination, got.ConcessionResult,
|
||||
got.ConcessionTermination, got.ConcessionPly, got.ConcessionTimeUTC)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a resignation or an agreed draw is a concession, and its columns
|
||||
// are set together.
|
||||
for name, query := range map[string]string{
|
||||
"checkmate concession": `UPDATE games SET concession_result = 'white_wins',
|
||||
concession_termination = 'checkmate', concession_ply = 1, concession_time_utc = now()
|
||||
WHERE game_id = $1`,
|
||||
"partial concession": `UPDATE games SET concession_result = 'draw' WHERE game_id = $1`,
|
||||
} {
|
||||
if _, err := store.db.Exec(query, ids["checkmate"]); err == nil {
|
||||
t.Errorf("games_concession_check accepted a %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
|
||||
store := openStore(t, pgtest.DSN(t))
|
||||
missing := make(chan string, 4)
|
||||
|
||||
Reference in new issue
Block a user