v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 22:35:52 -04:00
1 parent 52ac0fe31a
commit f1ac4cbbe7
71 files changed
+7677 -537

No files matched your search

+155
View File
@@ -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"
}
+156
View File
@@ -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")
}
}
+118
View File
@@ -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
}