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
@@ -0,0 +1,155 @@
|
||||
// Package replay works on stored games: it derives SAN for their moves,
|
||||
// exports them as PGN, and checks them against the rules. It reads the
|
||||
// records that storage returns and never writes.
|
||||
package replay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
|
||||
ErrPlyOutOfRange = errors.New("ply out of range")
|
||||
// ErrNotation reports a stored move the rules core cannot notate.
|
||||
ErrNotation = errors.New("cannot derive notation")
|
||||
)
|
||||
|
||||
// Notate derives SAN for each stored move from the stored position before
|
||||
// it, so one unparsable ply does not affect the others. Plies it cannot
|
||||
// notate are left empty and the first failure is returned.
|
||||
func Notate(initialFEN string, moves []storage.MoveRecord) ([]string, error) {
|
||||
san := make([]string, len(moves))
|
||||
before := initialFEN
|
||||
var firstErr error
|
||||
for i, move := range moves {
|
||||
pos, err := chess.ParseFEN(before)
|
||||
if err == nil {
|
||||
var m chess.Move
|
||||
if m, err = pos.ParseUCI(move.MoveUCI); err == nil {
|
||||
san[i] = pos.SAN(m)
|
||||
}
|
||||
}
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("%w: ply %d (%s): %v", ErrNotation, move.MoveNumber, move.MoveUCI, err)
|
||||
}
|
||||
before = move.FENAfterMove
|
||||
}
|
||||
return san, firstErr
|
||||
}
|
||||
|
||||
// PGN is an export and its suggested download name.
|
||||
type PGN struct {
|
||||
Text string
|
||||
Filename string
|
||||
}
|
||||
|
||||
// BuildPGN exports a stored game. ply < 0 exports every move; otherwise the
|
||||
// first ply moves, with result "*" and no termination when that stops short
|
||||
// of the end of the line.
|
||||
func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) {
|
||||
if ply > len(moves) {
|
||||
return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves))
|
||||
}
|
||||
san, err := Notate(record.InitialFEN, moves)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
complete := ply < 0 || ply == len(moves)
|
||||
if !complete {
|
||||
san = san[:ply]
|
||||
}
|
||||
result, comment := "*", ""
|
||||
if complete {
|
||||
result = chess.ResultToken(record.Result)
|
||||
comment = Describe(record.Result, record.Termination)
|
||||
}
|
||||
start := record.StartTimeUTC.UTC()
|
||||
tags := []chess.Tag{
|
||||
{Name: "Event", Value: "Casual game"},
|
||||
{Name: "Date", Value: start.Format("2006.01.02")},
|
||||
{Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)},
|
||||
{Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)},
|
||||
{Name: "GameId", Value: record.GameID},
|
||||
{Name: "UTCDate", Value: start.Format("2006.01.02")},
|
||||
{Name: "UTCTime", Value: start.Format("15:04:05")},
|
||||
{Name: "WhiteType", Value: playerType(record.WhiteType)},
|
||||
{Name: "BlackType", Value: playerType(record.BlackType)},
|
||||
{Name: "PlyCount", Value: strconv.Itoa(len(san))},
|
||||
}
|
||||
if complete {
|
||||
// PGN's Termination values describe how play stopped; mate, draws,
|
||||
// and resignation are all "normal". The reason goes in the comment.
|
||||
value := "unterminated"
|
||||
if record.Result != "" {
|
||||
value = "normal"
|
||||
}
|
||||
tags = append(tags, chess.Tag{Name: "Termination", Value: value})
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("chess-%s-%s", start.Format("20060102"), record.GameID[:8])
|
||||
if !complete {
|
||||
name += fmt.Sprintf("-ply%d", ply)
|
||||
}
|
||||
return &PGN{
|
||||
Text: chess.PGN{
|
||||
Tags: tags, StartFEN: record.InitialFEN, SAN: san, Comment: comment, Result: result,
|
||||
}.String(),
|
||||
Filename: name + ".pgn",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Describe returns a sentence for a stored result and termination, such as
|
||||
// "Black wins by resignation." or "Draw by threefold repetition."; empty for
|
||||
// an unfinished game.
|
||||
func Describe(result, termination string) string {
|
||||
winner := ""
|
||||
switch result {
|
||||
case "white_wins":
|
||||
winner = "White"
|
||||
case "black_wins":
|
||||
winner = "Black"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
switch core.Termination(termination) {
|
||||
case core.TermCheckmate:
|
||||
return winner + " wins by checkmate."
|
||||
case core.TermResignation:
|
||||
return winner + " wins by resignation."
|
||||
case core.TermStalemate:
|
||||
return "Draw by stalemate."
|
||||
case core.TermInsufficientMaterial:
|
||||
return "Draw by insufficient material."
|
||||
case core.TermThreefoldRepetition:
|
||||
return "Draw by threefold repetition."
|
||||
case core.TermFiftyMoveRule:
|
||||
return "Draw by the fifty-move rule."
|
||||
case core.TermAgreement:
|
||||
return "Draw by agreement."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func playerName(playerType, level int, name string) string {
|
||||
switch {
|
||||
case core.PlayerType(playerType) == core.PlayerComputer:
|
||||
return "Stockfish level " + strconv.Itoa(level)
|
||||
case name != "":
|
||||
return name
|
||||
}
|
||||
return "Anonymous"
|
||||
}
|
||||
|
||||
func playerType(t int) string {
|
||||
if core.PlayerType(t) == core.PlayerComputer {
|
||||
return "program"
|
||||
}
|
||||
return "human"
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
// stored returns the stored form of a line played from start.
|
||||
func stored(t *testing.T, start string, uci ...string) []storage.MoveRecord {
|
||||
t.Helper()
|
||||
line, err := chess.Replay(start, uci)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moves := make([]storage.MoveRecord, len(uci))
|
||||
for i, u := range uci {
|
||||
moves[i] = storage.MoveRecord{
|
||||
MoveNumber: i + 1, MoveUCI: u, FENAfterMove: line.Position(i + 1).FEN(),
|
||||
PlayerColor: line.Position(i).Turn().String(),
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
func record(start, result, termination string) *storage.GameRecord {
|
||||
return &storage.GameRecord{
|
||||
GameID: "68007fd1-8970-4d84-950d-2f6ec6375c0b", InitialFEN: start,
|
||||
Result: result, Termination: termination,
|
||||
StartTimeUTC: time.Date(2026, 9, 28, 1, 2, 3, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyConsistentGames(t *testing.T) {
|
||||
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
|
||||
for name, tc := range map[string]struct {
|
||||
start, result, termination string
|
||||
uci []string
|
||||
}{
|
||||
"checkmate": {chess.StartFEN, "black_wins", "checkmate", []string{"f2f3", "e7e5", "g2g4", "d8h4"}},
|
||||
"resignation": {chess.StartFEN, "white_wins", "resignation", []string{"e2e4"}},
|
||||
"agreement": {chess.StartFEN, "draw", "agreement", []string{"e2e4", "e7e5"}},
|
||||
"stalemate": {"7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "stalemate", "stalemate", []string{"f6g6"}},
|
||||
"dead": {"4k3/8/8/8/8/8/3r4/4KB2 w - - 0 1", "draw", "insufficient_material", []string{"e1d2"}},
|
||||
"threefold": {chess.StartFEN, "draw", "threefold_repetition", append(cycle, cycle...)},
|
||||
"fifty": {"4k3/8/8/8/8/8/8/R3K3 w - - 99 80", "draw", "fifty_move_rule", []string{"a1a2"}},
|
||||
"ongoing": {chess.StartFEN, "", "", []string{"e2e4"}},
|
||||
// A dead position left unfinished predates automatic draws.
|
||||
"legacy dead": {"4k3/8/8/8/8/8/8/4K3 w - - 0 1", "", "", nil},
|
||||
} {
|
||||
problems := Verify(record(tc.start, tc.result, tc.termination), stored(t, tc.start, tc.uci...))
|
||||
if len(problems) != 0 {
|
||||
t.Errorf("%s: problems %v", name, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFindsProblems(t *testing.T) {
|
||||
mate := stored(t, chess.StartFEN, "f2f3", "e7e5", "g2g4", "d8h4")
|
||||
gap := append(append([]storage.MoveRecord{}, mate[:1]...), mate[2:]...)
|
||||
corrupt := stored(t, chess.StartFEN, "e2e4", "e7e5")
|
||||
corrupt[1].FENAfterMove = chess.StartFEN
|
||||
illegal := stored(t, chess.StartFEN, "e2e4")
|
||||
illegal[0].MoveUCI = "e2e5"
|
||||
wrongSide := stored(t, chess.StartFEN, "e2e4")
|
||||
wrongSide[0].PlayerColor = "b"
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
record *storage.GameRecord
|
||||
moves []storage.MoveRecord
|
||||
want string
|
||||
}{
|
||||
"deleted ply": {record(chess.StartFEN, "black_wins", "checkmate"), gap, "ply 2 missing (next stored ply is 3)"},
|
||||
"wrong position": {record(chess.StartFEN, "", ""), corrupt, "ply 2 e7e5: stored"},
|
||||
"illegal move": {record(chess.StartFEN, "", ""), illegal, "ply 1 e2e5: illegal move"},
|
||||
"wrong side": {record(chess.StartFEN, "", ""), wrongSide, `stored as played by "b"`},
|
||||
"wrong winner": {record(chess.StartFEN, "white_wins", "checkmate"), mate, "does not hold"},
|
||||
"missing result": {record(chess.StartFEN, "", ""), mate, "checkmate but no result"},
|
||||
"mismatched pair": {record(chess.StartFEN, "draw", "checkmate"), mate, "cannot come from"},
|
||||
"orphan term": {record(chess.StartFEN, "", "agreement"), mate[:1], "without a result"},
|
||||
"false repetition": {record(chess.StartFEN, "draw", "threefold_repetition"), mate[:2], "does not hold"},
|
||||
"false dead": {record(chess.StartFEN, "draw", "insufficient_material"), mate[:2], "does not hold"},
|
||||
"false fifty": {record(chess.StartFEN, "draw", "fifty_move_rule"), mate[:2], "does not hold"},
|
||||
"bad initial": {record("8/8/8/8/8/8/8/8 w - - 0 1", "", ""), nil, "initial position"},
|
||||
"premature mate": {record(chess.StartFEN, "black_wins", "checkmate"), mate[:3], "does not hold"},
|
||||
"unknown result": {record(chess.StartFEN, "abandoned", "resignation"), mate[:1], "cannot come from"},
|
||||
"stalemate as mate": {record("7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "white_wins", "checkmate"), stored(t, "7k/5Q2/5K2/8/8/8/8/8 w - - 0 1", "f6g6"), "does not hold"},
|
||||
} {
|
||||
problems := Verify(tc.record, tc.moves)
|
||||
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "; "), tc.want) {
|
||||
t.Errorf("%s: problems = %v, want %q", name, problems, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAcceptsEnginePassantConvention(t *testing.T) {
|
||||
// Stockfish prints an en-passant square whenever a pawn could capture,
|
||||
// even when the capture is illegal; the rules core prints it only when
|
||||
// legal. Both describe the same position.
|
||||
moves := stored(t, chess.StartFEN, "e2e4", "d7d5", "e4e5", "f7f5")
|
||||
moves[0].FENAfterMove = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||
if problems := Verify(record(chess.StartFEN, "", ""), moves); len(problems) != 0 {
|
||||
t.Fatalf("problems %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe(t *testing.T) {
|
||||
for _, tc := range []struct{ result, termination, want string }{
|
||||
{"white_wins", "checkmate", "White wins by checkmate."},
|
||||
{"black_wins", "resignation", "Black wins by resignation."},
|
||||
{"stalemate", "stalemate", "Draw by stalemate."},
|
||||
{"draw", "agreement", "Draw by agreement."},
|
||||
{"draw", "threefold_repetition", "Draw by threefold repetition."},
|
||||
{"draw", "fifty_move_rule", "Draw by the fifty-move rule."},
|
||||
{"draw", "insufficient_material", "Draw by insufficient material."},
|
||||
{"", "", ""},
|
||||
} {
|
||||
if got := Describe(tc.result, tc.termination); got != tc.want {
|
||||
t.Errorf("Describe(%q, %q) = %q, want %q", tc.result, tc.termination, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPGN(t *testing.T) {
|
||||
moves := stored(t, chess.StartFEN, "e2e4", "e7e5")
|
||||
rec := record(chess.StartFEN, "white_wins", "resignation")
|
||||
rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice"
|
||||
|
||||
pgn, err := BuildPGN(rec, moves, -1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"[White \"alice\"]\n", "[Black \"Stockfish level 4\"]\n", "[Result \"1-0\"]\n",
|
||||
"[Termination \"normal\"]\n", "\n\n1. e4 e5 { White wins by resignation. } 1-0\n"} {
|
||||
if !strings.Contains(pgn.Text, want) {
|
||||
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
|
||||
}
|
||||
}
|
||||
if pgn.Filename != "chess-20260928-68007fd1.pgn" {
|
||||
t.Errorf("filename %q", pgn.Filename)
|
||||
}
|
||||
|
||||
partial, err := BuildPGN(rec, moves, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" {
|
||||
t.Errorf("partial %q:\n%s", partial.Filename, partial.Text)
|
||||
}
|
||||
if _, err := BuildPGN(rec, moves, 3); err == nil {
|
||||
t.Error("ply beyond the line accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
// Verify checks a stored game against the rules and returns one line per
|
||||
// problem, empty when the game is consistent:
|
||||
//
|
||||
// - the initial position parses, and moves are numbered 1..n with no gap
|
||||
// (a gap means rows were deleted);
|
||||
// - each move is made by the side to move, is legal from the stored
|
||||
// position before it, and produces the stored position after it;
|
||||
// - the result and its termination belong together, and a termination
|
||||
// decided by the position (mate, stalemate, dead material, repetition,
|
||||
// fifty moves) holds in the final position;
|
||||
// - a mated or stalemated final position has its result recorded.
|
||||
//
|
||||
// Draw rules that became automatic later are not required of older games:
|
||||
// an unfinished game in a drawn position is not a problem.
|
||||
func Verify(record *storage.GameRecord, moves []storage.MoveRecord) []string {
|
||||
var problems []string
|
||||
pos, err := chess.ParseFEN(record.InitialFEN)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("initial position: %v", err)}
|
||||
}
|
||||
positions := []*chess.Position{pos}
|
||||
for i, move := range moves {
|
||||
if move.MoveNumber != i+1 {
|
||||
return append(problems, fmt.Sprintf("ply %d missing (next stored ply is %d)", i+1, move.MoveNumber))
|
||||
}
|
||||
if move.PlayerColor != pos.Turn().String() {
|
||||
problems = append(problems, fmt.Sprintf("ply %d %s: stored as played by %q, but %q is to move",
|
||||
move.MoveNumber, move.MoveUCI, move.PlayerColor, pos.Turn().String()))
|
||||
}
|
||||
m, err := pos.ParseUCI(move.MoveUCI)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("ply %d %s: %v", move.MoveNumber, move.MoveUCI, err))
|
||||
} else if want := pos.Play(m).FEN(); !sameFEN(want, move.FENAfterMove) {
|
||||
problems = append(problems, fmt.Sprintf("ply %d %s: stored %q, rules give %q",
|
||||
move.MoveNumber, move.MoveUCI, move.FENAfterMove, want))
|
||||
}
|
||||
// Continue from the stored position so one fault is reported once.
|
||||
if pos, err = chess.ParseFEN(move.FENAfterMove); err != nil {
|
||||
return append(problems, fmt.Sprintf("ply %d stored position: %v", move.MoveNumber, err))
|
||||
}
|
||||
positions = append(positions, pos)
|
||||
}
|
||||
return append(problems, verifyOutcome(record.Result, record.Termination, positions)...)
|
||||
}
|
||||
|
||||
func verifyOutcome(result, termination string, positions []*chess.Position) []string {
|
||||
final := positions[len(positions)-1]
|
||||
hasMoves := final.HasLegalMoves()
|
||||
mated := !hasMoves && final.InCheck()
|
||||
stalemated := !hasMoves && !final.InCheck()
|
||||
|
||||
if result == "" {
|
||||
switch {
|
||||
case termination != "":
|
||||
return []string{fmt.Sprintf("termination %q without a result", termination)}
|
||||
case mated:
|
||||
return []string{"final position is checkmate but no result is stored"}
|
||||
case stalemated:
|
||||
return []string{"final position is stalemate but no result is stored"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
state, ok := stateOf(result)
|
||||
if !ok || !core.Termination(termination).ValidFor(state) {
|
||||
return []string{fmt.Sprintf("result %q cannot come from termination %q", result, termination)}
|
||||
}
|
||||
var holds bool
|
||||
switch core.Termination(termination) {
|
||||
case core.TermCheckmate:
|
||||
// The side to move is the one mated.
|
||||
holds = mated && (final.Turn() == chess.White) == (result == "black_wins")
|
||||
case core.TermStalemate:
|
||||
holds = stalemated
|
||||
case core.TermInsufficientMaterial:
|
||||
holds = final.InsufficientMaterial()
|
||||
case core.TermThreefoldRepetition:
|
||||
holds = chess.Repetitions(positions) >= 3
|
||||
case core.TermFiftyMoveRule:
|
||||
holds = final.Halfmove() >= 100
|
||||
case core.TermResignation, core.TermAgreement:
|
||||
holds = true // decided by the players, not the position
|
||||
}
|
||||
if !holds {
|
||||
return []string{fmt.Sprintf("stored %s by %s does not hold in the final position", result, termination)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateOf(result string) (core.State, bool) {
|
||||
switch result {
|
||||
case "white_wins":
|
||||
return core.StateWhiteWins, true
|
||||
case "black_wins":
|
||||
return core.StateBlackWins, true
|
||||
case "stalemate":
|
||||
return core.StateStalemate, true
|
||||
case "draw":
|
||||
return core.StateDraw, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func sameFEN(a, b string) bool {
|
||||
na, errA := chess.NormalizeFEN(a)
|
||||
nb, errB := chess.NormalizeFEN(b)
|
||||
return errA == nil && errB == nil && na == nb
|
||||
}
|
||||
Reference in new issue
Block a user