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

-119
View File
@@ -1,119 +0,0 @@
package board
import (
"fmt"
"strings"
"chess/internal/server/core"
)
const (
StartingFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
)
type Board struct {
squares [8][8]byte
turn core.Color
castling string
enPassant string
halfmove int
fullmove int
}
// FromFEN creates a Board from a FEN string with validation
func ParseFEN(fen string) (*Board, error) {
parts := strings.Fields(fen)
if len(parts) != 6 {
return nil, fmt.Errorf("invalid FEN: expected 6 parts, got %d", len(parts))
}
b := &Board{}
// Parse board
ranks := strings.Split(parts[0], "/")
if len(ranks) != 8 {
return nil, fmt.Errorf("invalid FEN: expected 8 ranks")
}
for r := 0; r < 8; r++ {
file := 0
for _, ch := range ranks[r] {
if ch >= '1' && ch <= '8' {
file += int(ch - '0')
} else {
if file >= 8 {
return nil, fmt.Errorf("invalid FEN: too many pieces in rank %d", r+1)
}
b.squares[r][file] = byte(ch)
file++
}
}
if file != 8 {
return nil, fmt.Errorf("invalid FEN: rank %d has %d files", r+1, file)
}
}
// Parse game state with validation
if len(parts[1]) != 1 {
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
}
switch parts[1] {
case "w":
b.turn = core.ColorWhite
case "b":
b.turn = core.ColorBlack
default:
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
}
b.castling = parts[2]
b.enPassant = parts[3]
if _, err := fmt.Sscanf(parts[4], "%d", &b.halfmove); err != nil {
return nil, fmt.Errorf("invalid FEN: halfmove counter")
}
if _, err := fmt.Sscanf(parts[5], "%d", &b.fullmove); err != nil {
return nil, fmt.Errorf("invalid FEN: fullmove counter")
}
return b, nil
}
// ToASCII creates an ASCII representation of the board
func (b *Board) ToASCII() string {
var sb strings.Builder
sb.WriteString(" a b c d e f g h\n")
for r := 0; r < 8; r++ {
sb.WriteString(fmt.Sprintf("%d ", 8-r))
for f := 0; f < 8; f++ {
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
piece := b.GetPieceAt(square)
if piece == 0 {
sb.WriteString(". ")
} else {
sb.WriteString(fmt.Sprintf("%c ", piece))
}
}
sb.WriteString(fmt.Sprintf("%d\n", 8-r))
}
sb.WriteString(" a b c d e f g h")
return sb.String()
}
func (b *Board) Turn() core.Color {
return b.turn
}
func (b *Board) GetPieceAt(square string) byte {
if len(square) != 2 {
return 0
}
if square[0] < 'a' || square[0] > 'h' || square[1] < '1' || square[1] > '8' {
return 0
}
file := square[0] - 'a'
rank := '8' - square[1]
return b.squares[rank][file]
}
+370
View File
@@ -0,0 +1,370 @@
package chess
import (
"errors"
"strings"
"testing"
)
// Perft reference counts from the Chess Programming Wiki "Perft Results"
// page; together they cover castling, en passant, promotion, pins, and checks.
var perftCases = []struct {
name string
fen string
counts []uint64 // depth 1..n
}{
{"start", StartFEN, []uint64{20, 400, 8902, 197281}},
{"kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
[]uint64{48, 2039, 97862, 4085603}},
{"position3", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", []uint64{14, 191, 2812, 43238, 674624}},
{"position4", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1",
[]uint64{6, 264, 9467, 422333}},
{"position4-mirrored", "r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1",
[]uint64{6, 264, 9467, 422333}},
{"position5", "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", []uint64{44, 1486, 62379, 2103487}},
{"position6", "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10",
[]uint64{46, 2079, 89890, 3894594}},
}
func TestPerft(t *testing.T) {
for _, tc := range perftCases {
t.Run(tc.name, func(t *testing.T) {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatal(err)
}
for i, want := range tc.counts {
depth := i + 1
if testing.Short() && want > 500000 {
break
}
if got := pos.Perft(depth); got != want {
t.Fatalf("perft(%d) = %d, want %d", depth, got, want)
}
}
})
}
}
func TestFENRoundTrip(t *testing.T) {
for _, tc := range perftCases {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatal(err)
}
if got := pos.FEN(); got != tc.fen {
t.Errorf("FEN() = %q, want %q", got, tc.fen)
}
}
}
func TestParseFENRejects(t *testing.T) {
for _, fen := range []string{
"",
"8/8/8/8/8/8/8/8 w - - 0 1", // no kings
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0", // 5 fields
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNRR w KQkq - 0 1", // 9 files
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN w KQkq - 0 1", // 7 files
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1", // side
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkqK - 0 1", // castling length
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KK - 0 1", // duplicate right
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e9 0 1", // ep square
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - -1 1", // halfmove
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 0", // fullmove
"Pnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", // pawn on rank 8
"4k3/8/8/8/8/8/8/4K2r b - - 0 1", // side not to move in check
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 x", // 7 fields
} {
if _, err := ParseFEN(fen); !errors.Is(err, ErrInvalidFEN) {
t.Errorf("ParseFEN(%q) error = %v, want ErrInvalidFEN", fen, err)
}
}
}
func TestParseFENNormalizes(t *testing.T) {
cases := []struct{ in, want string }{
// Castling rights without their rook are dropped.
{"4k3/8/8/8/8/8/8/4K3 w KQkq - 0 1", "4k3/8/8/8/8/8/8/4K3 w - - 0 1"},
// Right order is canonical.
{"r3k2r/8/8/8/8/8/8/R3K2R w qkQK - 0 1", "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"},
// Pseudo-legal en passant (no capturing pawn) is dropped.
{"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1"},
// A capture that would expose the king (pinned along the rank) is illegal.
{"8/8/8/8/k2pP2R/8/8/4K3 b - e3 0 1", "8/8/8/8/k2pP2R/8/8/4K3 b - - 0 1"},
// A legal en-passant capture is kept.
{"4k3/8/8/8/3pP3/8/8/3K3R b - e3 0 1", "4k3/8/8/8/3pP3/8/8/3K3R b - e3 0 1"},
}
for _, tc := range cases {
got, err := NormalizeFEN(tc.in)
if err != nil {
t.Fatalf("NormalizeFEN(%q): %v", tc.in, err)
}
if got != tc.want {
t.Errorf("NormalizeFEN(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSAN(t *testing.T) {
cases := []struct {
fen, uci, san string
}{
{StartFEN, "e2e4", "e4"},
{StartFEN, "g1f3", "Nf3"},
{"r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", "f1b5", "Bb5"},
// Castling both ways.
{"r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "e1g1", "O-O"},
{"r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1", "e8c8", "O-O-O"},
// File disambiguation: knights on b1 and f3 both reach d2.
{"4k3/8/8/8/8/5N2/8/1N2K3 w - - 0 1", "b1d2", "Nbd2"},
// Rank disambiguation: rooks on a1 and a5 both reach a3.
{"4k3/8/8/R7/8/8/8/R3K3 w - - 0 1", "a1a3", "R1a3"},
// Both: queens on e4, h4, and h1 all reach e1.
{"1k6/8/8/8/4Q2Q/8/8/K6Q w - - 0 1", "h4e1", "Qh4e1"},
// Pawn capture, en passant, promotion with check and capture.
{"rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2", "e4d5", "exd5"},
{"rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3", "e5f6", "exf6"},
{"8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55", "g7g8q", "g8=Q"},
{"8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55", "g7g8n", "g8=N"},
{"3r3k/4P3/8/8/8/8/8/K7 w - - 0 1", "e7d8q", "exd8=Q+"},
// Mate.
{"rnbqkbnr/ppppp2p/5p2/6p1/4P3/8/PPPP1PPP/RNBQKBNR w KQkq g6 0 3", "d1h5", "Qh5#"},
}
for _, tc := range cases {
pos, err := ParseFEN(tc.fen)
if err != nil {
t.Fatalf("%s: %v", tc.fen, err)
}
m, err := pos.ParseUCI(tc.uci)
if err != nil {
t.Fatalf("%s %s: %v", tc.fen, tc.uci, err)
}
if got := pos.SAN(m); got != tc.san {
t.Errorf("%s %s: SAN = %q, want %q", tc.fen, tc.uci, got, tc.san)
}
back, err := pos.ParseSAN(tc.san)
if err != nil || back != m {
t.Errorf("ParseSAN(%q) = %v, %v; want %v", tc.san, back, err, m)
}
}
}
func TestParseSANLenient(t *testing.T) {
pos, err := ParseFEN("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
if err != nil {
t.Fatal(err)
}
for in, want := range map[string]string{"0-0": "e1g1", "O-O-O": "e1c1", "Kf1!?": "e1f1", "Ra1b1": "a1b1"} {
m, err := pos.ParseSAN(in)
if err != nil || m.UCI() != want {
t.Errorf("ParseSAN(%q) = %v, %v; want %s", in, m.UCI(), err, want)
}
}
promo, _ := ParseFEN("8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55")
if m, err := promo.ParseSAN("g8Q"); err != nil || m.UCI() != "g7g8q" {
t.Errorf("ParseSAN(g8Q) = %v, %v", m.UCI(), err)
}
if _, err := promo.ParseSAN("g8"); !errors.Is(err, ErrIllegalMove) {
t.Errorf("ParseSAN(g8) error = %v, want ErrIllegalMove", err)
}
knights, _ := ParseFEN("4k3/8/8/8/8/5N2/8/1N2K3 w - - 0 1")
if _, err := knights.ParseSAN("Nd2"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Errorf("ParseSAN(Nd2) error = %v, want ambiguous", err)
}
}
func TestParseUCIPromotionRequired(t *testing.T) {
pos, err := ParseFEN("8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55")
if err != nil {
t.Fatal(err)
}
if _, err := pos.ParseUCI("g7g8"); !errors.Is(err, ErrPromotionRequired) {
t.Fatalf("ParseUCI(g7g8) error = %v, want ErrPromotionRequired", err)
}
if _, err := pos.ParseUCI("f7g8"); !errors.Is(err, ErrIllegalMove) {
t.Fatalf("ParseUCI(f7g8) error = %v, want ErrIllegalMove", err)
}
}
// The reported game: every stored FEN must match the replay, and the final
// position must accept the promotion that the web client could not send.
func TestReplayReportedGame(t *testing.T) {
uci := strings.Fields(`e2e4 e7e6 d2d4 d7d5 b1c3 a7a6 g1f3 g8f6 f1d3 f6e4 c3e4 d5e4 d3e4 c7c5 c2c3 f8e7
c1e3 c5d4 c3d4 b8d7 e1g1 d7f6 e3g5 f6e4 g5e7 d8e7 d1d3 f7f5 a1c1 e7d6 f3e5 c8d7 f2f3 e4f6 e5d7 d6d7
f1e1 e8g8 d3e3 f8f7 e3e6 a8d8 e6d7 f7d7 c1d1 d7d4 d1d4 d8d4 e1e5 g8f7 e5f5 g7g6 f5a5 f7e7 b2b3 d4d5
a5d5 f6d5 g1f2 e7d6 g2g4 d6e5 f2g3 b7b5 h2h4 d5f6 f3f4 e5e4 g4g5 f6d5 g3g4 a6a5 h4h5 d5e3 g4g3 b5b4
h5g6 h7g6 g3h4 e4f3 h4h3 f3e4 h3g3 e3f5 g3g4 f5e7 g4g3 e7c6 g3g4 e4e3 f4f5 c6e5 g4h4 g6f5 h4h5 e3f4
g5g6 e5d7 g6g7 d7f6 h5g6 f4e5 g6g5 f5f4 g5g6 f6g8 g6f7 g8f6`)
line, err := Replay(StartFEN, uci)
if err != nil {
t.Fatal(err)
}
if got, want := line.Final().FEN(), "8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"; got != want {
t.Fatalf("final FEN = %q, want %q", got, want)
}
if line.SAN[20] != "O-O" || line.SAN[37] != "O-O" {
t.Errorf("castling SAN = %q, %q", line.SAN[20], line.SAN[37])
}
next, err := Replay(StartFEN, append(uci, "g7g8q"))
if err != nil {
t.Fatal(err)
}
if got := next.SAN[len(next.SAN)-1]; got != "g8=Q" {
t.Errorf("promotion SAN = %q", got)
}
}
func TestReplayReportsPly(t *testing.T) {
_, err := Replay(StartFEN, []string{"e2e4", "e7e5", "e1e3"})
var plyErr *PlyError
if !errors.As(err, &plyErr) || plyErr.Ply != 3 || !errors.Is(err, ErrIllegalMove) {
t.Fatalf("error = %v, want illegal move at ply 3", err)
}
}
func TestDrawPrimitives(t *testing.T) {
for fen, want := range map[string]bool{
"4k3/8/8/8/8/8/8/4K3 w - - 0 1": true, // bare kings
"4k3/8/8/8/8/8/8/3NK3 w - - 0 1": true, // one knight
"3bk3/8/8/8/8/8/8/2B1K3 w - - 0 1": true, // bishops on one color
"2b1k3/8/8/8/8/8/8/2B1K3 w - - 0 1": false, // opposite-colored bishops
"4k3/8/8/8/8/8/8/2NNK3 w - - 0 1": false, // two knights can mate with help
"4k3/8/8/8/8/8/P7/4K3 w - - 0 1": false,
} {
pos, err := ParseFEN(fen)
if err != nil {
t.Fatal(err)
}
if got := pos.InsufficientMaterial(); got != want {
t.Errorf("%s: InsufficientMaterial = %v, want %v", fen, got, want)
}
}
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
line, err := Replay(StartFEN, repeat(cycle, 2))
if err != nil {
t.Fatal(err)
}
for ply, want := range map[int]int{0: 1, 3: 1, 4: 2, 5: 2, 8: 3} {
if got := line.Repetitions(ply); got != want {
t.Errorf("Repetitions(%d) = %d, want %d", ply, got, want)
}
}
// A pawn move in between resets the window: the start position cannot
// recur after 1. e3.
line, err = Replay(StartFEN, append([]string{"e2e3", "e7e6"}, repeat(cycle, 2)...))
if err != nil {
t.Fatal(err)
}
if got := line.Repetitions(10); got != 3 {
t.Errorf("after pawn moves: Repetitions(10) = %d, want 3", got)
}
if got := Repetitions(nil); got != 0 {
t.Errorf("Repetitions(nil) = %d", got)
}
}
func repeat(moves []string, n int) []string {
var out []string
for i := 0; i < n; i++ {
out = append(out, moves...)
}
return out
}
func TestPGN(t *testing.T) {
line, err := Replay(StartFEN, []string{"f2f3", "e7e5", "g2g4", "d8h4"})
if err != nil {
t.Fatal(err)
}
got := PGN{
Tags: []Tag{
{"Event", "Casual game"}, {"Date", "2026.09.27"}, {"White", `Al "the" \ pawn`},
{"Black", "Stockfish level 3"}, {"Termination", "normal"}, {"Bad Tag", "x"}, {"Evil", "a\"]\n[X \"y"},
},
StartFEN: StartFEN,
SAN: line.SAN,
Result: "0-1",
}.String()
want := `[Event "Casual game"]
[Site "?"]
[Date "2026.09.27"]
[Round "-"]
[White "Al \"the\" \\ pawn"]
[Black "Stockfish level 3"]
[Result "0-1"]
[Termination "normal"]
[Evil "a\"][X \"y"]
1. f3 e5 2. g4 Qh4# 0-1
`
if got != want {
t.Fatalf("PGN mismatch\n got:\n%s\nwant:\n%s", got, want)
}
}
func TestPGNCustomStartAndWrap(t *testing.T) {
start := "4k3/8/8/8/8/8/8/R3K3 b - - 0 40"
var uci []string
for i := 0; i < 20; i++ {
// Shuffle king and rook without repeating five times in a row.
uci = append(uci, [][]string{{"e8d8", "a1a2"}, {"d8e8", "a2a1"}}[i%2]...)
}
line, err := Replay(start, uci[:30])
if err != nil {
t.Fatal(err)
}
text := PGN{Tags: []Tag{{"Date", "2026.01.02"}}, StartFEN: start, SAN: line.SAN, Result: "garbage"}.String()
if !strings.Contains(text, "[SetUp \"1\"]\n[FEN \""+start+"\"]\n") {
t.Errorf("missing SetUp/FEN tags:\n%s", text)
}
movetext := text[strings.Index(text, "\n\n")+2:]
if !strings.HasPrefix(movetext, "40... Kd8 41. Ra2 Ke8 ") {
t.Errorf("movetext starts %q", movetext[:30])
}
if !strings.HasSuffix(movetext, " *\n") {
t.Errorf("result token missing: %q", movetext)
}
for _, l := range strings.Split(strings.TrimSuffix(movetext, "\n"), "\n") {
if len(l) > pgnLineWidth {
t.Errorf("line longer than %d: %q", pgnLineWidth, l)
}
}
}
func TestResultToken(t *testing.T) {
for in, want := range map[string]string{"white_wins": "1-0", "black_wins": "0-1", "draw": "1/2-1/2",
"stalemate": "1/2-1/2", "": "*", "other": "*"} {
if got := ResultToken(in); got != want {
t.Errorf("ResultToken(%q) = %q, want %q", in, got, want)
}
}
}
func TestASCII(t *testing.T) {
pos, err := ParseFEN(StartFEN)
if err != nil {
t.Fatal(err)
}
want := ` a b c d e f g h
8 r n b q k b n r 8
7 p p p p p p p p 7
6 . . . . . . . . 6
5 . . . . . . . . 5
4 . . . . . . . . 4
3 . . . . . . . . 3
2 P P P P P P P P 2
1 R N B Q K B N R 1
a b c d e f g h`
if got := pos.ASCII(); got != want {
t.Fatalf("ASCII() =\n%s\nwant\n%s", got, want)
}
}
func TestPGNComment(t *testing.T) {
text := PGN{SAN: []string{"e4", "e5"}, Comment: "White {resigns}.\n", Result: "0-1"}.String()
if !strings.HasSuffix(text, "\n\n1. e4 e5 { White resigns. } 0-1\n") {
t.Fatalf("PGN comment:\n%s", text)
}
}
+98
View File
@@ -0,0 +1,98 @@
package chess
import (
"regexp"
"strings"
"testing"
)
func FuzzParseFEN(f *testing.F) {
for _, tc := range perftCases {
f.Add(tc.fen)
}
f.Add("8/8/8/8/k2pP2R/8/8/4K3 b - e3 0 1")
f.Add("4k3/8/8/8/8/8/8/4K3 w KQkq - 0 1")
f.Fuzz(func(t *testing.T, fen string) {
pos, err := ParseFEN(fen)
if err != nil {
return
}
out := pos.FEN()
again, err := ParseFEN(out)
if err != nil {
t.Fatalf("FEN() output %q does not parse: %v", out, err)
}
if again.FEN() != out {
t.Fatalf("FEN not stable: %q -> %q", out, again.FEN())
}
for _, m := range pos.LegalMoves() {
san := pos.SAN(m)
back, err := pos.ParseSAN(san)
if err != nil || back != m {
t.Fatalf("%s: SAN %q of %s parses to %v, %v", out, san, m.UCI(), back.UCI(), err)
}
if u, err := pos.ParseUCI(m.UCI()); err != nil || u != m {
t.Fatalf("%s: UCI %s parses to %v, %v", out, m.UCI(), u.UCI(), err)
}
}
})
}
func FuzzParseSAN(f *testing.F) {
positions := make([]*Position, 0, len(perftCases))
for _, tc := range perftCases {
pos, err := ParseFEN(tc.fen)
if err != nil {
f.Fatal(err)
}
positions = append(positions, pos)
}
for _, s := range []string{"e4", "Nf3", "O-O", "0-0-0", "exd8=Q+", "Qh4e1", "Rxa1#", "b8Q", "N", "x", "=Q"} {
f.Add(s, uint8(0))
}
f.Fuzz(func(t *testing.T, san string, which uint8) {
pos := positions[int(which)%len(positions)]
m, err := pos.ParseSAN(san)
if err != nil {
return
}
found := false
for _, legal := range pos.LegalMoves() {
found = found || legal == m
}
if !found {
t.Fatalf("ParseSAN(%q) returned non-legal move %s", san, m.UCI())
}
})
}
var tagLine = regexp.MustCompile(`^\[[A-Za-z][A-Za-z0-9_]* "([^"\\]|\\["\\])*"\]$`)
func FuzzPGN(f *testing.F) {
f.Add("Event", "Casual game", "x\"]\n[Y \"z")
f.Add("White", `a\b`, "Ω")
f.Fuzz(func(t *testing.T, name, value, other string) {
text := PGN{
Tags: []Tag{{name, value}, {"White", other}, {"Black", value}},
SAN: []string{"e4", "e5"},
Comment: other,
Result: "*",
}.String()
header, movetext, ok := strings.Cut(text, "\n\n")
if !ok {
t.Fatalf("no blank line between tags and movetext:\n%s", text)
}
for _, l := range strings.Split(header, "\n") {
if !tagLine.MatchString(l) {
t.Fatalf("malformed tag line %q", l)
}
}
// The comment must stay one brace comment: exactly one of each brace,
// in order, before the result.
if !strings.HasPrefix(movetext, "1. e4 e5 ") || !strings.HasSuffix(movetext, "*\n") ||
strings.Count(movetext, "{") > 1 || strings.Count(movetext, "}") != strings.Count(movetext, "{") ||
strings.Index(movetext, "}") < strings.Index(movetext, "{") {
t.Fatalf("movetext = %q", movetext)
}
})
}
+427
View File
@@ -0,0 +1,427 @@
package chess
import (
"errors"
"fmt"
)
const (
sqA1 Square = 0
sqC1 Square = 2
sqD1 Square = 3
sqE1 Square = 4
sqF1 Square = 5
sqG1 Square = 6
sqH1 Square = 7
sqA8 Square = 56
sqC8 Square = 58
sqD8 Square = 59
sqE8 Square = 60
sqF8 Square = 61
sqG8 Square = 62
sqH8 Square = 63
)
// Move is a from-to pair plus the promotion kind, if any. Castling is the
// king's two-square move, as in UCI.
type Move struct {
From, To Square
Promotion Kind
}
// UCI returns long algebraic notation, e.g. "e2e4" or "e7e8q".
func (m Move) UCI() string {
s := m.From.String() + m.To.String()
if m.Promotion != NoKind {
s += string(kindLetters[m.Promotion] + 'a' - 'A')
}
return s
}
// Direction tables: the first four are orthogonal, the last four diagonal.
var (
knightTargets [64][]Square
kingTargets [64][]Square
rays [64][8][]Square
// castleMask[s] holds the rights that survive a move from or to s.
castleMask [64]Castling
)
func init() {
knightDeltas := [8][2]int{{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}}
kingDeltas := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
dirs := [8][2]int{{0, 1}, {0, -1}, {1, 0}, {-1, 0}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}
for s := Square(0); s < 64; s++ {
f, r := s.File(), s.Rank()
for _, d := range knightDeltas {
if onBoard(f+d[0], r+d[1]) {
knightTargets[s] = append(knightTargets[s], MakeSquare(f+d[0], r+d[1]))
}
}
for _, d := range kingDeltas {
if onBoard(f+d[0], r+d[1]) {
kingTargets[s] = append(kingTargets[s], MakeSquare(f+d[0], r+d[1]))
}
}
for i, d := range dirs {
for nf, nr := f+d[0], r+d[1]; onBoard(nf, nr); nf, nr = nf+d[0], nr+d[1] {
rays[s][i] = append(rays[s][i], MakeSquare(nf, nr))
}
}
castleMask[s] = WhiteKingside | WhiteQueenside | BlackKingside | BlackQueenside
}
castleMask[sqE1] &^= WhiteKingside | WhiteQueenside
castleMask[sqH1] &^= WhiteKingside
castleMask[sqA1] &^= WhiteQueenside
castleMask[sqE8] &^= BlackKingside | BlackQueenside
castleMask[sqH8] &^= BlackKingside
castleMask[sqA8] &^= BlackQueenside
}
func onBoard(file, rank int) bool { return file >= 0 && file < 8 && rank >= 0 && rank < 8 }
func (p *Position) kingSquare(c Color) Square {
king := MakePiece(c, King)
for s := Square(0); s < 64; s++ {
if p.board[s] == king {
return s
}
}
return NoSquare
}
// attacked reports whether side `by` attacks square s.
func (p *Position) attacked(s Square, by Color) bool {
if s == NoSquare {
return false
}
f, r := s.File(), s.Rank()
pawn := MakePiece(by, Pawn)
pr := r - 1 // a white pawn attacks upward, so it stands one rank below
if by == Black {
pr = r + 1
}
if pr >= 0 && pr < 8 {
if f > 0 && p.board[MakeSquare(f-1, pr)] == pawn {
return true
}
if f < 7 && p.board[MakeSquare(f+1, pr)] == pawn {
return true
}
}
knight := MakePiece(by, Knight)
for _, t := range knightTargets[s] {
if p.board[t] == knight {
return true
}
}
king := MakePiece(by, King)
for _, t := range kingTargets[s] {
if p.board[t] == king {
return true
}
}
queen := MakePiece(by, Queen)
for i := 0; i < 8; i++ {
slider := MakePiece(by, Rook)
if i >= 4 {
slider = MakePiece(by, Bishop)
}
for _, t := range rays[s][i] {
if piece := p.board[t]; piece != NoPiece {
if piece == slider || piece == queen {
return true
}
break
}
}
}
return false
}
// InCheck reports whether the side to move is in check.
func (p *Position) InCheck() bool {
return p.attacked(p.kingSquare(p.turn), p.turn.Other())
}
var promotionKinds = [4]Kind{Queen, Rook, Bishop, Knight}
// pseudoLegal appends moves that obey piece movement but may leave the
// mover's king in check.
func (p *Position) pseudoLegal(moves []Move) []Move {
us := p.turn
them := us.Other()
forward, startRank, lastRank := 8, 1, 7
if us == Black {
forward, startRank, lastRank = -8, 6, 0
}
addPawn := func(from, to Square) {
if to.Rank() == lastRank {
for _, k := range promotionKinds {
moves = append(moves, Move{From: from, To: to, Promotion: k})
}
return
}
moves = append(moves, Move{From: from, To: to})
}
for s := Square(0); s < 64; s++ {
piece := p.board[s]
if piece == NoPiece || piece.Color() != us {
continue
}
switch piece.Kind() {
case Pawn:
one := s + Square(forward)
if p.board[one] == NoPiece {
addPawn(s, one)
two := one + Square(forward)
if s.Rank() == startRank && p.board[two] == NoPiece {
moves = append(moves, Move{From: s, To: two})
}
}
for _, df := range [2]int{-1, 1} {
f := s.File() + df
if f < 0 || f > 7 {
continue
}
to := one + Square(df)
if target := p.board[to]; target != NoPiece && target.Color() == them {
addPawn(s, to)
} else if to == p.ep && target == NoPiece {
moves = append(moves, Move{From: s, To: to})
}
}
case Knight:
for _, t := range knightTargets[s] {
if target := p.board[t]; target == NoPiece || target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
}
case King:
for _, t := range kingTargets[s] {
if target := p.board[t]; target == NoPiece || target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
}
default:
first, last := 0, 8
switch piece.Kind() {
case Rook:
last = 4
case Bishop:
first = 4
}
for i := first; i < last; i++ {
for _, t := range rays[s][i] {
target := p.board[t]
if target == NoPiece {
moves = append(moves, Move{From: s, To: t})
continue
}
if target.Color() == them {
moves = append(moves, Move{From: s, To: t})
}
break
}
}
}
}
return p.castlingMoves(moves)
}
func (p *Position) castlingMoves(moves []Move) []Move {
us, them := p.turn, p.turn.Other()
kingside, queenside := WhiteKingside, WhiteQueenside
e, f, g, d, c, b := sqE1, sqF1, sqG1, sqD1, sqC1, Square(1)
if us == Black {
kingside, queenside = BlackKingside, BlackQueenside
e, f, g, d, c, b = sqE8, sqF8, sqG8, sqD8, sqC8, Square(57)
}
if p.castling&(kingside|queenside) == 0 || p.attacked(e, them) {
return moves
}
if p.castling&kingside != 0 && p.board[f] == NoPiece && p.board[g] == NoPiece &&
!p.attacked(f, them) && !p.attacked(g, them) {
moves = append(moves, Move{From: e, To: g})
}
if p.castling&queenside != 0 && p.board[d] == NoPiece && p.board[c] == NoPiece && p.board[b] == NoPiece &&
!p.attacked(d, them) && !p.attacked(c, them) {
moves = append(moves, Move{From: e, To: c})
}
return moves
}
// LegalMoves returns every legal move in the position.
func (p *Position) LegalMoves() []Move {
candidates := p.pseudoLegal(make([]Move, 0, 48))
legal := candidates[:0]
for _, m := range candidates {
next := p.play(m)
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
legal = append(legal, m)
}
}
return legal
}
// HasLegalMoves reports whether the side to move can move; it stops at the
// first legal move.
func (p *Position) HasLegalMoves() bool {
for _, m := range p.pseudoLegal(make([]Move, 0, 48)) {
next := p.play(m)
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
return true
}
}
return false
}
// play applies a pseudo-legal move and returns the resulting position.
func (p *Position) play(m Move) Position {
next := *p
piece := next.board[m.From]
captured := next.board[m.To]
next.board[m.From] = NoPiece
next.board[m.To] = piece
switch piece.Kind() {
case Pawn:
if m.To == p.ep && captured == NoPiece && m.From.File() != m.To.File() {
next.board[MakeSquare(m.To.File(), m.From.Rank())] = NoPiece
}
if m.Promotion != NoKind {
next.board[m.To] = MakePiece(p.turn, m.Promotion)
}
case King:
switch {
case m.From == sqE1 && m.To == sqG1:
next.board[sqH1], next.board[sqF1] = NoPiece, next.board[sqH1]
case m.From == sqE1 && m.To == sqC1:
next.board[sqA1], next.board[sqD1] = NoPiece, next.board[sqA1]
case m.From == sqE8 && m.To == sqG8:
next.board[sqH8], next.board[sqF8] = NoPiece, next.board[sqH8]
case m.From == sqE8 && m.To == sqC8:
next.board[sqA8], next.board[sqD8] = NoPiece, next.board[sqA8]
}
}
next.castling &= castleMask[m.From] & castleMask[m.To]
next.ep = NoSquare
if piece.Kind() == Pawn && (m.To-m.From == 16 || m.From-m.To == 16) {
next.ep = (m.From + m.To) / 2
}
if piece.Kind() == Pawn || captured != NoPiece {
next.halfmove = 0
} else {
next.halfmove++
}
if p.turn == Black {
next.fullmove++
}
next.turn = p.turn.Other()
return next
}
// Play returns the position after a legal move. It does not re-check
// legality; use ParseUCI or ParseSAN to obtain a legal Move.
func (p *Position) Play(m Move) *Position {
next := p.play(m)
return &next
}
// legalEPSquare returns the en-passant target only when a capture on it is
// legal for the side to move.
func (p *Position) legalEPSquare() Square {
if p.ep == NoSquare {
return NoSquare
}
pawn := MakePiece(p.turn, Pawn)
from := p.ep.Rank() - 1
if p.turn == Black {
from = p.ep.Rank() + 1
}
for _, df := range [2]int{-1, 1} {
f := p.ep.File() + df
if f < 0 || f > 7 {
continue
}
s := MakeSquare(f, from)
if p.board[s] != pawn {
continue
}
next := p.play(Move{From: s, To: p.ep})
if !next.attacked(next.kingSquare(p.turn), p.turn.Other()) {
return p.ep
}
}
return NoSquare
}
// ErrIllegalMove reports a move that is malformed or not legal here.
var ErrIllegalMove = errors.New("illegal move")
// ErrPromotionRequired reports a pawn move to the last rank without a
// promotion piece.
var ErrPromotionRequired = errors.New("promotion piece required")
// ParseUCI resolves a UCI move string against the legal moves.
func (p *Position) ParseUCI(s string) (Move, error) {
return p.parseUCI(s, p.LegalMoves())
}
func (p *Position) parseUCI(s string, legal []Move) (Move, error) {
if len(s) != 4 && len(s) != 5 {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
from, err1 := ParseSquare(s[0:2])
to, err2 := ParseSquare(s[2:4])
if err1 != nil || err2 != nil {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
m := Move{From: from, To: to}
if len(s) == 5 {
switch s[4] {
case 'q', 'Q':
m.Promotion = Queen
case 'r', 'R':
m.Promotion = Rook
case 'b', 'B':
m.Promotion = Bishop
case 'n', 'N':
m.Promotion = Knight
default:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
}
promotable := false
for _, candidate := range legal {
if candidate == m {
return m, nil
}
if candidate.From == from && candidate.To == to && candidate.Promotion != NoKind {
promotable = true
}
}
if promotable && m.Promotion == NoKind {
return Move{}, fmt.Errorf("%w: %q", ErrPromotionRequired, s)
}
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
// Perft counts leaf nodes of the legal move tree to the given depth.
func (p *Position) Perft(depth int) uint64 {
if depth <= 0 {
return 1
}
moves := p.LegalMoves()
if depth == 1 {
return uint64(len(moves))
}
var n uint64
for _, m := range moves {
next := p.play(m)
n += next.Perft(depth - 1)
}
return n
}
+185
View File
@@ -0,0 +1,185 @@
package chess
import (
"strconv"
"strings"
)
// pgnLineWidth is the export-format line limit recommended by the PGN
// standard (section 8.2.1).
const pgnLineWidth = 80
// Tag is one PGN tag pair.
type Tag struct {
Name, Value string
}
// sevenTagRoster is the mandatory tag order of PGN export format.
var sevenTagRoster = [...]string{"Event", "Site", "Date", "Round", "White", "Black", "Result"}
// PGN is a game ready for export. Tags may hold any of the Seven Tag Roster
// (missing ones are written as "?", Result from the Result field) followed by
// supplemental tags in the given order. StartFEN is written as SetUp/FEN
// tags when it is not the standard start. Comment, if set, is written as a
// brace comment before the result, e.g. "White resigns.".
type PGN struct {
Tags []Tag
StartFEN string
SAN []string
Comment string
Result string // "1-0", "0-1", "1/2-1/2", or "*"
}
// String renders PGN export format: tag section, blank line, movetext
// wrapped at 80 columns and ending with the result, and a final newline.
func (g PGN) String() string {
result := g.Result
switch result {
case "1-0", "0-1", "1/2-1/2":
default:
result = "*"
}
var b strings.Builder
values := make(map[string]string, len(g.Tags))
for _, t := range g.Tags {
values[t.Name] = t.Value
}
for _, name := range sevenTagRoster {
value, ok := values[name]
if name == "Result" {
value, ok = result, true
}
if !ok || value == "" {
value = "?"
if name == "Round" {
value = "-"
}
}
writeTag(&b, name, value)
}
turn, fullmove := White, 1
if g.StartFEN != "" && g.StartFEN != StartFEN {
writeTag(&b, "SetUp", "1")
writeTag(&b, "FEN", g.StartFEN)
if start, err := ParseFEN(g.StartFEN); err == nil {
turn, fullmove = start.turn, start.fullmove
}
}
for _, t := range g.Tags {
if isRosterTag(t.Name) || t.Name == "SetUp" || t.Name == "FEN" || !validTagName(t.Name) {
continue
}
writeTag(&b, t.Name, t.Value)
}
b.WriteByte('\n')
width := 0
emit := func(token string) {
switch {
case width == 0:
case width+1+len(token) > pgnLineWidth:
b.WriteByte('\n')
width = 0
default:
b.WriteByte(' ')
width++
}
b.WriteString(token)
width += len(token)
}
for i, san := range g.SAN {
switch {
case turn == White:
emit(strconv.Itoa(fullmove) + ".")
case i == 0:
emit(strconv.Itoa(fullmove) + "...")
}
emit(san)
if turn == Black {
fullmove++
}
turn = turn.Other()
}
if words := strings.Fields(commentText(g.Comment)); len(words) > 0 {
// A comment may be broken across lines at spaces, so it wraps like
// movetext; braces cannot nest and are removed from the text.
emit("{")
for _, word := range words {
emit(word)
}
emit("}")
}
emit(result)
b.WriteByte('\n')
return b.String()
}
// commentText drops braces and control characters, which would end or
// corrupt a brace comment.
func commentText(s string) string {
return strings.Map(func(r rune) rune {
if r == '{' || r == '}' || r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
}
func isRosterTag(name string) bool {
for _, r := range sevenTagRoster {
if r == name {
return true
}
}
return false
}
// validTagName accepts PGN tag symbols: letters, digits, and underscore,
// starting with a letter.
func validTagName(name string) bool {
if name == "" || len(name) > 64 {
return false
}
for i := 0; i < len(name); i++ {
c := name[i]
letter := c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
if !letter && (i == 0 || !(c >= '0' && c <= '9' || c == '_')) {
return false
}
}
return true
}
// writeTag writes one tag pair, escaping backslash and quote and dropping
// control characters so a value cannot break out of its string token.
func writeTag(b *strings.Builder, name, value string) {
b.WriteByte('[')
b.WriteString(name)
b.WriteString(` "`)
for _, r := range value {
switch {
case r == '\\' || r == '"':
b.WriteByte('\\')
b.WriteRune(r)
case r < 0x20 || r == 0x7f:
default:
b.WriteRune(r)
}
}
b.WriteString("\"]\n")
}
// ResultToken maps a stored result to its PGN token.
func ResultToken(result string) string {
switch result {
case "white_wins":
return "1-0"
case "black_wins":
return "0-1"
case "draw", "stalemate":
return "1/2-1/2"
}
return "*"
}
+430
View File
@@ -0,0 +1,430 @@
// Package chess is a dependency-free rules core for replay and notation: FEN,
// legal move generation, SAN, PGN, and draw-rule detection. Stockfish remains
// the move validator for live play; this package derives notation from stored
// games and cross-checks the engine.
package chess
import (
"errors"
"fmt"
"strconv"
"strings"
)
// StartFEN is the standard initial position.
const StartFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
// maxFENLength bounds parser input; a legal FEN is far shorter.
const maxFENLength = 128
// Color is the side to move or a piece's owner.
type Color uint8
const (
White Color = iota
Black
)
// Other returns the opposing color.
func (c Color) Other() Color { return c ^ 1 }
func (c Color) String() string {
if c == White {
return "w"
}
return "b"
}
// Kind is a piece type without color.
type Kind uint8
const (
NoKind Kind = iota
Pawn
Knight
Bishop
Rook
Queen
King
)
// Piece is a Kind with a Color; the zero value is an empty square.
type Piece uint8
const NoPiece Piece = 0
// MakePiece combines a color and kind.
func MakePiece(c Color, k Kind) Piece { return Piece(k) | Piece(c)<<3 }
func (p Piece) Kind() Kind { return Kind(p & 7) }
func (p Piece) Color() Color { return Color(p >> 3) }
// Square indexes the board from a1 = 0 to h8 = 63.
type Square int8
const NoSquare Square = -1
// MakeSquare returns the square at file 0-7 (a-h) and rank 0-7 (1-8).
func MakeSquare(file, rank int) Square { return Square(rank*8 + file) }
func (s Square) File() int { return int(s) & 7 }
func (s Square) Rank() int { return int(s) >> 3 }
func (s Square) String() string {
if s < 0 || s > 63 {
return "-"
}
return string([]byte{byte('a' + s.File()), byte('1' + s.Rank())})
}
// ParseSquare parses algebraic coordinates such as "e4".
func ParseSquare(s string) (Square, error) {
if len(s) != 2 || s[0] < 'a' || s[0] > 'h' || s[1] < '1' || s[1] > '8' {
return NoSquare, fmt.Errorf("invalid square %q", s)
}
return MakeSquare(int(s[0]-'a'), int(s[1]-'1')), nil
}
// Castling is a set of castling rights.
type Castling uint8
const (
WhiteKingside Castling = 1 << iota
WhiteQueenside
BlackKingside
BlackQueenside
)
// Position is an immutable-by-convention board state; Play returns a copy.
type Position struct {
board [64]Piece
turn Color
castling Castling
// ep is the en-passant target after any double pawn step, whether or not
// a capture is possible; FEN output prints it only when one is legal.
ep Square
halfmove int
fullmove int
}
var (
pieceLetters = map[byte]Piece{
'P': MakePiece(White, Pawn), 'N': MakePiece(White, Knight), 'B': MakePiece(White, Bishop),
'R': MakePiece(White, Rook), 'Q': MakePiece(White, Queen), 'K': MakePiece(White, King),
'p': MakePiece(Black, Pawn), 'n': MakePiece(Black, Knight), 'b': MakePiece(Black, Bishop),
'r': MakePiece(Black, Rook), 'q': MakePiece(Black, Queen), 'k': MakePiece(Black, King),
}
kindLetters = [...]byte{NoKind: ' ', Pawn: 'P', Knight: 'N', Bishop: 'B', Rook: 'R', Queen: 'Q', King: 'K'}
)
// Letter returns the FEN letter of a piece (uppercase for White).
func (p Piece) Letter() byte {
if p == NoPiece {
return ' '
}
l := kindLetters[p.Kind()]
if p.Color() == Black {
l += 'a' - 'A'
}
return l
}
// ErrInvalidFEN wraps every FEN parse failure.
var ErrInvalidFEN = errors.New("invalid FEN")
func fenError(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidFEN, fmt.Sprintf(format, args...))
}
// ParseFEN parses and validates a six-field FEN. It rejects positions the
// rules cannot arise from in ways that matter to move generation: a king
// count other than one per side, pawns on the first or last rank, or the side
// not to move being in check. Castling rights whose king or rook is not on
// its original square are dropped, and an en-passant square is kept only
// when a double step could have produced it.
func ParseFEN(fen string) (*Position, error) {
if len(fen) > maxFENLength {
return nil, fenError("longer than %d bytes", maxFENLength)
}
fields := strings.Fields(fen)
if len(fields) != 6 {
return nil, fenError("expected 6 fields, got %d", len(fields))
}
p := &Position{ep: NoSquare}
ranks := strings.Split(fields[0], "/")
if len(ranks) != 8 {
return nil, fenError("expected 8 ranks")
}
kings := [2]int{}
for i, row := range ranks {
rank := 7 - i
file := 0
for j := 0; j < len(row); j++ {
ch := row[j]
if ch >= '1' && ch <= '8' {
file += int(ch - '0')
if file > 8 {
return nil, fenError("rank %d overflows", rank+1)
}
continue
}
piece, ok := pieceLetters[ch]
if !ok {
return nil, fenError("unknown piece %q", ch)
}
if file >= 8 {
return nil, fenError("rank %d overflows", rank+1)
}
if piece.Kind() == Pawn && (rank == 0 || rank == 7) {
return nil, fenError("pawn on rank %d", rank+1)
}
if piece.Kind() == King {
kings[piece.Color()]++
}
p.board[MakeSquare(file, rank)] = piece
file++
}
if file != 8 {
return nil, fenError("rank %d has %d files", rank+1, file)
}
}
if kings[White] != 1 || kings[Black] != 1 {
return nil, fenError("each side needs exactly one king")
}
switch fields[1] {
case "w":
p.turn = White
case "b":
p.turn = Black
default:
return nil, fenError("side to move must be w or b")
}
if fields[2] != "-" {
if len(fields[2]) > 4 {
return nil, fenError("castling field too long")
}
for i := 0; i < len(fields[2]); i++ {
var right Castling
switch fields[2][i] {
case 'K':
right = WhiteKingside
case 'Q':
right = WhiteQueenside
case 'k':
right = BlackKingside
case 'q':
right = BlackQueenside
default:
return nil, fenError("invalid castling right %q", fields[2][i])
}
if p.castling&right != 0 {
return nil, fenError("duplicate castling right %q", fields[2][i])
}
p.castling |= right
}
p.castling &= p.castlingSupported()
}
if fields[3] != "-" {
sq, err := ParseSquare(fields[3])
if err != nil {
return nil, fenError("en passant: %v", err)
}
if p.epPlausible(sq) {
p.ep = sq
}
}
var err error
if p.halfmove, err = parseCounter(fields[4], 0); err != nil {
return nil, fenError("halfmove clock: %v", err)
}
if p.fullmove, err = parseCounter(fields[5], 1); err != nil {
return nil, fenError("fullmove number: %v", err)
}
if p.attacked(p.kingSquare(p.turn.Other()), p.turn) {
return nil, fenError("side not to move is in check")
}
return p, nil
}
func parseCounter(s string, minimum int) (int, error) {
if len(s) > 5 {
return 0, errors.New("out of range")
}
n, err := strconv.Atoi(s)
if err != nil || n < minimum {
return 0, errors.New("out of range")
}
return n, nil
}
// castlingSupported returns the rights whose king and rook stand on their
// original squares.
func (p *Position) castlingSupported() Castling {
var c Castling
wk, bk := MakePiece(White, King), MakePiece(Black, King)
wr, br := MakePiece(White, Rook), MakePiece(Black, Rook)
if p.board[sqE1] == wk {
if p.board[sqH1] == wr {
c |= WhiteKingside
}
if p.board[sqA1] == wr {
c |= WhiteQueenside
}
}
if p.board[sqE8] == bk {
if p.board[sqH8] == br {
c |= BlackKingside
}
if p.board[sqA8] == br {
c |= BlackQueenside
}
}
return c
}
// epPlausible reports whether the opponent's last move could have been a
// double pawn step past sq.
func (p *Position) epPlausible(sq Square) bool {
them := p.turn.Other()
var pawnRank, targetRank, originRank int
if them == White {
targetRank, pawnRank, originRank = 2, 3, 1
} else {
targetRank, pawnRank, originRank = 5, 4, 6
}
if sq.Rank() != targetRank {
return false
}
pawnSq := MakeSquare(sq.File(), pawnRank)
origin := MakeSquare(sq.File(), originRank)
return p.board[pawnSq] == MakePiece(them, Pawn) && p.board[sq] == NoPiece && p.board[origin] == NoPiece
}
// FEN serializes the position. The en-passant square is printed only when
// an en-passant capture is legal, so equal positions have equal FENs
// regardless of which convention produced the input.
func (p *Position) FEN() string {
var b strings.Builder
b.Grow(90)
for rank := 7; rank >= 0; rank-- {
empty := 0
for file := 0; file < 8; file++ {
piece := p.board[MakeSquare(file, rank)]
if piece == NoPiece {
empty++
continue
}
if empty > 0 {
b.WriteByte(byte('0' + empty))
empty = 0
}
b.WriteByte(piece.Letter())
}
if empty > 0 {
b.WriteByte(byte('0' + empty))
}
if rank > 0 {
b.WriteByte('/')
}
}
b.WriteByte(' ')
b.WriteString(p.turn.String())
b.WriteByte(' ')
b.WriteString(p.castlingString())
b.WriteByte(' ')
b.WriteString(p.legalEPSquare().String())
b.WriteByte(' ')
b.WriteString(strconv.Itoa(p.halfmove))
b.WriteByte(' ')
b.WriteString(strconv.Itoa(p.fullmove))
return b.String()
}
// Key identifies a position for repetition: placement, side to move,
// castling rights, and a legal en-passant square.
func (p *Position) Key() string {
fen := p.FEN()
// Drop the two counters.
for i, n := len(fen)-1, 0; i >= 0; i-- {
if fen[i] == ' ' {
n++
if n == 2 {
return fen[:i]
}
}
}
return fen
}
func (p *Position) castlingString() string {
if p.castling == 0 {
return "-"
}
var b []byte
for _, r := range []struct {
right Castling
letter byte
}{{WhiteKingside, 'K'}, {WhiteQueenside, 'Q'}, {BlackKingside, 'k'}, {BlackQueenside, 'q'}} {
if p.castling&r.right != 0 {
b = append(b, r.letter)
}
}
return string(b)
}
// Turn returns the side to move.
func (p *Position) Turn() Color { return p.turn }
// Fullmove returns the FEN fullmove number.
func (p *Position) Fullmove() int { return p.fullmove }
// Halfmove returns the halfmove clock for the fifty-move rule.
func (p *Position) Halfmove() int { return p.halfmove }
// PieceAt returns the piece on a square.
func (p *Position) PieceAt(s Square) Piece {
if s < 0 || s > 63 {
return NoPiece
}
return p.board[s]
}
// NormalizeFEN parses and re-serializes a FEN, so FENs that differ only in
// en-passant convention or castling-right order compare equal.
func NormalizeFEN(fen string) (string, error) {
p, err := ParseFEN(fen)
if err != nil {
return "", err
}
return p.FEN(), nil
}
// ASCII renders the board from White's side: files on the first and last
// lines, ranks at both ends of each row, "." for an empty square.
func (p *Position) ASCII() string {
var b strings.Builder
b.Grow(200)
b.WriteString(" a b c d e f g h\n")
for rank := 7; rank >= 0; rank-- {
b.WriteByte(byte('1' + rank))
b.WriteByte(' ')
for file := 0; file < 8; file++ {
if piece := p.board[MakeSquare(file, rank)]; piece != NoPiece {
b.WriteByte(piece.Letter())
} else {
b.WriteByte('.')
}
b.WriteByte(' ')
}
b.WriteByte(byte('1' + rank))
b.WriteByte('\n')
}
b.WriteString(" a b c d e f g h")
return b.String()
}
+179
View File
@@ -0,0 +1,179 @@
package chess
import (
"fmt"
"strings"
)
// maxSANLength bounds parser input; the longest SAN is "Qa1xb2+" plus
// annotation glyphs.
const maxSANLength = 16
// SAN returns Standard Algebraic Notation for a legal move, with "+" or "#".
func (p *Position) SAN(m Move) string {
return p.san(m, p.LegalMoves())
}
func (p *Position) san(m Move, legal []Move) string {
var b strings.Builder
piece := p.board[m.From]
switch {
case piece.Kind() == King && m.To-m.From == 2:
b.WriteString("O-O")
case piece.Kind() == King && m.From-m.To == 2:
b.WriteString("O-O-O")
case piece.Kind() == Pawn:
if m.From.File() != m.To.File() {
b.WriteByte(byte('a' + m.From.File()))
b.WriteByte('x')
}
b.WriteString(m.To.String())
if m.Promotion != NoKind {
b.WriteByte('=')
b.WriteByte(kindLetters[m.Promotion])
}
default:
b.WriteByte(kindLetters[piece.Kind()])
b.WriteString(p.disambiguation(m, legal))
if p.board[m.To] != NoPiece {
b.WriteByte('x')
}
b.WriteString(m.To.String())
}
next := p.play(m)
if next.InCheck() {
if next.HasLegalMoves() {
b.WriteByte('+')
} else {
b.WriteByte('#')
}
}
return b.String()
}
// disambiguation returns the file, rank, or square needed to tell m apart
// from other legal moves of the same piece kind to the same square.
func (p *Position) disambiguation(m Move, legal []Move) string {
kind := p.board[m.From].Kind()
ambiguous, sameFile, sameRank := false, false, false
for _, other := range legal {
if other.To != m.To || other.From == m.From || p.board[other.From].Kind() != kind {
continue
}
ambiguous = true
if other.From.File() == m.From.File() {
sameFile = true
}
if other.From.Rank() == m.From.Rank() {
sameRank = true
}
}
switch {
case !ambiguous:
return ""
case !sameFile:
return string(byte('a' + m.From.File()))
case !sameRank:
return string(byte('1' + m.From.Rank()))
default:
return m.From.String()
}
}
// ParseSAN resolves a SAN move against the legal moves. It accepts check and
// annotation suffixes, "0-0" castling, promotion with or without "=", and
// redundant disambiguation.
func (p *Position) ParseSAN(s string) (Move, error) {
if len(s) == 0 || len(s) > maxSANLength {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
text := strings.TrimRight(s, "+#!?")
legal := p.LegalMoves()
switch text {
case "O-O", "0-0", "O-O-O", "0-0-0":
kingFrom, delta := sqE1, Square(2)
if p.turn == Black {
kingFrom = sqE8
}
if len(text) == 5 {
delta = -2
}
for _, m := range legal {
if m.From == kingFrom && m.To == kingFrom+delta && p.board[m.From].Kind() == King {
return m, nil
}
}
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
kind := Pawn
if len(text) > 0 && strings.IndexByte("NBRQK", text[0]) >= 0 {
kind = kindOfLetter(text[0])
text = text[1:]
}
promotion := NoKind
if n := len(text); n >= 2 && strings.IndexByte("NBRQ", text[n-1]) >= 0 {
promotion = kindOfLetter(text[n-1])
text = strings.TrimSuffix(text[:n-1], "=")
}
if len(text) < 2 {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
to, err := ParseSquare(text[len(text)-2:])
if err != nil {
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
text = strings.TrimSuffix(text[:len(text)-2], "x")
fromFile, fromRank := -1, -1
for i := 0; i < len(text); i++ {
switch c := text[i]; {
case c >= 'a' && c <= 'h' && fromFile < 0 && fromRank < 0:
fromFile = int(c - 'a')
case c >= '1' && c <= '8' && fromRank < 0:
fromRank = int(c - '1')
default:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
}
}
var found Move
matches := 0
for _, m := range legal {
if m.To != to || m.Promotion != promotion || p.board[m.From].Kind() != kind {
continue
}
if fromFile >= 0 && m.From.File() != fromFile || fromRank >= 0 && m.From.Rank() != fromRank {
continue
}
found = m
matches++
}
switch matches {
case 1:
return found, nil
case 0:
return Move{}, fmt.Errorf("%w: %q", ErrIllegalMove, s)
default:
return Move{}, fmt.Errorf("%w: ambiguous %q", ErrIllegalMove, s)
}
}
func kindOfLetter(c byte) Kind {
switch c {
case 'N':
return Knight
case 'B':
return Bishop
case 'R':
return Rook
case 'Q':
return Queen
case 'K':
return King
}
return NoKind
}
+102
View File
@@ -0,0 +1,102 @@
package chess
import "strconv"
// InsufficientMaterial reports a dead position by material alone: bare
// kings, a single minor piece, or only bishops, all on squares of one color.
// Two knights are not included: mate is possible with the loser's help.
func (p *Position) InsufficientMaterial() bool {
minors := 0
bishopColors := [2]bool{}
knights := 0
for s := Square(0); s < 64; s++ {
switch p.board[s].Kind() {
case Pawn, Rook, Queen:
return false
case Knight:
minors++
knights++
case Bishop:
minors++
bishopColors[(s.File()+s.Rank())&1] = true
}
}
if minors <= 1 {
return true
}
return knights == 0 && !(bishopColors[0] && bishopColors[1])
}
// Repetitions counts how often the last of positions occurs in the line,
// itself included. positions[i] is the position after i plies of one game.
// Only positions since the last capture or pawn move can repeat, and only
// every other ply has the same side to move, so at most halfmove/2 earlier
// positions are compared.
func Repetitions(positions []*Position) int {
last := len(positions) - 1
if last < 0 {
return 0
}
key := positions[last].Key()
count := 1
for i := last - 2; i >= 0 && last-i <= positions[last].halfmove; i -= 2 {
if positions[i].Key() == key {
count++
}
}
return count
}
// Line is a replayed game: the positions before and after every ply.
type Line struct {
Start *Position
Moves []Move
SAN []string
positions []*Position // positions[i] is the position after i plies
}
// Replay applies UCI moves from a starting FEN. On an illegal move it
// returns the line up to that ply together with the error.
func Replay(startFEN string, uci []string) (*Line, error) {
start, err := ParseFEN(startFEN)
if err != nil {
return nil, err
}
line := &Line{
Start: start,
Moves: make([]Move, 0, len(uci)),
SAN: make([]string, 0, len(uci)),
positions: append(make([]*Position, 0, len(uci)+1), start),
}
pos := start
for i, text := range uci {
legal := pos.LegalMoves()
m, err := pos.parseUCI(text, legal)
if err != nil {
return line, &PlyError{Ply: i + 1, Err: err}
}
line.SAN = append(line.SAN, pos.san(m, legal))
line.Moves = append(line.Moves, m)
pos = pos.Play(m)
line.positions = append(line.positions, pos)
}
return line, nil
}
// PlyError locates a replay failure; Ply counts from 1.
type PlyError struct {
Ply int
Err error
}
func (e *PlyError) Error() string { return "ply " + strconv.Itoa(e.Ply) + ": " + e.Err.Error() }
func (e *PlyError) Unwrap() error { return e.Err }
// Position returns the position after ply plies (0 is the start).
func (l *Line) Position(ply int) *Position { return l.positions[ply] }
// Final returns the position after the last replayed ply.
func (l *Line) Final() *Position { return l.positions[len(l.positions)-1] }
// Repetitions counts occurrences of the position after ply, itself included.
func (l *Line) Repetitions(ply int) int { return Repetitions(l.positions[:ply+1]) }
+159
View File
@@ -0,0 +1,159 @@
package chess
import (
"bufio"
"io"
"math/rand/v2"
"os/exec"
"slices"
"strings"
"testing"
"time"
)
// stockfish drives a Stockfish process for cross-checks.
type stockfish struct {
cmd *exec.Cmd
in io.WriteCloser
out *bufio.Scanner
}
func startStockfish(t *testing.T) *stockfish {
t.Helper()
path, err := exec.LookPath("stockfish")
if err != nil {
if _, statErr := exec.LookPath("/usr/games/stockfish"); statErr != nil {
t.Skip("stockfish not found; skipping engine cross-check")
}
path = "/usr/games/stockfish"
}
cmd := exec.Command(path)
in, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
outPipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
sf := &stockfish{cmd: cmd, in: in, out: bufio.NewScanner(outPipe)}
t.Cleanup(func() {
io.WriteString(in, "quit\n")
done := make(chan struct{})
go func() { cmd.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
cmd.Process.Kill()
}
})
sf.send(t, "uci")
sf.until(t, "uciok", nil)
return sf
}
func (sf *stockfish) send(t *testing.T, cmd string) {
t.Helper()
if _, err := io.WriteString(sf.in, cmd+"\n"); err != nil {
t.Fatal(err)
}
}
func (sf *stockfish) until(t *testing.T, prefix string, visit func(string)) string {
t.Helper()
for sf.out.Scan() {
line := sf.out.Text()
if visit != nil {
visit(line)
}
if strings.HasPrefix(line, prefix) {
return line
}
}
t.Fatalf("stockfish closed before %q: %v", prefix, sf.out.Err())
return ""
}
// position returns Stockfish's FEN and sorted legal moves (from perft 1).
func (sf *stockfish) position(t *testing.T, fen string) (string, []string) {
t.Helper()
sf.send(t, "position fen "+fen)
sf.send(t, "d")
var sfFEN string
sf.until(t, "Checkers:", func(l string) {
if s, ok := strings.CutPrefix(l, "Fen: "); ok {
sfFEN = s
}
})
sf.send(t, "go perft 1")
var moves []string
sf.until(t, "Nodes searched:", func(l string) {
if uci, count, ok := strings.Cut(l, ": "); ok && count == "1" && len(uci) >= 4 && len(uci) <= 5 {
moves = append(moves, uci)
}
})
slices.Sort(moves)
return sfFEN, moves
}
// TestAgainstStockfish plays random legal games and compares, at every ply,
// the legal move set with Stockfish's perft and the FEN after each move with
// Stockfish's own (normalized for its en-passant convention).
func TestAgainstStockfish(t *testing.T) {
sf := startStockfish(t)
games, maxPlies := 40, 160
if testing.Short() {
games = 5
}
rng := rand.New(rand.NewPCG(20260927, 1))
starts := []string{StartFEN}
for _, tc := range perftCases[1:] {
starts = append(starts, tc.fen)
}
checked := 0
for g := 0; g < games; g++ {
pos, err := ParseFEN(starts[g%len(starts)])
if err != nil {
t.Fatal(err)
}
for ply := 0; ply < maxPlies; ply++ {
fen := pos.FEN()
sfFEN, sfMoves := sf.position(t, fen)
normalized, err := NormalizeFEN(sfFEN)
if err != nil {
t.Fatalf("stockfish FEN %q: %v", sfFEN, err)
}
if normalized != fen {
t.Fatalf("game %d ply %d: FEN %q, stockfish %q", g, ply, fen, sfFEN)
}
legal := pos.LegalMoves()
ours := make([]string, len(legal))
for i, m := range legal {
ours[i] = m.UCI()
}
slices.Sort(ours)
if !slices.Equal(ours, sfMoves) {
t.Fatalf("game %d ply %d %s:\n ours %v\n sf %v", g, ply, fen, ours, sfMoves)
}
checked++
if len(legal) == 0 {
break
}
// Prefer captures and promotions now and then to reach endgames.
m := legal[rng.IntN(len(legal))]
if rng.IntN(3) == 0 {
for _, c := range legal {
if pos.board[c.To] != NoPiece || c.Promotion != NoKind {
m = c
break
}
}
}
pos = pos.Play(m)
}
}
t.Logf("compared %d positions with stockfish", checked)
}
+41 -7
View File
@@ -19,20 +19,40 @@ type MoveRequest struct {
Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves
}
// ResignRequest ends the game in the opponent's favor. Color may be omitted
// when the caller controls exactly one human side.
type ResignRequest struct {
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
}
// DrawRequest offers, accepts, or declines a draw by agreement. Offering to a
// computer opponent is answered at once.
type DrawRequest struct {
Action string `json:"action" validate:"required,oneof=offer accept decline"`
Color string `json:"color,omitempty" validate:"omitempty,oneof=w b white black"`
}
type UndoRequest struct {
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
}
// Response types
// GameResponse is the live game. Termination names how a finished game ended;
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
// reports what a draw request did ("offered", "accepted", "declined") and is
// set only on responses to that request.
type GameResponse struct {
GameID string `json:"gameId"`
FEN string `json:"fen"`
Turn string `json:"turn"` // "w" or "b"
State string `json:"state"` // "ongoing", "white wins", etc
Moves []string `json:"moves"`
Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"`
GameID string `json:"gameId"`
FEN string `json:"fen"`
Turn string `json:"turn"` // "w" or "b"
State string `json:"state"` // "ongoing", "white wins", etc
Termination string `json:"termination,omitempty"`
DrawOffer string `json:"drawOffer,omitempty"`
DrawOutcome string `json:"drawOutcome,omitempty"`
Moves []string `json:"moves"`
Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"`
}
type MoveInfo struct {
@@ -50,19 +70,28 @@ type BoardResponse struct {
// GameHistoryResponse is the durable replay representation of a game. Moves
// are ordered and include the resulting FEN so clients do not need an engine
// to replay a stored game.
//
// PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and
// Termination names how a result was reached (see core.Termination; omitted
// while the game is unfinished).
type GameHistoryResponse struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
Termination string `json:"termination,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"`
}
// HistoryMove is one ply. SAN is derived on read and omitted only when the
// stored move cannot be notated.
type HistoryMove struct {
MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"`
SAN string `json:"san,omitempty"`
FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"`
@@ -76,17 +105,22 @@ type GameSummary struct {
InitialFEN string `json:"initialFen"`
FinalFEN string `json:"finalFen"`
Result string `json:"result,omitempty"`
PGNResult string `json:"pgnResult"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"`
}
// GameListResponse pages newest first. NextCursor continues the listing and
// stays stable while new games are added; NextOffset is kept for clients
// that page by position and is omitted on cursor requests.
type GameListResponse struct {
Games []GameSummary `json:"games"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type ErrorResponse struct {
+42
View File
@@ -59,3 +59,45 @@ func (s State) Result() (string, bool) {
return "", false
}
}
// Termination names how a game reached its result.
type Termination string
const (
TermNone Termination = ""
TermCheckmate Termination = "checkmate"
TermStalemate Termination = "stalemate"
TermInsufficientMaterial Termination = "insufficient_material"
TermThreefoldRepetition Termination = "threefold_repetition"
TermFiftyMoveRule Termination = "fifty_move_rule"
TermAgreement Termination = "agreement"
TermResignation Termination = "resignation"
)
// ValidFor reports whether a termination can produce the terminal state:
// wins come from checkmate or resignation, stalemate from stalemate, and
// draws from the draw rules or agreement.
func (t Termination) ValidFor(s State) bool {
switch s {
case StateWhiteWins, StateBlackWins:
return t == TermCheckmate || t == TermResignation
case StateStalemate:
return t == TermStalemate
case StateDraw:
return t == TermInsufficientMaterial || t == TermThreefoldRepetition ||
t == TermFiftyMoveRule || t == TermAgreement
default:
return t == TermNone
}
}
// ParseColor accepts "w", "b", "white", or "black"; anything else is zero.
func ParseColor(s string) Color {
switch s {
case "w", "white":
return ColorWhite
case "b", "black":
return ColorBlack
}
return 0
}
+94 -9
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"chess/internal/server/board"
"chess/internal/server/chess"
"chess/internal/server/core"
)
@@ -29,9 +29,13 @@ type Game struct {
snapshots []Snapshot
players map[core.Color]*core.Player
state core.State
termination core.Termination
lastResult *MoveResult
endTimeUTC *time.Time
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
drawOffer core.Color
// offerPly[c] is the ply count when color c last offered a draw, or -1.
offerPly map[core.Color]int
}
// View is an immutable copy of the state needed by processors and transports.
@@ -42,11 +46,16 @@ type View struct {
InitialFEN string
NextTurnColor core.Color
Moves []string
WhitePlayer *core.Player
BlackPlayer *core.Player
State core.State
LastResult *MoveResult
EndTimeUTC *time.Time
// FENs holds the position before the first move and after every move.
FENs []string
WhitePlayer *core.Player
BlackPlayer *core.Player
State core.State
Termination core.Termination
DrawOffer core.Color // color whose offer awaits an answer; 0 when none
OfferPly map[core.Color]int
LastResult *MoveResult
EndTimeUTC *time.Time
}
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
@@ -77,7 +86,8 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC
core.ColorWhite: &whiteCopy,
core.ColorBlack: &blackCopy,
},
state: core.StateOngoing,
state: core.StateOngoing,
offerPly: map[core.Color]int{core.ColorWhite: -1, core.ColorBlack: -1},
}
}
@@ -87,7 +97,17 @@ func (g *Game) View() View {
InitialFEN: g.InitialFEN(),
NextTurnColor: g.NextTurnColor(),
Moves: g.Moves(),
FENs: make([]string, len(g.snapshots)),
State: g.state,
Termination: g.termination,
DrawOffer: g.drawOffer,
OfferPly: map[core.Color]int{
core.ColorWhite: g.offerPly[core.ColorWhite],
core.ColorBlack: g.offerPly[core.ColorBlack],
},
}
for i, snapshot := range g.snapshots {
view.FENs[i] = snapshot.FEN
}
if player := g.players[core.ColorWhite]; player != nil {
copy := *player
@@ -156,7 +176,13 @@ func (g *Game) GetPlayer(color core.Color) *core.Player {
return g.players[color]
}
// AddSnapshot appends a move. It answers a draw offer made by the other
// side: moving instead of accepting declines it. An offer from the mover
// stands.
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
if g.drawOffer == nextTurnColor {
g.drawOffer = 0
}
// Get the player ID for the next turn
nextPlayer := g.players[nextTurnColor]
g.snapshots = append(g.snapshots, Snapshot{
@@ -195,11 +221,58 @@ func (g *Game) UndoMoves(count int) error {
g.snapshots = g.snapshots[:len(g.snapshots)-count]
g.state = core.StateOngoing // Reset game state when undoing
g.lastResult = nil // Clear last result
g.termination = core.TermNone
g.lastResult = nil // Clear last result
g.endTimeUTC = nil
g.ClearDrawOffers()
return nil
}
// Plies returns the number of moves played.
func (g *Game) Plies() int {
return len(g.snapshots) - 1
}
// DrawOffer returns the color whose draw offer is pending, or 0.
func (g *Game) DrawOffer() core.Color {
return g.drawOffer
}
// OfferDraw records a pending offer from color and the ply it was made at.
func (g *Game) OfferDraw(color core.Color) {
g.drawOffer = color
g.offerPly[color] = g.Plies()
}
// LastOfferPly returns the ply count at color's last draw offer, or -1.
func (g *Game) LastOfferPly(color core.Color) int {
return g.offerPly[color]
}
// RecordDeclinedOffer counts an offer that was answered at once (by a
// computer) against its maker's one-offer-per-move allowance.
func (g *Game) RecordDeclinedOffer(color core.Color) {
g.offerPly[color] = g.Plies()
}
// DeclineDraw withdraws a pending offer.
func (g *Game) DeclineDraw() {
g.drawOffer = 0
}
// ClearDrawOffers forgets the pending offer and the per-move allowance, as
// after an undo or a player change.
func (g *Game) ClearDrawOffers() {
g.drawOffer = 0
g.offerPly[core.ColorWhite] = -1
g.offerPly[core.ColorBlack] = -1
}
// Termination returns how a finished game ended.
func (g *Game) Termination() core.Termination {
return g.termination
}
func (g *Game) Moves() []string {
moves := []string{}
for i := 1; i < len(g.snapshots); i++ {
@@ -214,6 +287,18 @@ func (g *Game) State() core.State {
return g.state
}
// SetOutcome sets the state and how it was reached; a non-terminal state
// clears the termination.
func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
g.SetStateAt(s, at)
if s.IsTerminal() {
g.termination = termination
g.drawOffer = 0
} else {
g.termination = core.TermNone
}
}
func (g *Game) SetStateAt(s core.State, at time.Time) {
g.Touch(at)
if s.IsTerminal() {
@@ -261,7 +346,7 @@ func (g *Game) InitialFEN() string {
if len(g.snapshots) > 0 {
return g.snapshots[0].FEN
}
return board.StartingFEN
return chess.StartFEN
}
// ClaimSlot claims a player slot for a user
+161 -21
View File
@@ -1,9 +1,12 @@
package http
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"chess/internal/server/core"
@@ -166,11 +169,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Get("/games/:gameId/history", h.GetGameHistory)
api.Get("/games/:gameId/pgn", h.GetGamePGN)
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign)
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
@@ -508,6 +514,58 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// Resign ends the game in the opponent's favor.
func (h *HTTPHandler) Resign(c *fiber.Ctx) error {
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
return processor.NewResignCommand(gameID, *body.(*core.ResignRequest))
})
}
// Draw offers, accepts, or declines a draw by agreement.
func (h *HTTPHandler) Draw(c *fiber.Ctx) error {
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
return processor.NewDrawCommand(gameID, *body.(*core.DrawRequest))
})
}
// executeWithBody runs a game command built from the validated request body,
// with the caller's user ID for slot authorization.
func (h *HTTPHandler) executeWithBody(c *fiber.Ctx, build func(gameID string, body any) processor.Command) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return invalidGameID(c)
}
body := c.Locals("validatedBody")
if validated, _ := c.Locals("validated").(bool); !validated || body == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected", Code: core.ErrInternalError,
})
}
cmd := build(gameID, body)
cmd.UserID, _ = c.Locals("userID").(string)
resp := h.proc.Execute(cmd)
if !resp.Success {
return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// statusForCode maps processor error codes to HTTP statuses.
func statusForCode(code string) int {
switch code {
case core.ErrGameNotFound:
return fiber.StatusNotFound
case core.ErrUnauthorized:
return fiber.StatusForbidden
case core.ErrConflict:
return fiber.StatusConflict
case core.ErrInternalError:
return fiber.StatusInternalServerError
}
return fiber.StatusBadRequest
}
// DeleteGame unloads a live game while retaining its durable history.
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
@@ -563,34 +621,105 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
return invalidGameID(c)
}
history, err := h.svc.GetGameHistory(gameID)
if err != nil {
switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
})
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError,
})
return storedGameError(c, err)
}
body, err := c.App().Config().JSONEncoder(history)
if err != nil {
return err
}
return sendRevalidated(c, fiber.MIMEApplicationJSON, body)
}
// GetGamePGN exports a stored game as a PGN attachment; ?ply=N stops after N
// plies. Same visibility as the history.
func (h *HTTPHandler) GetGamePGN(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return invalidGameID(c)
}
ply, err := queryInt(c, "ply", -1, 0, 1_000_000)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
pgn, err := h.svc.GetGamePGN(gameID, ply)
if err != nil {
return storedGameError(c, err)
}
// The name is built from a date and the hexadecimal game ID only.
c.Set(fiber.HeaderContentDisposition, `attachment; filename="`+pgn.Filename+`"`)
return sendRevalidated(c, "application/x-chess-pgn; charset=utf-8", []byte(pgn.Text))
}
// sendRevalidated sends a stored-game representation with a strong ETag and
// answers a matching If-None-Match with 304. Stored games still change
// (moves, undo after a result), so caches must revalidate on every use
// rather than keep a copy for a fixed time.
func sendRevalidated(c *fiber.Ctx, contentType string, body []byte) error {
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
c.Set(fiber.HeaderETag, etag)
c.Set(fiber.HeaderCacheControl, "private, no-cache")
if etagMatches(c.Get(fiber.HeaderIfNoneMatch), etag) {
return c.SendStatus(fiber.StatusNotModified)
}
c.Set(fiber.HeaderContentType, contentType)
return c.Send(body)
}
// etagMatches applies the weak comparison If-None-Match requires (RFC 9110
// section 13.1.2) to a comma-separated header value.
func etagMatches(header, etag string) bool {
for _, candidate := range strings.Split(header, ",") {
candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
if candidate == "*" || candidate == etag {
return true
}
}
return c.JSON(history)
return false
}
func invalidGameID(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
func storedGameError(c *fiber.Ctx, err error) error {
switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
})
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound,
})
case errors.Is(err, service.ErrPlyOutOfRange):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
})
case errors.Is(err, service.ErrNotation):
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "game cannot be exported as PGN", Code: core.ErrInternalError,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError,
})
}
}
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
// pickers. The extra row used to compute nextOffset stays internal.
// pickers. The extra row used to detect a next page stays internal.
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
@@ -612,9 +741,20 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
})
}
games, err := h.svc.GetUserGames(userID, limit, offset)
games, err := h.svc.GetUserGames(userID, service.UserGamesOptions{
Limit: limit,
Offset: offset,
Cursor: c.Query("cursor"),
Color: c.Query("color"),
Status: c.Query("status"),
})
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
switch {
case errors.Is(err, service.ErrInvalidListQuery):
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid list query", Code: core.ErrInvalidRequest, Details: err.Error(),
})
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
+116 -7
View File
@@ -2,13 +2,20 @@ package http
import (
"io"
nethttp "net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/service"
"chess/internal/server/storage"
"chess/internal/server/storage/pgtest"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
@@ -63,7 +70,7 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
}
}
func TestAPIRoutesAreUnversioned(t *testing.T) {
func TestAPIRoutes(t *testing.T) {
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
@@ -74,9 +81,6 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
registered := make(map[string]bool)
for _, route := range app.GetRoutes(true) {
registered[route.Method+" "+route.Path] = true
if strings.HasPrefix(route.Path, "/api/v1") {
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
}
}
for _, want := range []string{
"GET /health",
@@ -87,19 +91,124 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
"POST /api/games",
"GET /api/games/:gameId",
"GET /api/games/:gameId/history",
"GET /api/games/:gameId/pgn",
"POST /api/games/:gameId/moves",
"POST /api/games/:gameId/resign",
"POST /api/games/:gameId/draw",
"GET /api/users/me/games",
} {
if !registered[want] {
t.Errorf("route %s is not registered", want)
}
}
}
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil))
func TestETagMatches(t *testing.T) {
const etag = `"0123abcd"`
for header, want := range map[string]bool{
"": false,
`"0123abcd"`: true,
`W/"0123abcd"`: true,
`"other", "0123abcd"`: true,
`"other",W/"0123abcd" `: true,
"*": true,
`"0123abc"`: false,
`0123abcd`: false,
} {
if got := etagMatches(header, etag); got != want {
t.Errorf("etagMatches(%q) = %v, want %v", header, got, want)
}
}
}
func TestStoredGameEndpoints(t *testing.T) {
store, err := storage.NewStore(pgtest.DSN(t))
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNotFound {
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode)
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { svc.Shutdown(time.Second) })
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
pos, _ := chess.ParseFEN(chess.StartFEN)
for i, uci := range []string{"e2e4", "e7e5"} {
m, err := pos.ParseUCI(uci)
if err != nil {
t.Fatal(err)
}
next := pos.Play(m)
turn := core.ColorWhite
if i == 1 {
turn = core.ColorBlack
}
if err := svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
MoveUCI: uci, NewFEN: next.FEN(), State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
pos = next
}
app := NewFiberApp(nil, svc, Options{DevMode: true})
get := func(path string, header ...string) (*nethttp.Response, string) {
t.Helper()
request := httptest.NewRequest("GET", path, nil)
for i := 0; i+1 < len(header); i += 2 {
request.Header.Set(header[i], header[i+1])
}
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
return response, string(body)
}
response, body := get("/api/games/" + gameID + "/history")
etag := response.Header.Get("ETag")
if response.StatusCode != 200 || etag == "" || response.Header.Get("Cache-Control") != "private, no-cache" ||
!strings.Contains(body, `"san":"e4"`) || !strings.Contains(body, `"pgnResult":"*"`) ||
!strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") {
t.Fatalf("history: %d %v %s", response.StatusCode, response.Header, body)
}
if response, body = get("/api/games/"+gameID+"/history", "If-None-Match", etag); response.StatusCode != 304 || body != "" {
t.Fatalf("revalidated history: %d %q", response.StatusCode, body)
}
response, body = get("/api/games/" + gameID + "/pgn")
if response.StatusCode != 200 || response.Header.Get("Content-Type") != "application/x-chess-pgn; charset=utf-8" ||
!strings.HasPrefix(response.Header.Get("Content-Disposition"), `attachment; filename="chess-`) ||
!strings.HasSuffix(body, "\n\n1. e4 e5 *\n") {
t.Fatalf("pgn: %d %v\n%s", response.StatusCode, response.Header, body)
}
if response, body = get("/api/games/" + gameID + "/pgn?ply=1"); response.StatusCode != 200 ||
!strings.HasSuffix(body, "\n\n1. e4 *\n") ||
!strings.Contains(response.Header.Get("Content-Disposition"), "-ply1.pgn") {
t.Fatalf("pgn ply 1: %d %v\n%s", response.StatusCode, response.Header, body)
}
for path, want := range map[string]int{
"/api/games/" + gameID + "/pgn?ply=3": 400,
"/api/games/" + gameID + "/pgn?ply=-1": 400,
"/api/games/" + gameID + "/pgn?ply=x": 400,
"/api/games/" + uuid.NewString() + "/pgn": 404,
"/api/games/" + uuid.NewString() + "/history": 404,
"/api/games/not-a-uuid/pgn": 400,
} {
if response, body := get(path); response.StatusCode != want {
t.Errorf("GET %s = %d %s, want %d", path, response.StatusCode, body, want)
}
}
}
+4
View File
@@ -36,6 +36,10 @@ func validationMiddleware(c *fiber.Ctx) error {
requestType = &core.MoveRequest{}
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
requestType = &core.UndoRequest{}
case strings.HasSuffix(path, "/resign") && method == fiber.MethodPost:
requestType = &core.ResignRequest{}
case strings.HasSuffix(path, "/draw") && method == fiber.MethodPost:
requestType = &core.DrawRequest{}
default:
return c.Next() // No validation for unknown endpoints
}
+18
View File
@@ -15,6 +15,8 @@ const (
CmdMakeMove
CmdUndoMove
CmdGetBoard
CmdResign
CmdDraw
)
// Command is a unified structure for all processor operations
@@ -84,3 +86,19 @@ func NewGetBoardCommand(gameID string) Command {
GameID: gameID,
}
}
func NewResignCommand(gameID string, req core.ResignRequest) Command {
return Command{
Type: CmdResign,
GameID: gameID,
Args: req,
}
}
func NewDrawCommand(gameID string, req core.DrawRequest) Command {
return Command{
Type: CmdDraw,
GameID: gameID,
Args: req,
}
}
+388 -40
View File
@@ -10,7 +10,7 @@ import (
"time"
"unicode"
"chess/internal/server/board"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/engine"
"chess/internal/server/game"
@@ -19,6 +19,14 @@ import (
const (
minSearchTime = 100
// A computer answers a draw offer only once both sides have made ten
// moves, and accepts when its evaluation, from its own side, is level or
// worse. The evaluation is a short search on the validation engine; the
// one-offer-per-move allowance bounds how often it runs.
drawOfferMinPlies = 20
drawEvalTimeMs = 100
drawAcceptMaxScore = 0 // centipawns
)
// FEN validation regex
@@ -63,6 +71,10 @@ func (p *Processor) Execute(cmd Command) ProcessorResponse {
return p.handleDeleteGame(cmd)
case CmdGetBoard:
return p.handleGetBoard(cmd)
case CmdResign:
return p.handleResign(cmd)
case CmdDraw:
return p.handleDraw(cmd)
default:
return p.errorResponse("unknown command", core.ErrInvalidRequest)
}
@@ -144,28 +156,38 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
gameID := p.svc.GenerateGameID()
// Validate FEN safety, then classify via engine
initialFEN := board.StartingFEN
initialFEN := chess.StartFEN
if args.FEN != "" {
if !p.isFENSafe(args.FEN) {
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
}
initialFEN = args.FEN
// The rules core rejects positions the engine must never see (missing
// kings, the side not to move in check) and drops castling rights
// without their rook, so both agree on the legal moves.
pos, err := chess.ParseFEN(args.FEN)
if err != nil {
return p.errorResponse(err.Error(), core.ErrInvalidFEN)
}
initialFEN = pos.FEN()
}
p.mu.Lock()
err := p.validationEng.NewGame()
var validatedFEN string
initialState := core.StateOngoing
initialState, termination := core.StateOngoing, core.TermNone
if err == nil {
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
validatedFEN, initialState, termination, err = p.classifyLocked(initialFEN)
}
p.mu.Unlock()
if err != nil {
return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError)
}
if initialState == core.StateOngoing {
initialState, termination = adjudicateDraw(nil, validatedFEN)
}
// Parse canonical FEN to get starting turn
b, err := board.ParseFEN(validatedFEN)
start, err := chess.ParseFEN(validatedFEN)
if err != nil {
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
}
@@ -186,7 +208,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
}
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN,
coreColor(start.Turn()), initialState, termination); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
}
@@ -308,7 +331,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
}
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
p.svc.UpdateGameState(cmd.GameID, core.StateStuck, core.TermNone)
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
}
@@ -351,27 +374,38 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
currentFEN := g.FEN
// A pawn move to the last rank needs its promotion piece; say so instead
// of the engine's generic rejection.
rules := rulesCheck(currentFEN, move)
if errors.Is(rules.err, chess.ErrPromotionRequired) {
return p.errorResponse("promotion piece required: append q, r, b, or n (e.g. "+move+"q)", core.ErrInvalidMove)
}
// Validate move and classify the resulting position in one engine session
p.mu.Lock()
err = p.validationEng.SetPosition(currentFEN, []string{move})
var newFEN string
finalState := core.StateOngoing
finalState, termination := core.StateOngoing, core.TermNone
if err == nil {
newFEN, finalState, err = p.classifyCurrentLocked()
newFEN, finalState, termination, err = p.classifyCurrentLocked()
}
p.mu.Unlock()
if err != nil {
// Game untouched at pre-move position; retry runs on a respawned engine
return p.errorResponse("engine unavailable", core.ErrInternalError)
}
rules.compare(cmd.GameID, move, newFEN != currentFEN, newFEN)
if newFEN == currentFEN {
return p.errorResponse("illegal move", core.ErrInvalidMove)
}
if finalState == core.StateOngoing {
finalState, termination = adjudicateDraw(g.FENs, newFEN)
}
// Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState, Termination: termination,
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
}); err != nil {
if errors.Is(err, service.ErrSlotOwner) {
@@ -455,11 +489,11 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
b, err := board.ParseFEN(g.FEN)
pos, err := chess.ParseFEN(g.FEN)
if err != nil {
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
}
ascii := b.ToASCII()
ascii := pos.ASCII()
return ProcessorResponse{
Success: true,
@@ -486,38 +520,42 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
}
if result.Error != nil {
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
return
}
if result.Move == "" || result.Move == "(none)" {
// Worker says no legal moves; verify against the validation engine.
p.mu.Lock()
_, state, cerr := p.classifyLocked(fen)
_, state, termination, cerr := p.classifyLocked(fen)
p.mu.Unlock()
if cerr != nil || state == core.StateOngoing {
p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone) // engines disagree
return
}
p.svc.UpdateGameState(gameID, state)
p.svc.UpdateGameState(gameID, state, termination)
return
}
p.mu.Lock()
aerr := p.validationEng.SetPosition(fen, []string{result.Move})
var newFEN string
finalState := core.StateOngoing
finalState, termination := core.StateOngoing, core.TermNone
if aerr == nil {
newFEN, finalState, aerr = p.classifyCurrentLocked()
newFEN, finalState, termination, aerr = p.classifyCurrentLocked()
}
p.mu.Unlock()
if aerr != nil || newFEN == fen {
p.svc.UpdateGameState(gameID, core.StateStuck)
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
return
}
rulesCheck(fen, result.Move).compare(gameID, result.Move, true, newFEN)
if finalState == core.StateOngoing {
finalState, termination = adjudicateDraw(currentGame.FENs, newFEN)
}
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState, Termination: termination,
Result: &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
@@ -525,60 +563,106 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
}); err != nil {
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
p.svc.UpdateGameState(gameID, core.StateStuck)
p.svc.UpdateGameState(gameID, core.StateStuck, core.TermNone)
}
}
})
}
// classifyCurrentLocked classifies whatever position is loaded in the
// validation engine. Caller holds p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
// validation engine: mate and stalemate come from the engine. Caller holds
// p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, termination core.Termination, err error) {
diag, err := p.validationEng.Diagnose()
if err != nil {
return "", core.StateOngoing, err
return "", core.StateOngoing, core.TermNone, err
}
legal, err := p.validationEng.HasLegalMoves()
if err != nil {
return "", core.StateOngoing, err
return "", core.StateOngoing, core.TermNone, err
}
if legal {
return diag.FEN, core.StateOngoing, nil
return diag.FEN, core.StateOngoing, core.TermNone, nil
}
if !diag.InCheck {
return diag.FEN, core.StateStalemate, nil
return diag.FEN, core.StateStalemate, core.TermStalemate, nil
}
b, err := board.ParseFEN(diag.FEN)
pos, err := chess.ParseFEN(diag.FEN)
if err != nil {
return "", core.StateOngoing, err
return "", core.StateOngoing, core.TermNone, err
}
if b.Turn() == core.ColorWhite {
return diag.FEN, core.StateBlackWins, nil
if pos.Turn() == chess.White {
return diag.FEN, core.StateBlackWins, core.TermCheckmate, nil
}
return diag.FEN, core.StateWhiteWins, nil
return diag.FEN, core.StateWhiteWins, core.TermCheckmate, nil
}
// classifyLocked sets a position from fen and classifies it. Caller holds p.mu.
func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
func (p *Processor) classifyLocked(fen string) (string, core.State, core.Termination, error) {
if err := p.validationEng.SetPosition(fen, nil); err != nil {
return "", core.StateOngoing, err
return "", core.StateOngoing, core.TermNone, err
}
return p.classifyCurrentLocked()
}
// adjudicateDraw applies the draw rules that end a game without a claim, as
// most online servers do: dead material, threefold repetition, and the
// fifty-move rule (a claimable draw under FIDE rules). history holds the
// positions before fen, oldest first. It returns StateOngoing when no rule
// applies. The engine has already ruled out mate and stalemate, which take
// precedence.
func adjudicateDraw(history []string, fen string) (core.State, core.Termination) {
pos, err := chess.ParseFEN(fen)
if err != nil {
return core.StateOngoing, core.TermNone
}
if pos.InsufficientMaterial() {
return core.StateDraw, core.TermInsufficientMaterial
}
// Positions before the last capture or pawn move cannot repeat.
window := min(len(history), pos.Halfmove())
line := make([]*chess.Position, 0, window+1)
for _, prior := range history[len(history)-window:] {
prev, err := chess.ParseFEN(prior)
if err != nil {
line = line[:0] // a gap cannot be bridged; count from here on
continue
}
line = append(line, prev)
}
if chess.Repetitions(append(line, pos)) >= 3 {
return core.StateDraw, core.TermThreefoldRepetition
}
if pos.Halfmove() >= 100 {
return core.StateDraw, core.TermFiftyMoveRule
}
return core.StateOngoing, core.TermNone
}
func coreColor(c chess.Color) core.Color {
if c == chess.White {
return core.ColorWhite
}
return core.ColorBlack
}
// buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
resp := core.GameResponse{
GameID: gameID,
FEN: g.FEN,
Turn: g.NextTurnColor.String(),
State: g.State.String(),
Moves: g.Moves,
GameID: gameID,
FEN: g.FEN,
Turn: g.NextTurnColor.String(),
State: g.State.String(),
Termination: string(g.Termination),
Moves: g.Moves,
Players: core.PlayersResponse{
White: g.WhitePlayer,
Black: g.BlackPlayer,
},
}
if g.DrawOffer != 0 {
resp.DrawOffer = g.DrawOffer.String()
}
// Include last move if available
if result := g.LastResult; result != nil {
@@ -612,3 +696,267 @@ func (p *Processor) Close() error {
p.mu.Unlock()
return errors.Join(queueErr, engineErr)
}
// rulesResult is the rules core's verdict on a move, computed alongside the
// engine's. Stockfish stays authoritative; a disagreement is logged so the
// core can be trusted, or fixed, before it validates anything.
type rulesResult struct {
fen string // normalized FEN after the move; empty when illegal
err error
}
func rulesCheck(fen, uci string) rulesResult {
pos, err := chess.ParseFEN(fen)
if err != nil {
return rulesResult{err: err}
}
m, err := pos.ParseUCI(uci)
if err != nil {
return rulesResult{err: err}
}
return rulesResult{fen: pos.Play(m).FEN()}
}
func (r rulesResult) compare(gameID, uci string, engineLegal bool, engineFEN string) {
if errors.Is(r.err, chess.ErrInvalidFEN) {
slog.Warn("rules core cannot parse game position", "game_id", gameID, "error", r.err)
return
}
if engineLegal != (r.err == nil) {
slog.Warn("rules core and engine disagree on legality",
"game_id", gameID, "move", uci, "engine_legal", engineLegal, "rules_error", r.err)
return
}
if !engineLegal {
return
}
if normalized, err := chess.NormalizeFEN(engineFEN); err != nil || normalized != r.fen {
slog.Warn("rules core and engine disagree on resulting position",
"game_id", gameID, "move", uci, "engine_fen", engineFEN, "rules_fen", r.fen)
}
}
// handleResign ends the game in the opponent's favor. It is allowed while the
// computer is thinking or the engine is stuck: resigning never needs one.
func (p *Processor) handleResign(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.ResignRequest)
if !ok {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
if g.State.IsTerminal() {
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
}
color, failure := actingColor(g, args.Color, cmd.UserID)
if failure != nil {
return *failure
}
winner := core.StateWhiteWins
if color == core.ColorWhite {
winner = core.StateBlackWins
}
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: winner, Termination: core.TermResignation,
})
if err != nil {
return p.endGameError(err)
}
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{Success: true, Data: p.buildGameResponse(cmd.GameID, g)}
}
// handleDraw offers, accepts, or declines a draw by agreement. An offer to a
// human stands until answered, or until the opponent moves instead; an offer
// to a computer is answered at once. Offering while the opponent's offer
// stands accepts it.
func (p *Processor) handleDraw(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.DrawRequest)
if !ok {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
switch {
case g.State.IsTerminal():
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
case g.State != core.StateOngoing:
return p.errorResponse("draws can be agreed only while no computer move is running", core.ErrInvalidRequest)
}
color, failure := actingColor(g, args.Color, cmd.UserID)
if failure != nil {
return *failure
}
opponent := core.OppositeColor(color)
accept := func() ProcessorResponse {
err := p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: opponent,
})
if err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "accepted")
}
switch args.Action {
case "accept":
if g.DrawOffer != opponent {
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
}
return accept()
case "decline":
if err := p.svc.DeclineDraw(cmd.GameID, color, cmd.UserID); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "declined")
}
// Offer.
switch {
case g.DrawOffer == opponent:
return accept()
case g.DrawOffer == color:
return p.drawResponse(cmd.GameID, "offered")
}
if last := g.OfferPly[color]; last >= 0 && len(g.Moves) < last+2 {
return p.errorResponse(service.ErrOfferLimit.Error(), core.ErrConflict)
}
if player := g.Player(opponent); player != nil && player.Type == core.PlayerComputer {
agrees := false
if len(g.Moves) >= drawOfferMinPlies {
if agrees, err = p.computerAcceptsDraw(g.FEN, opponent); err != nil {
return p.errorResponse("engine unavailable", core.ErrInternalError)
}
}
if agrees {
err = p.svc.EndGame(cmd.GameID, service.EndCommit{
ExpectedFEN: g.FEN, Color: color, ActorUserID: cmd.UserID,
State: core.StateDraw, Termination: core.TermAgreement,
})
if err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "accepted")
}
if err := p.svc.RecordDeclinedOffer(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "declined")
}
if err := p.svc.OfferDraw(cmd.GameID, color, cmd.UserID, g.FEN); err != nil {
return p.endGameError(err)
}
return p.drawResponse(cmd.GameID, "offered")
}
func (p *Processor) drawResponse(gameID, outcome string) ProcessorResponse {
g, err := p.svc.GetGameView(gameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
response := p.buildGameResponse(gameID, g)
response.DrawOutcome = outcome
return ProcessorResponse{Success: true, Data: response}
}
// computerAcceptsDraw evaluates fen for the computer playing color with a
// short full-strength search. UCI scores are from the side to move.
func (p *Processor) computerAcceptsDraw(fen string, computer core.Color) (bool, error) {
pos, err := chess.ParseFEN(fen)
if err != nil {
return false, err
}
p.mu.Lock()
defer p.mu.Unlock()
if err := p.validationEng.SetPosition(fen, nil); err != nil {
return false, err
}
result, err := p.validationEng.Search(drawEvalTimeMs)
if err != nil {
return false, err
}
score := result.Score
if coreColor(pos.Turn()) != computer {
score = -score
}
return score <= drawAcceptMaxScore, nil
}
// actingColor resolves which side a resignation or draw request acts for: the
// requested color, else the one side the caller claimed, else the only human
// side. It enforces slot ownership: a claimed side acts only for its claimant.
func actingColor(g game.View, requested, userID string) (core.Color, *ProcessorResponse) {
fail := func(message, code string) (core.Color, *ProcessorResponse) {
return 0, &ProcessorResponse{Error: &core.ErrorResponse{Error: message, Code: code}}
}
var color core.Color
switch {
case requested != "":
color = core.ParseColor(requested)
default:
var claimed, humans []core.Color
for _, c := range []core.Color{core.ColorWhite, core.ColorBlack} {
player := g.Player(c)
if player == nil || player.Type != core.PlayerHuman {
continue
}
humans = append(humans, c)
if userID != "" && player.ClaimedBy == userID {
claimed = append(claimed, c)
}
}
switch {
case len(claimed) == 1:
color = claimed[0]
case len(claimed) == 0 && len(humans) == 1:
color = humans[0]
case len(humans) == 0:
return fail("both sides are played by the computer", core.ErrInvalidRequest)
default:
return fail("color required: both sides are human", core.ErrInvalidRequest)
}
}
player := g.Player(color)
if player == nil {
return fail("invalid color", core.ErrInvalidRequest)
}
if player.Type != core.PlayerHuman {
return fail(service.ErrNotHuman.Error(), core.ErrInvalidRequest)
}
if player.ClaimedBy != "" && player.ClaimedBy != userID {
if userID == "" {
return fail("slot claimed - authentication required", core.ErrUnauthorized)
}
return fail("slot claimed by another player", core.ErrUnauthorized)
}
return color, nil
}
// endGameError maps a service refusal to a response; the service re-checks
// under its lock what the processor checked on a snapshot.
func (p *Processor) endGameError(err error) ProcessorResponse {
switch {
case errors.Is(err, service.ErrGameNotFound):
return p.errorResponse("game not found", core.ErrGameNotFound)
case errors.Is(err, service.ErrGameOver):
return p.errorResponse("game is over", core.ErrGameOver)
case errors.Is(err, service.ErrSlotOwner):
return p.errorResponse("slot claimed by another player", core.ErrUnauthorized)
case errors.Is(err, service.ErrNotHuman):
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
case errors.Is(err, service.ErrNoDrawOffer):
return p.errorResponse("no draw offer from the opponent", core.ErrInvalidRequest)
case errors.Is(err, service.ErrOfferLimit):
return p.errorResponse(err.Error(), core.ErrConflict)
case errors.Is(err, service.ErrGameChanged):
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
}
+97
View File
@@ -0,0 +1,97 @@
package processor
import (
"errors"
"testing"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/game"
)
func TestRulesCheck(t *testing.T) {
const promotion = "8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
if r := rulesCheck(promotion, "g7g8"); !errors.Is(r.err, chess.ErrPromotionRequired) {
t.Errorf("g7g8: error = %v, want ErrPromotionRequired", r.err)
}
if r := rulesCheck(promotion, "g7g8q"); r.err != nil || r.fen != "6Q1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" {
t.Errorf("g7g8q: %+v", r)
}
if r := rulesCheck(chess.StartFEN, "e2e5"); !errors.Is(r.err, chess.ErrIllegalMove) {
t.Errorf("e2e5: error = %v, want ErrIllegalMove", r.err)
}
if r := rulesCheck("not a fen", "e2e4"); !errors.Is(r.err, chess.ErrInvalidFEN) {
t.Errorf("bad FEN: error = %v, want ErrInvalidFEN", r.err)
}
}
func TestAdjudicateDraw(t *testing.T) {
cycle := []string{"g1f3", "g8f6", "f3g1", "f6g8"}
line, err := chess.Replay(chess.StartFEN, append(cycle, cycle...))
if err != nil {
t.Fatal(err)
}
fens := make([]string, 9)
for i := range fens {
fens[i] = line.Position(i).FEN()
}
for name, tc := range map[string]struct {
history []string
fen string
want core.Termination
}{
"start": {nil, chess.StartFEN, core.TermNone},
"bare kings": {nil, "4k3/8/8/8/8/8/8/4K3 w - - 0 1", core.TermInsufficientMaterial},
"knight": {nil, "4k3/8/8/8/8/8/8/3NK3 b - - 3 40", core.TermInsufficientMaterial},
"rook": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 3 40", core.TermNone},
"fifty moves": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 100 90", core.TermFiftyMoveRule},
"forty-nine": {nil, "4k3/8/8/8/8/8/8/R3K3 w - - 99 90", core.TermNone},
"second occurrence": {fens[:4], fens[4], core.TermNone},
"third occurrence": {fens[:8], fens[8], core.TermThreefoldRepetition},
"unparsable": {nil, "not a fen", core.TermNone},
} {
state, termination := adjudicateDraw(tc.history, tc.fen)
if termination != tc.want || (tc.want == core.TermNone) != (state == core.StateOngoing) {
t.Errorf("%s: got %v %q, want %q", name, state, termination, tc.want)
}
}
}
func TestActingColor(t *testing.T) {
human := func(claimedBy string) *core.Player {
return &core.Player{Type: core.PlayerHuman, ClaimedBy: claimedBy}
}
computer := &core.Player{Type: core.PlayerComputer}
view := func(white, black *core.Player) game.View {
return game.View{WhitePlayer: white, BlackPlayer: black}
}
for name, tc := range map[string]struct {
view game.View
requested string
user string
want core.Color
code string
}{
"only human side": {view(human(""), computer), "", "", core.ColorWhite, ""},
"only human side, black": {view(computer, human("")), "", "", core.ColorBlack, ""},
"claimed side of caller": {view(human("u1"), human("")), "", "u1", core.ColorWhite, ""},
"explicit color": {view(human(""), human("")), "b", "", core.ColorBlack, ""},
"explicit long color": {view(human(""), human("")), "white", "", core.ColorWhite, ""},
"ambiguous hot seat": {view(human(""), human("")), "", "", 0, core.ErrInvalidRequest},
"self-play needs color": {view(human("u1"), human("u1")), "", "u1", 0, core.ErrInvalidRequest},
"computer only": {view(computer, computer), "", "", 0, core.ErrInvalidRequest},
"computer side requested": {view(human(""), computer), "b", "", 0, core.ErrInvalidRequest},
"claimed by another": {view(human("u1"), computer), "", "u2", 0, core.ErrUnauthorized},
"claimed, anonymous caller": {view(human("u1"), computer), "", "", 0, core.ErrUnauthorized},
} {
color, failure := actingColor(tc.view, tc.requested, tc.user)
switch {
case tc.code != "" && (failure == nil || failure.Error.Code != tc.code):
t.Errorf("%s: got %v %+v, want error %s", name, color, failure, tc.code)
case tc.code == "" && (failure != nil || color != tc.want):
t.Errorf("%s: got %v %+v, want %v", name, color, failure, tc.want)
}
}
}
+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
}
+245
View File
@@ -0,0 +1,245 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// newHumanGame creates a human-vs-human game at the standard start.
func newHumanGame(t *testing.T, svc *Service) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
return gameID
}
// move commits one legal move for the side to move.
func move(t *testing.T, svc *Service, gameID, actor, uci string) {
t.Helper()
view, err := svc.GetGameView(gameID)
if err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(view.FEN)
if err != nil {
t.Fatal(err)
}
m, err := pos.ParseUCI(uci)
if err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: view.FEN, ExpectedState: core.StateOngoing, ExpectedTurn: view.NextTurnColor,
ActorUserID: actor, MoveUCI: uci, NewFEN: pos.Play(m).FEN(), State: core.StateOngoing,
}); err != nil {
t.Fatal(err)
}
}
func TestResignationClaimsAndPersists(t *testing.T) {
svc := newPersistentTestService(t)
gameID, alice, bob := newHumanGame(t, svc), uuid.NewString(), uuid.NewString()
move(t, svc, gameID, alice, "e2e4") // alice claims white
view, _ := svc.GetGameView(gameID)
resign := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: bob,
State: core.StateBlackWins, Termination: core.TermResignation,
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrSlotOwner) {
t.Fatalf("resigning another user's side: %v, want ErrSlotOwner", err)
}
stale := resign
stale.ActorUserID, stale.ExpectedFEN = alice, chess.StartFEN
if err := svc.EndGame(gameID, stale); !errors.Is(err, ErrGameChanged) {
t.Fatalf("stale position: %v, want ErrGameChanged", err)
}
wrong := resign
wrong.ActorUserID, wrong.Termination = alice, core.TermAgreement
if err := svc.EndGame(gameID, wrong); err == nil {
t.Fatal("a win by agreement was accepted")
}
// Black resigns anonymously; an authenticated resignation would claim it.
resign.Color, resign.ActorUserID, resign.State = core.ColorBlack, bob, core.StateWhiteWins
if err := svc.EndGame(gameID, resign); err != nil {
t.Fatal(err)
}
if err := svc.EndGame(gameID, resign); !errors.Is(err, ErrGameOver) {
t.Fatalf("second resignation: %v, want ErrGameOver", err)
}
view, _ = svc.GetGameView(gameID)
if view.State != core.StateWhiteWins || view.Termination != core.TermResignation ||
view.BlackPlayer.ClaimedBy != bob || view.EndTimeUTC == nil {
t.Fatalf("view after resignation: %+v", view)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "white_wins" || history.Termination != "resignation" ||
history.Players.Black.ClaimedBy != bob || history.PGNResult != "1-0" {
t.Fatalf("history after resignation: %+v", history)
}
// Undo after a result rewinds it (D6), in memory and in storage.
if err := svc.UndoMoves(gameID, 1); err != nil {
t.Fatal(err)
}
if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" {
t.Fatalf("undo kept the resignation: %+v", history)
}
}
func TestDrawOffersBetweenHumans(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
move(t, svc, gameID, "", "e2e4")
view, _ := svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatal(err)
}
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("repeating a standing offer: %v", err)
}
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer not recorded: %+v", view)
}
if err := svc.DeclineDraw(gameID, core.ColorBlack, ""); !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("declining one's own offer: %v", err)
}
if err := svc.DeclineDraw(gameID, core.ColorWhite, ""); err != nil {
t.Fatal(err)
}
// One offer per move: black must move before offering again.
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); !errors.Is(err, ErrOfferLimit) {
t.Fatalf("second offer in one move: %v, want ErrOfferLimit", err)
}
move(t, svc, gameID, "", "e7e5")
move(t, svc, gameID, "", "g1f3")
view, _ = svc.GetGameView(gameID)
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
t.Fatalf("offer after a move: %v", err)
}
// The offerer's own move keeps the offer standing ...
move(t, svc, gameID, "", "b8c6")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != core.ColorBlack {
t.Fatalf("offer lapsed on the offerer's own move: %v", view.DrawOffer)
}
// ... and the recipient's move instead of an answer declines it.
move(t, svc, gameID, "", "f1c4")
if view, _ = svc.GetGameView(gameID); view.DrawOffer != 0 {
t.Fatalf("offer survived the recipient's move: %v", view.DrawOffer)
}
if err := svc.OfferDraw(gameID, core.ColorWhite, "", view.FEN); err != nil {
t.Fatal(err)
}
view, _ = svc.GetGameView(gameID)
accept := EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
}
if err := svc.EndGame(gameID, accept); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
if history.Result != "draw" || history.Termination != "agreement" || history.PGNResult != "1/2-1/2" {
t.Fatalf("history after agreement: %+v", history)
}
}
func TestOfferToMissingOfferIsRejected(t *testing.T) {
svc := newPersistentTestService(t)
gameID := newHumanGame(t, svc)
view, _ := svc.GetGameView(gameID)
err := svc.EndGame(gameID, EndCommit{
ExpectedFEN: view.FEN, Color: core.ColorBlack,
State: core.StateDraw, Termination: core.TermAgreement, OfferFrom: core.ColorWhite,
})
if !errors.Is(err, ErrNoDrawOffer) {
t.Fatalf("accepting without an offer: %v, want ErrNoDrawOffer", err)
}
}
func TestGameWithDeletedRowIsUnloadedNotDegraded(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
gameID := newHumanGame(t, svc)
if _, err := svc.GetGameHistory(gameID); err != nil { // flushes the insert
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
move(t, svc, gameID, "", "e2e4") // accepted in memory; its write finds no row
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := svc.GetGameView(gameID); errors.Is(err, ErrGameNotFound) {
break
}
if time.Now().After(deadline) {
t.Fatal("game with a deleted row is still loaded")
}
time.Sleep(10 * time.Millisecond)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Fatalf("storage health %q after a write for a deleted game", health)
}
}
func TestFinishedGamesDoNotCountAgainstComputerLimit(t *testing.T) {
svc := newPersistentTestService(t)
newComputerGame := func() (string, error) {
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
return gameID, svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
core.StateOngoing, core.TermNone)
}
var games []string
for i := 0; i < MaxComputerGames; i++ {
gameID, err := newComputerGame()
if err != nil {
t.Fatal(err)
}
games = append(games, gameID)
}
if _, err := newComputerGame(); err == nil {
t.Fatal("computer game limit not enforced")
}
// Resigning one frees its slot while the game stays loaded.
if err := svc.EndGame(games[0], EndCommit{
ExpectedFEN: chess.StartFEN, Color: core.ColorWhite,
State: core.StateBlackWins, Termination: core.TermResignation,
}); err != nil {
t.Fatal(err)
}
if got := svc.GetComputerGameCount(); got != MaxComputerGames-1 {
t.Fatalf("active computer games = %d, want %d", got, MaxComputerGames-1)
}
if _, err := newComputerGame(); err != nil {
t.Fatalf("finished game still counted: %v", err)
}
}
+210 -28
View File
@@ -17,6 +17,10 @@ var (
ErrGameNotFound = errors.New("game not found")
ErrGameChanged = errors.New("game changed while move was being validated")
ErrSlotOwner = errors.New("player slot is owned by another user")
ErrGameOver = errors.New("game is over")
ErrNotHuman = errors.New("that side is played by the computer")
ErrNoDrawOffer = errors.New("no draw offer from the opponent")
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again")
)
type MoveCommit struct {
@@ -27,10 +31,24 @@ type MoveCommit struct {
MoveUCI string
NewFEN string
State core.State
Termination core.Termination // how State was reached when terminal
Result *game.MoveResult
At time.Time
}
// EndCommit ends a game without a move: a resignation or a draw by
// agreement. Color is the side acting (resigning or accepting); an
// authenticated actor claims it if unclaimed, as a first move would.
type EndCommit struct {
ExpectedFEN string
Color core.Color
ActorUserID string
State core.State
Termination core.Termination
// OfferFrom, when set, requires that side's draw offer to still stand.
OfferFrom core.Color
}
// CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame(
id string,
@@ -38,6 +56,7 @@ func (s *Service) CreateGame(
initialFEN string,
startingTurn core.Color,
initialState core.State,
termination core.Termination,
) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -48,16 +67,15 @@ func (s *Service) CreateGame(
// Check computer game limit
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if hasComputer {
if s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
if hasComputer && !initialState.IsTerminal() {
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
}
s.computerGames.Add(1)
}
now := time.Now().UTC()
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
g.SetStateAt(initialState, now)
g.SetOutcome(initialState, termination, now)
s.games[id] = g
// Persist if storage enabled
@@ -77,6 +95,7 @@ func (s *Service) CreateGame(
BlackSearchTime: blackPlayer.SearchTime,
BlackClaimedBy: blackPlayer.ClaimedBy,
Result: result,
Termination: string(g.Termination()),
StartTimeUTC: now,
EndTimeUTC: g.EndTimeUTC(),
}
@@ -106,8 +125,10 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
oldBlack := g.GetPlayer(core.ColorBlack)
oldHasComputer := g.HasComputerPlayer()
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
if !oldHasComputer && newHasComputer && !g.State().IsTerminal() {
if active := s.activeComputerGamesLocked(); active >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", active, MaxComputerGames)
}
}
// Player configuration is mutable, but historical user association is not.
@@ -127,14 +148,8 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
}
g.UpdatePlayers(whitePlayer, blackPlayer)
g.ClearDrawOffers() // an offer made to the previous opponent lapses
g.Touch(time.Now().UTC())
if oldHasComputer != newHasComputer {
if newHasComputer {
s.computerGames.Add(1)
} else {
s.computerGames.Add(-1)
}
}
if s.store != nil {
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
if err != nil {
@@ -239,8 +254,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return err
}
}
if commit.State.IsTerminal() && !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
g.SetStateAt(commit.State, at)
g.SetOutcome(commit.State, commit.Termination, at)
if commit.Result != nil {
g.SetLastResult(commit.Result)
}
@@ -252,10 +270,11 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
},
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
EndTimeUTC: g.EndTimeUTC(),
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
Termination: string(g.Termination()),
EndTimeUTC: g.EndTimeUTC(),
}
if claimUserID == "" {
persistence.ClaimColor = ""
@@ -277,8 +296,9 @@ func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
return nil
}
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
func (s *Service) UpdateGameState(gameID string, state core.State) error {
// UpdateGameState sets an operational state (pending, stuck) or a terminal
// state found without a move, such as an engine reporting no legal move.
func (s *Service) UpdateGameState(gameID string, state core.State, termination core.Termination) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -286,13 +306,18 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if state.IsTerminal() && !termination.ValidFor(state) {
return fmt.Errorf("termination %q cannot produce %s", termination, state)
}
previousState := g.State()
now := time.Now().UTC()
g.SetStateAt(state, now)
g.SetOutcome(state, termination, now)
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
result, _ := state.Result()
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
if err := s.store.RecordGameEnd(storage.GameEnd{
GameID: gameID, Result: result, Termination: string(termination), EndTimeUTC: now,
}); err != nil {
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
}
}
@@ -303,6 +328,168 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
return nil
}
// EndGame applies a resignation or an agreed draw. It re-checks, under the
// service lock, everything the processor validated: the game is unfinished
// and unchanged, the acting side is human and the actor may play it, and an
// accepted offer still stands.
func (s *Service) EndGame(gameID string, commit EndCommit) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return ErrGameOver
}
if !commit.State.IsTerminal() || !commit.Termination.ValidFor(commit.State) {
return fmt.Errorf("termination %q cannot produce %s", commit.Termination, commit.State)
}
if g.CurrentFEN() != commit.ExpectedFEN {
return ErrGameChanged
}
if commit.OfferFrom != 0 && g.DrawOffer() != commit.OfferFrom {
return ErrNoDrawOffer
}
claimUserID, err := actorClaim(g, commit.Color, commit.ActorUserID)
if err != nil {
return err
}
now := time.Now().UTC()
if claimUserID != "" {
if err := g.ClaimSlot(commit.Color, claimUserID); err != nil {
return err
}
}
g.SetOutcome(commit.State, commit.Termination, now)
if s.store != nil {
result, _ := commit.State.Result()
end := storage.GameEnd{
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
}
if claimUserID != "" {
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
}
if err := s.store.RecordGameEnd(end); err != nil {
slog.Error("failed to queue game end persistence", "game_id", gameID, "error", err)
}
}
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
slog.Debug("game ended without a move", "game_id", gameID,
"state", commit.State.String(), "termination", commit.Termination, "side", commit.Color.String())
return nil
}
// actorClaim authorizes actor to act for color and returns the user ID to
// record as the slot's claim, empty when nothing is to be claimed.
func actorClaim(g *game.Game, color core.Color, actor string) (string, error) {
player := g.GetPlayer(color)
if player == nil {
return "", errors.New("invalid color")
}
if player.Type != core.PlayerHuman {
return "", ErrNotHuman
}
owner := g.GetSlotOwner(color)
switch {
case owner != "" && owner != actor:
return "", ErrSlotOwner
case owner == "" && actor != "":
return actor, nil
}
return "", nil
}
// OfferDraw records color's offer for a human opponent to answer. Repeating
// a standing offer is a no-op; a new offer needs a move since the last one.
func (s *Service) OfferDraw(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if g.DrawOffer() == color {
return nil
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.OfferDraw(color)
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw offered", "game_id", gameID, "side", color.String())
return nil
}
// RecordDeclinedOffer counts an offer the computer answered at once against
// color's one-offer-per-move allowance.
func (s *Service) RecordDeclinedOffer(gameID string, color core.Color, actor, expectedFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, expectedFEN)
if err != nil {
return err
}
if err := offerAllowed(g, color); err != nil {
return err
}
g.RecordDeclinedOffer(color)
g.Touch(time.Now().UTC())
slog.Debug("draw offer declined by the computer", "game_id", gameID, "side", color.String())
return nil
}
// DeclineDraw lets color turn down the opponent's standing offer.
func (s *Service) DeclineDraw(gameID string, color core.Color, actor string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, err := s.drawTarget(gameID, color, actor, "")
if err != nil {
return err
}
if g.DrawOffer() != core.OppositeColor(color) {
return ErrNoDrawOffer
}
g.DeclineDraw()
g.Touch(time.Now().UTC())
s.waiter.NotifyAll(gameID)
slog.Debug("draw declined", "game_id", gameID, "side", color.String())
return nil
}
// drawTarget returns an unfinished game whose side color the actor may play,
// optionally still at expectedFEN. Caller holds s.mu.
func (s *Service) drawTarget(gameID string, color core.Color, actor, expectedFEN string) (*game.Game, error) {
g, ok := s.games[gameID]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State().IsTerminal() {
return nil, ErrGameOver
}
if g.State() != core.StateOngoing || (expectedFEN != "" && g.CurrentFEN() != expectedFEN) {
return nil, ErrGameChanged
}
if _, err := actorClaim(g, color, actor); err != nil {
return nil, err
}
return g, nil
}
func offerAllowed(g *game.Game, color core.Color) error {
if last := g.LastOfferPly(color); last >= 0 && g.Plies() < last+2 {
return ErrOfferLimit
}
return nil
}
// UndoMoves removes the specified number of moves from game history
func (s *Service) UndoMoves(gameID string, count int) error {
s.mu.Lock()
@@ -351,11 +538,6 @@ func (s *Service) DeleteGame(gameID string) error {
return errors.New("cannot delete game while computer move is in progress")
}
// Decrement computer game count if applicable
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
// Remove from wait registry
s.waiter.RemoveGame(gameID)
+18 -9
View File
@@ -19,7 +19,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
@@ -27,7 +27,7 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, Termination: core.TermCheckmate, At: ended,
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
}
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
@@ -60,13 +60,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, Termination: core.TermStalemate,
}); err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
white.ClaimedBy = userID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone,
); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, core.TermStalemate,
); err != nil {
t.Fatal(err)
}
@@ -166,7 +166,7 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
white.ID, white.ClaimedBy = claimant, claimant
}
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
}
@@ -202,7 +202,16 @@ func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
func newPersistentTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(pgtest.DSN(t))
svc, _ := newPersistentTestServiceDSN(t)
return svc
}
// newPersistentTestServiceDSN also returns the DSN of the test schema, for
// tests that change rows behind the service's back.
func newPersistentTestServiceDSN(t *testing.T) (*Service, string) {
t.Helper()
dsn := pgtest.DSN(t)
store, err := storage.NewStore(dsn)
if err != nil {
t.Fatal(err)
}
@@ -218,5 +227,5 @@ func newPersistentTestService(t *testing.T) *Service {
t.Errorf("shutdown: %v", err)
}
})
return svc
return svc, dsn
}
+134 -21
View File
@@ -2,43 +2,59 @@ package service
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"chess/internal/server/chess"
"chess/internal/server/core"
"chess/internal/server/replay"
"chess/internal/server/storage"
"github.com/google/uuid"
)
var (
// ErrInvalidListQuery reports a malformed cursor or filter.
ErrInvalidListQuery = errors.New("invalid list query")
// ErrPlyOutOfRange reports a PGN ply beyond the stored line.
ErrPlyOutOfRange = replay.ErrPlyOutOfRange
// ErrNotation reports a stored move the rules core cannot notate.
ErrNotation = replay.ErrNotation
)
// GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted.
// evicted from memory or the server has restarted. SAN is derived on read.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
record, moves, err := s.loadHistory(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)
return nil, err
}
history := &core.GameHistoryResponse{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
Termination: record.Termination,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
for _, move := range moves {
san, err := replay.Notate(record.InitialFEN, moves)
if err != nil {
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
}
for i, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI,
SAN: san[i],
FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC,
@@ -47,13 +63,79 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
return history, nil
}
// GetGamePGN exports the stored game in PGN; see replay.BuildPGN for ply.
func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) {
record, moves, err := s.loadHistory(gameID)
if err != nil {
return nil, err
}
pgn, err := replay.BuildPGN(record, moves, ply)
if errors.Is(err, replay.ErrNotation) {
slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err)
}
return pgn, err
}
func (s *Service) loadHistory(gameID string) (*storage.GameRecord, []storage.MoveRecord, error) {
if s.store == nil {
return nil, nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, nil, fmt.Errorf("get game history: %w", err)
}
return record, moves, nil
}
// UserGamesOptions selects a page of the caller's games. Cursor, when set,
// continues a previous page and excludes Offset.
type UserGamesOptions struct {
Limit int
Offset int
Cursor string
Color string // "", "white", or "black"
Status string // "", "ongoing", or "finished"
}
// 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) {
func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.GameListResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
query := storage.UserGamesQuery{Limit: opts.Limit + 1, Offset: opts.Offset, Status: opts.Status}
switch opts.Color {
case "":
case "white":
query.Color = "w"
case "black":
query.Color = "b"
default:
return nil, fmt.Errorf("%w: color must be white or black", ErrInvalidListQuery)
}
switch opts.Status {
case "", "ongoing", "finished":
default:
return nil, fmt.Errorf("%w: status must be ongoing or finished", ErrInvalidListQuery)
}
if opts.Cursor != "" {
if opts.Offset != 0 {
return nil, fmt.Errorf("%w: cursor and offset are exclusive", ErrInvalidListQuery)
}
after, err := decodeCursor(opts.Cursor)
if err != nil {
return nil, err
}
query.After = after
}
records, err := s.store.QueryGamesForUser(userID, query)
if err != nil {
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
@@ -61,24 +143,29 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return nil, fmt.Errorf("get user games: %w", err)
}
hasNext := len(records) > limit
hasNext := len(records) > opts.Limit
if hasNext {
records = records[:limit]
records = records[:opts.Limit]
}
response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)),
Limit: limit,
Offset: offset,
Limit: opts.Limit,
Offset: opts.Offset,
}
if hasNext {
next := offset + limit
response.NextOffset = &next
last := records[len(records)-1]
response.NextCursor = encodeCursor(last.StartTimeUTC, last.GameID)
if opts.Cursor == "" {
next := opts.Offset + opts.Limit
response.NextOffset = &next
}
}
for _, record := range records {
response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
PGNResult: chess.ResultToken(record.Result),
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
@@ -89,6 +176,32 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
return response, nil
}
// Cursors are opaque to clients: base64url of "<start µs>.<game ID>". The
// microsecond value round-trips PostgreSQL timestamptz exactly.
func encodeCursor(start time.Time, gameID string) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(start.UnixMicro(), 10) + "." + gameID))
}
func decodeCursor(cursor string) (*storage.GameCursor, error) {
invalid := fmt.Errorf("%w: malformed cursor", ErrInvalidListQuery)
if len(cursor) > 128 {
return nil, invalid
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return nil, invalid
}
micros, id, ok := strings.Cut(string(raw), ".")
if !ok {
return nil, invalid
}
us, err := strconv.ParseInt(micros, 10, 64)
if err != nil || uuid.Validate(id) != nil {
return nil, invalid
}
return &storage.GameCursor{StartTimeUTC: time.UnixMicro(us).UTC(), GameID: id}, nil
}
func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) ||
+156
View File
@@ -0,0 +1,156 @@
package service
import (
"errors"
"strings"
"testing"
"chess/internal/server/chess"
"chess/internal/server/core"
"github.com/google/uuid"
)
// playLine creates a game and commits uci as the players' moves, with FENs
// from the rules core and the given final state (reached by checkmate) on the
// last move.
func playLine(t *testing.T, svc *Service, userID string, black core.PlayerConfig, uci []string, final core.State) string {
t.Helper()
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
blackPlayer := core.NewPlayer(black, core.ColorBlack)
if err := svc.CreateGame(gameID, white, blackPlayer, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
pos, err := chess.ParseFEN(chess.StartFEN)
if err != nil {
t.Fatal(err)
}
for i, text := range uci {
m, err := pos.ParseUCI(text)
if err != nil {
t.Fatal(err)
}
next := pos.Play(m)
state, termination := core.StateOngoing, core.TermNone
if i == len(uci)-1 && final.IsTerminal() {
state, termination = final, core.TermCheckmate
}
turn, actor := core.ColorWhite, userID
if i%2 == 1 {
turn, actor = core.ColorBlack, ""
}
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
ActorUserID: actor, MoveUCI: text, NewFEN: next.FEN(), State: state, Termination: termination,
}); err != nil {
t.Fatal(err)
}
pos = next
}
return gameID
}
func TestHistoryNotationAndPGN(t *testing.T) {
svc := newPersistentTestService(t)
computer := core.PlayerConfig{Type: core.PlayerComputer, Level: 3, SearchTime: 100}
gameID := playLine(t, svc, uuid.NewString(), computer,
[]string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
history, err := svc.GetGameHistory(gameID)
if err != nil {
t.Fatal(err)
}
var san []string
for _, m := range history.Moves {
san = append(san, m.SAN)
}
if strings.Join(san, " ") != "f3 e5 g4 Qh4#" || history.PGNResult != "0-1" || history.Termination != "checkmate" {
t.Fatalf("history notation = %v, %q, %q", san, history.PGNResult, history.Termination)
}
pgn, err := svc.GetGamePGN(gameID, -1)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"[Event \"Casual game\"]\n[Site \"?\"]\n", "[White \"Anonymous\"]\n", "[Black \"Stockfish level 3\"]\n",
"[Result \"0-1\"]\n", "[GameId \"" + gameID + "\"]\n", "[BlackType \"program\"]\n", "[PlyCount \"4\"]\n",
"[Termination \"normal\"]\n", "\n\n1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1\n",
} {
if !strings.Contains(pgn.Text, want) {
t.Errorf("PGN lacks %q:\n%s", want, pgn.Text)
}
}
if !strings.HasPrefix(pgn.Filename, "chess-") || !strings.HasSuffix(pgn.Filename, "-"+gameID[:8]+".pgn") {
t.Errorf("filename = %q", pgn.Filename)
}
partial, err := svc.GetGamePGN(gameID, 2)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(partial.Text, "\n\n1. f3 e5 *\n") || strings.Contains(partial.Text, "Termination") ||
!strings.HasSuffix(partial.Filename, "-ply2.pgn") {
t.Errorf("partial PGN %q:\n%s", partial.Filename, partial.Text)
}
if full, err := svc.GetGamePGN(gameID, 4); err != nil || full.Text != pgn.Text {
t.Errorf("ply equal to the line length should export the whole game: %v", err)
}
if _, err := svc.GetGamePGN(gameID, 5); !errors.Is(err, ErrPlyOutOfRange) {
t.Errorf("ply 5 error = %v, want ErrPlyOutOfRange", err)
}
if _, err := svc.GetGamePGN(uuid.NewString(), -1); !errors.Is(err, ErrGameNotFound) {
t.Errorf("unknown game error = %v, want ErrGameNotFound", err)
}
}
func TestUserGamesCursorAndFilters(t *testing.T) {
svc := newPersistentTestService(t)
userID := uuid.NewString()
human := core.PlayerConfig{Type: core.PlayerHuman}
var ids []string
for i := 0; i < 3; i++ {
ids = append([]string{playLine(t, svc, userID, human, []string{"e2e4"}, core.StateOngoing)}, ids...)
}
finished := playLine(t, svc, userID, human, []string{"f2f3", "e7e5", "g2g4", "d8h4"}, core.StateBlackWins)
ids = append([]string{finished}, ids...)
var got []string
cursor := ""
for {
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 3, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
if cursor != "" && page.NextOffset != nil {
t.Error("cursor page reported nextOffset")
}
for _, g := range page.Games {
got = append(got, g.GameID)
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if strings.Join(got, ",") != strings.Join(ids, ",") {
t.Fatalf("cursor pages = %v, want %v", got, ids)
}
page, err := svc.GetUserGames(userID, UserGamesOptions{Limit: 10, Status: "finished", Color: "white"})
if err != nil {
t.Fatal(err)
}
if len(page.Games) != 1 || page.Games[0].GameID != finished || page.Games[0].PGNResult != "0-1" {
t.Fatalf("finished games = %+v", page.Games)
}
for _, bad := range []UserGamesOptions{
{Limit: 1, Cursor: "!!"}, {Limit: 1, Cursor: "bm90LWEtY3Vyc29y"}, {Limit: 1, Offset: 1, Cursor: page.NextCursor + "x"},
{Limit: 1, Color: "red"}, {Limit: 1, Status: "paused"},
} {
if _, err := svc.GetUserGames(userID, bad); !errors.Is(err, ErrInvalidListQuery) {
t.Errorf("GetUserGames(%+v) error = %v, want ErrInvalidListQuery", bad, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"chess/internal/server/replay"
"github.com/lixenwraith/auth"
)
// IntegrityMode selects what the periodic integrity sweep does.
type IntegrityMode int
const (
// IntegrityOff skips the sweep.
IntegrityOff IntegrityMode = iota
// IntegrityReport logs what the sweep finds and changes nothing.
IntegrityReport
// IntegrityDelete also unloads and deletes what it finds.
IntegrityDelete
)
// integrityBatch bounds how many stored games one sweep replays against the
// rules; the sweep resumes after the last one next time, so every game is
// checked in turn at a bounded cost per run.
const integrityBatch = 200
// ParseIntegrityMode accepts "off", "report", or "delete".
func ParseIntegrityMode(s string) (IntegrityMode, error) {
switch s {
case "", "off":
return IntegrityOff, nil
case "report":
return IntegrityReport, nil
case "delete":
return IntegrityDelete, nil
}
return IntegrityOff, fmt.Errorf("invalid integrity mode %q (want off, report, or delete)", s)
}
func (m IntegrityMode) String() string {
switch m {
case IntegrityReport:
return "report"
case IntegrityDelete:
return "delete"
}
return "off"
}
// SetIntegrityMode configures the sweep run with every cleanup job.
func (s *Service) SetIntegrityMode(mode IntegrityMode) {
s.mu.Lock()
s.integrityMode = mode
s.mu.Unlock()
}
// integrityFindings is what one sweep found. Keys are IDs, values reasons.
type integrityFindings struct {
vanished map[string]string // loaded games without a database row
games map[string]string // stored games to delete
users map[string]string // accounts to delete
loaded map[string]bool
}
// checkIntegrity finds data that the server cannot use and, in delete mode,
// removes it:
//
// - loaded games whose row was deleted by hand (unloaded from memory);
// - games with missing plies, when move rows were deleted by hand
// (unloaded if loaded, then deleted);
// - stored games that fail replay.Verify, integrityBatch per run
// (loaded games are skipped: their rows may trail memory);
// - games whose registered players were all deleted, once idle past the
// anonymous-game retention, like games that never had one;
// - accounts whose password hash is not a usable Argon2id PHC string, so
// nobody can sign in to them.
//
// Every finding is logged at WARN with its reason.
func (s *Service) checkIntegrity(now time.Time) {
s.mu.RLock()
mode, anonymousTTL := s.integrityMode, s.anonymousTTL
loaded := make(map[string]bool, len(s.games))
for gameID := range s.games {
loaded[gameID] = true
}
s.mu.RUnlock()
if mode == IntegrityOff || s.store == nil {
return
}
findings, err := s.findIntegrityProblems(now, loaded, anonymousTTL)
if err != nil {
slog.Error("integrity sweep failed", "error", err)
return
}
for gameID, reason := range findings.vanished {
slog.Warn("integrity: loaded game has no database row", "game_id", gameID, "reason", reason)
}
for gameID, reason := range findings.games {
slog.Warn("integrity: invalid game", "game_id", gameID, "reason", reason)
}
for userID, reason := range findings.users {
slog.Warn("integrity: unusable account", "user_id", userID, "reason", reason)
}
summary := []any{"mode", mode.String(), "vanished_games", len(findings.vanished),
"invalid_games", len(findings.games), "unusable_accounts", len(findings.users)}
if mode != IntegrityDelete {
if len(findings.vanished)+len(findings.games)+len(findings.users) > 0 {
slog.Info("integrity sweep found problems; nothing changed (report mode)", summary...)
}
return
}
for gameID := range findings.vanished {
s.evictMissingGame(gameID)
}
var games []string
for gameID := range findings.games {
if findings.loaded[gameID] {
s.evictMissingGame(gameID) // unload first: no further writes for it
}
games = append(games, gameID)
}
if err := s.store.DeleteGames(games); err != nil {
slog.Error("integrity sweep failed to queue game deletion", "error", err)
}
var users []string
for userID := range findings.users {
users = append(users, userID)
}
if err := s.store.DeleteUsers(users); err != nil {
slog.Error("integrity sweep failed to delete accounts", "error", err)
}
if len(findings.vanished)+len(games)+len(users) > 0 {
slog.Info("integrity sweep removed invalid data", summary...)
}
}
func (s *Service) findIntegrityProblems(now time.Time, loaded map[string]bool,
anonymousTTL time.Duration) (*integrityFindings, error) {
found := &integrityFindings{
vanished: map[string]string{},
games: map[string]string{},
users: map[string]string{},
loaded: loaded,
}
live := make([]string, 0, len(loaded))
for gameID := range loaded {
live = append(live, gameID)
}
// Every loaded game's insert was queued before the snapshot was taken, so
// after this flush a missing row means the row was deleted.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.store.Flush(ctx); err != nil {
return nil, err
}
exists, err := s.store.ExistingGames(live)
if err != nil {
return nil, err
}
for _, gameID := range live {
if !exists[gameID] {
found.vanished[gameID] = "row deleted outside the server"
}
}
gaps, err := s.store.GamesWithMoveGaps()
if err != nil {
return nil, err
}
for _, gameID := range gaps {
found.games[gameID] = "missing plies: move rows were deleted"
}
ids, err := s.store.GameIDsAfter(s.integrityCursor, integrityBatch)
if err != nil {
return nil, err
}
s.integrityCursor = ""
if len(ids) == integrityBatch {
s.integrityCursor = ids[len(ids)-1]
}
for _, gameID := range ids {
if loaded[gameID] || found.games[gameID] != "" {
continue
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
continue // deleted since the listing
}
if problems := replay.Verify(record, moves); len(problems) > 0 {
found.games[gameID] = problems[0]
}
}
if anonymousTTL > 0 {
orphans, err := s.store.OrphanedGames(now.Add(-anonymousTTL), live)
if err != nil {
return nil, err
}
for _, gameID := range orphans {
if found.games[gameID] == "" {
found.games[gameID] = "all registered players were deleted"
}
}
}
users, err := s.store.GetAllUsers()
if err != nil {
return nil, err
}
for _, user := range users {
if err := auth.ValidatePHCHashFormat(user.PasswordHash); err != nil {
found.users[user.UserID] = fmt.Sprintf("%s: %v", user.Username, err)
}
}
return found, nil
}
// evictMissingGame unloads a game whose database row disappeared, typically
// deleted by hand. It runs when a write for the game finds the row gone.
func (s *Service) evictMissingGame(gameID string) {
s.mu.Lock()
_, ok := s.games[gameID]
if ok {
delete(s.games, gameID)
}
s.mu.Unlock()
if ok {
s.waiter.RemoveGame(gameID)
slog.Warn("game unloaded: its database row no longer exists", "game_id", gameID)
}
}
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"database/sql"
"errors"
"testing"
"time"
"chess/internal/server/chess"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
)
func TestIntegritySweep(t *testing.T) {
svc, dsn := newPersistentTestServiceDSN(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
stored := func(gameID string) bool {
t.Helper()
var exists bool
if err := db.QueryRow(`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID).
Scan(&exists); err != nil {
t.Fatal(err)
}
return exists
}
// played creates a game, plays the moves, and unloads it from memory.
played := func(uci ...string) string {
t.Helper()
gameID := newHumanGame(t, svc)
for _, u := range uci {
move(t, svc, gameID, "", u)
}
if err := svc.DeleteGame(gameID); err != nil {
t.Fatal(err)
}
return gameID
}
valid := played("e2e4", "e7e5", "g1f3")
gap := played("e2e4", "e7e5", "g1f3")
corrupt := played("e2e4", "e7e5")
loaded := newHumanGame(t, svc)
vanished := newHumanGame(t, svc)
old := time.Now().Add(-48 * time.Hour)
orphan, claimed := uuid.NewString(), uuid.NewString()
aliceHash, err := auth.HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
alice, mallory := uuid.NewString(), uuid.NewString()
exec(`INSERT INTO users (user_id, username, password_hash) VALUES ($1, 'alice', $2), ($3, 'mallory', 'hunter2')`,
alice, aliceHash, mallory)
for gameID, claimant := range map[string]string{orphan: uuid.NewString(), claimed: alice} {
if err := svc.store.RecordNewGame(storage.GameRecord{
GameID: gameID, InitialFEN: chess.StartFEN,
WhitePlayerID: claimant, WhiteType: 1, WhiteClaimedBy: claimant,
BlackPlayerID: uuid.NewString(), BlackType: 1, StartTimeUTC: old,
}); err != nil {
t.Fatal(err)
}
}
if _, err := svc.GetGameHistory(valid); err != nil { // flush queued writes
t.Fatal(err)
}
// Damage made by hand, as with psql.
exec(`DELETE FROM moves WHERE game_id = $1 AND move_number = 2`, gap)
exec(`UPDATE moves SET fen_after_move = $2 WHERE game_id = $1 AND move_number = 2`, corrupt, chess.StartFEN)
exec(`DELETE FROM games WHERE game_id = $1`, vanished)
svc.SetIntegrityMode(IntegrityReport)
svc.checkIntegrity(time.Now())
for _, gameID := range []string{valid, gap, corrupt, orphan, claimed, loaded} {
if !stored(gameID) {
t.Fatalf("report mode deleted game %s", gameID)
}
}
if _, err := svc.GetGameView(vanished); err != nil {
t.Fatal("report mode unloaded a game")
}
svc.SetIntegrityMode(IntegrityDelete)
svc.checkIntegrity(time.Now())
if _, err := svc.GetGameHistory(valid); err != nil { // flush the deletion
t.Fatal(err)
}
for gameID, want := range map[string]bool{
valid: true, loaded: true, claimed: true, gap: false, corrupt: false, orphan: false,
} {
if got := stored(gameID); got != want {
t.Errorf("game %s stored = %v, want %v", gameID, got, want)
}
}
if _, err := svc.GetGameView(vanished); !errors.Is(err, ErrGameNotFound) {
t.Errorf("game with a deleted row still loaded: %v", err)
}
if _, err := svc.GetGameView(loaded); err != nil {
t.Errorf("healthy loaded game was unloaded: %v", err)
}
var users int
if err := db.QueryRow(`SELECT count(*) FROM users WHERE user_id IN ($1, $2)`, alice, mallory).
Scan(&users); err != nil {
t.Fatal(err)
}
if users != 1 {
t.Errorf("accounts left = %d, want alice only", users)
}
if health := svc.GetStorageHealth(); health != "ok" {
t.Errorf("storage health %q after the sweep", health)
}
// A healthy database gives a clean second run.
findings, err := svc.findIntegrityProblems(time.Now(), map[string]bool{loaded: true}, AnonymousGameTTL)
if err != nil {
t.Fatal(err)
}
if len(findings.games)+len(findings.users)+len(findings.vanished) != 0 {
t.Errorf("second sweep found %+v", findings)
}
}
func TestParseIntegrityMode(t *testing.T) {
for in, want := range map[string]IntegrityMode{"": IntegrityOff, "off": IntegrityOff,
"report": IntegrityReport, "delete": IntegrityDelete} {
if got, err := ParseIntegrityMode(in); err != nil || got != want {
t.Errorf("ParseIntegrityMode(%q) = %v, %v", in, got, err)
}
}
if _, err := ParseIntegrityMode("purge"); err == nil {
t.Error("ParseIntegrityMode accepted an unknown mode")
}
}
+40 -20
View File
@@ -29,16 +29,18 @@ const (
// Service coordinates game state, user management, and storage
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store
jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store
jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry
finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
integrityMode IntegrityMode
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
}
// New creates a service with optional storage. jwtSecret signs session tokens
@@ -64,6 +66,9 @@ func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
anonymousTTL: AnonymousGameTTL,
}
s.maxUsers.Store(DefaultMaxUsers)
if store != nil {
store.SetGameMissingHandler(s.evictMissingGame)
}
return s, nil
}
@@ -115,14 +120,30 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
// CanCreateComputerGame checks if a new computer game can be created
// CanCreateComputerGame reports whether the computer game limit leaves room
// for another game; CreateGame re-checks under its lock.
func (s *Service) CanCreateComputerGame() bool {
return s.computerGames.Load() < MaxComputerGames
return s.GetComputerGameCount() < MaxComputerGames
}
// GetComputerGameCount returns current computer game count
func (s *Service) GetComputerGameCount() int32 {
return s.computerGames.Load()
// GetComputerGameCount returns the number of unfinished loaded games with a
// computer player. Finished games stay loaded for a while but need no engine,
// so they do not count against MaxComputerGames.
func (s *Service) GetComputerGameCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeComputerGamesLocked()
}
// activeComputerGamesLocked counts as GetComputerGameCount. Caller holds s.mu.
func (s *Service) activeComputerGamesLocked() int {
active := 0
for _, g := range s.games {
if g.HasComputerPlayer() && !g.State().IsTerminal() {
active++
}
}
return active
}
// Shutdown gracefully shuts down the service
@@ -146,8 +167,9 @@ func (s *Service) Shutdown(timeout time.Duration) error {
return errors.Join(errs...)
}
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
// deletes anonymous games past their retention.
// RunCleanupJob periodically removes expired sessions, unloads idle games,
// deletes anonymous games past their retention, and runs the integrity sweep
// when enabled.
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired()
ticker := time.NewTicker(interval)
@@ -173,6 +195,7 @@ func (s *Service) cleanupExpired() {
}
}
s.cleanupGames(now)
s.checkIntegrity(now)
}
// cleanupGames unloads terminal games after the finished-game TTL and
@@ -199,9 +222,6 @@ func (s *Service) cleanupGames(now time.Time) {
live = append(live, gameID)
continue
}
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID)
}
s.mu.Unlock()
+6
View File
@@ -105,6 +105,12 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
}
}
// NotifyAll wakes every client waiting on a game, for changes that neither
// move nor settle it, such as a draw offer.
func (w *WaitRegistry) NotifyAll(gameID string) {
w.RemoveGame(gameID)
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.RLock()
+237 -73
View File
@@ -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")
}
+144
View File
@@ -0,0 +1,144 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
)
// Queries for the integrity sweep. None of them change data except the two
// Delete methods.
// ExistingGames reports which of ids have a games row. Flush first when ids
// include games whose insert may still be queued.
func (s *Store) ExistingGames(ids []string) (map[string]bool, error) {
exists := make(map[string]bool, len(ids))
if len(ids) == 0 {
return exists, nil
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return nil, fmt.Errorf("query existing games: %w", err)
}
return exists, collectIDs(rows, func(id string) { exists[id] = true })
}
// GamesWithMoveGaps returns games whose moves are not numbered 1..n: a move
// row was deleted from the start or the middle of the line.
func (s *Store) GamesWithMoveGaps() ([]string, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM moves
GROUP BY game_id HAVING count(*) <> max(move_number) ORDER BY game_id`)
if err != nil {
return nil, fmt.Errorf("query move gaps: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// GameIDsAfter returns up to limit game IDs above after, in ID order; an
// empty after starts at the lowest.
func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) {
if after == "" {
after = "00000000-0000-0000-0000-000000000000"
} else if !validUUID(after) {
return nil, fmt.Errorf("invalid game ID %q", after)
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id > $1 ORDER BY game_id LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("query game IDs: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// OrphanedGames returns games that registered users claimed but whose
// claimants were all deleted, idle since cutoff by the anonymous-game rule
// (start, last move, and end all before it), excluding live games.
func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) {
if live == nil {
live = []string{}
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g
WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL)
AND NOT EXISTS (
SELECT 1 FROM users u
WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by)
)
AND g.start_time_utc < $1
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
AND NOT EXISTS (
SELECT 1 FROM moves m
WHERE m.game_id = g.game_id AND m.move_time_utc >= $1
)
AND NOT (g.game_id = ANY($2::uuid[]))
ORDER BY g.game_id`, cutoff.UTC(), live)
if err != nil {
return nil, fmt.Errorf("query orphaned games: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// DeleteGames queues deletion of games and, by cascade, their moves. It runs
// through the ordered writer, after every write queued before it.
func (s *Store) DeleteGames(ids []string) error {
if len(ids) == 0 {
return nil
}
return s.enqueue("delete_games", "", func(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return err
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage games deleted", "count", deleted)
}
return nil
})
}
// DeleteUsers deletes accounts and, by cascade, their sessions. Their games
// keep the claims and name snapshots.
func (s *Store) DeleteUsers(ids []string) error {
if len(ids) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
defer cancel()
result, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE user_id = ANY($1::uuid[])`, ids)
if err != nil {
return fmt.Errorf("delete users: %w", err)
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage users deleted", "count", deleted)
}
return nil
}
func collectIDs(rows *sql.Rows, add func(string)) error {
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
add(id)
}
if err := rows.Err(); err != nil {
return errors.Join(errors.New("iterate game IDs"), err)
}
return nil
}
+27 -6
View File
@@ -38,6 +38,7 @@ type GameRecord struct {
BlackClaimedBy string
BlackName string
Result string
Termination string // how Result was reached; empty while unfinished
StartTimeUTC time.Time
EndTimeUTC *time.Time
}
@@ -63,17 +64,18 @@ type MoveRecord struct {
// MovePersistence groups changes caused by one accepted move so the move,
// first-move slot claim, and terminal result commit in one transaction.
type MovePersistence struct {
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
EndTimeUTC *time.Time
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
Termination string
EndTimeUTC *time.Time
}
// 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 = 1
const schemaVersion = 2
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
// released migration; append a new one.
@@ -161,6 +163,25 @@ CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
WHERE black_claimed_by IS NOT NULL;
`,
// v2: how a result was reached. Results before v2 came only from
// checkmate and stalemate (the server never produced "draw"), so the
// backfill is exact for them; a hand-written draw becomes "agreement".
`
ALTER TABLE games ADD COLUMN termination text;
UPDATE games SET termination = CASE result
WHEN 'stalemate' THEN 'stalemate'
WHEN 'draw' THEN 'agreement'
ELSE 'checkmate' END
WHERE result IS NOT NULL;
ALTER TABLE games
ADD CONSTRAINT games_result_termination_check CHECK ((result IS NULL) = (termination IS NULL)),
ADD CONSTRAINT games_termination_check CHECK (
(result IN ('white_wins', 'black_wins') AND termination IN ('checkmate', 'resignation'))
OR (result = 'stalemate' AND termination = 'stalemate')
OR (result = 'draw' AND termination IN
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
`,
}
// ownedTables lists every table created by migrations, children first.
+17
View File
@@ -68,6 +68,15 @@ type Store struct {
enqueueMu sync.RWMutex
closeOnce sync.Once
closeErr error
gameMissing atomic.Pointer[func(gameID string)]
}
// SetGameMissingHandler registers the callback run, on its own goroutine,
// when a write finds its game row gone. Such a write is dropped without
// degrading storage: later writes for that game fail the same way and write
// nothing, so no other game's history is affected.
func (s *Store) SetGameMissingHandler(handler func(gameID string)) {
s.gameMissing.Store(&handler)
}
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
@@ -203,6 +212,14 @@ func (s *Store) executeWrite(req writeRequest) error {
)
return nil
}
if errors.Is(err, ErrGameMissing) {
slog.Warn("storage write dropped: game row no longer exists",
"operation", req.operation, "game_id", req.gameID)
if handler := s.gameMissing.Load(); handler != nil && req.gameID != "" {
go (*handler)(req.gameID)
}
return err
}
if committing || attempt >= writeAttempts || !isTransient(err) {
break
}
+229 -5
View File
@@ -116,7 +116,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
},
ClaimColor: "w", ClaimedBy: user,
Result: "white_wins", EndTimeUTC: &ended,
Result: "white_wins", Termination: "checkmate", EndTimeUTC: &ended,
}); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
t.Fatalf("moves = %+v", moves)
}
owned, err := store.QueryGamesForUser(user, 10, 0)
owned, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil {
t.Fatal(err)
}
@@ -155,7 +155,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
}
owned, err = store.QueryGamesForUser(user, 10, 0)
owned, err = store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil {
t.Fatal(err)
}
@@ -188,6 +188,8 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
record.WhiteClaimedBy = user
case 1:
record.BlackClaimedBy = user
end := record.StartTimeUTC.Add(time.Minute)
record.Result, record.Termination, record.EndTimeUTC = "white_wins", "resignation", &end
case 2:
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
case 3:
@@ -205,7 +207,7 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
var got []string
for offset := 0; ; offset += 2 {
page, err := store.QueryGamesForUser(user, 2, offset)
page, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, Offset: offset})
if err != nil {
t.Fatal(err)
}
@@ -219,9 +221,88 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("user games = %v, want %v", got, want)
}
if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 {
if games, err := store.QueryGamesForUser("not-a-uuid", UserGamesQuery{Limit: 10}); err != nil || len(games) != 0 {
t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
}
// Keyset pages match offset pages, and a game inserted at the head
// between pages does not shift the next page.
got = nil
var after *GameCursor
for page := 0; ; page++ {
games, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, After: after})
if err != nil {
t.Fatal(err)
}
for _, game := range games {
got = append(got, game.GameID)
}
if len(games) < 2 {
break
}
last := games[len(games)-1]
after = &GameCursor{StartTimeUTC: last.StartTimeUTC, GameID: last.GameID}
if page == 0 {
newer := GameRecord{
GameID: uuid.NewString(), InitialFEN: "initial",
WhitePlayerID: user, WhiteType: 1, WhiteClaimedBy: user,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: base.Add(time.Hour),
}
if err := store.RecordNewGame(newer); err != nil {
t.Fatal(err)
}
}
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("keyset pages = %v, want %v", got, want)
}
count := func(q UserGamesQuery) int {
t.Helper()
q.Limit = 50
games, err := store.QueryGamesForUser(user, q)
if err != nil {
t.Fatal(err)
}
return len(games)
}
// Five games claimed by user: indexes 0, 1, 2, 4 and the newer one.
for _, tc := range []struct {
q UserGamesQuery
want int
}{
{UserGamesQuery{}, 5},
{UserGamesQuery{Color: "w"}, 3},
{UserGamesQuery{Color: "b"}, 3},
{UserGamesQuery{Status: "finished"}, 1},
{UserGamesQuery{Status: "ongoing"}, 4},
{UserGamesQuery{Color: "b", Status: "finished"}, 1},
} {
if got := count(tc.q); got != tc.want {
t.Errorf("QueryGamesForUser(%+v) = %d games, want %d", tc.q, got, tc.want)
}
}
for _, bad := range []UserGamesQuery{
{Limit: 1, Color: "x"}, {Limit: 1, Status: "x"},
{Limit: 1, Offset: 1, After: &GameCursor{GameID: user}},
{Limit: 1, After: &GameCursor{GameID: "x"}},
} {
if _, err := store.QueryGamesForUser(user, bad); err == nil {
t.Errorf("QueryGamesForUser(%+v) succeeded", bad)
}
}
prefix := want[0][:8]
if id, err := store.ResolveGameID(prefix); err != nil || id != want[0] {
t.Errorf("ResolveGameID(%s) = %q, %v; want %s", prefix, id, err, want[0])
}
if _, err := store.ResolveGameID("0000000"); err == nil {
t.Error("ResolveGameID accepted a 7-digit prefix")
}
if _, err := store.ResolveGameID("ffffffff-ffff"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("ResolveGameID(unknown) error = %v, want sql.ErrNoRows", err)
}
}
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
@@ -242,6 +323,14 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
args: []any{userID},
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
},
{
name: "keyset page of one color uses its claim index",
query: `SELECT game_id FROM games g
WHERE g.white_claimed_by = $1 AND (g.start_time_utc, g.game_id) < ($2, $3)
ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`,
args: []any{userID, time.Now(), userID},
want: []string{"games_white_claimed_idx"},
},
{
name: "last move probe uses the moves primary key",
query: `SELECT move_number FROM moves WHERE game_id = $1
@@ -621,6 +710,12 @@ func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) {
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
StartTimeUTC: started, Result: result, EndTimeUTC: ended,
}
switch result {
case "stalemate":
record.Termination = "stalemate"
case "white_wins", "black_wins":
record.Termination = "checkmate"
}
if err := store.RecordNewGame(record); err != nil {
t.Fatal(err)
}
@@ -792,3 +887,132 @@ func explain(t *testing.T, db *sql.DB, query string, args ...any) string {
}
return strings.Join(lines, "\n")
}
func TestMigrationV2BackfillsTermination(t *testing.T) {
dsn := pgtest.DSN(t)
store, err := NewStore(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
// A version 1 database with one game of each stored result.
if _, err := store.db.Exec(migrations[0]); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (1)`); err != nil {
t.Fatal(err)
}
ids := map[string]string{}
for _, result := range []string{"", "white_wins", "black_wins", "stalemate", "draw"} {
id := uuid.NewString()
ids[result] = id
var end any
if result != "" {
end = time.Now()
}
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
black_player_id, black_type, result, end_time_utc) VALUES ($1, 'initial', $2, 1, $3, 1, $4, $5)`,
id, uuid.NewString(), uuid.NewString(), nullableString(result), end); err != nil {
t.Fatal(err)
}
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
for result, want := range map[string]string{
"": "", "white_wins": "checkmate", "black_wins": "checkmate", "stalemate": "stalemate", "draw": "agreement",
} {
var termination sql.NullString
if err := store.db.QueryRow(`SELECT termination FROM games WHERE game_id = $1`, ids[result]).
Scan(&termination); err != nil {
t.Fatal(err)
}
if termination.String != want {
t.Errorf("result %q: termination %q, want %q", result, termination.String, want)
}
}
// The constraints reject a termination that cannot produce the result.
if _, err := store.db.Exec(`UPDATE games SET termination = 'agreement' WHERE game_id = $1`,
ids["white_wins"]); err == nil {
t.Error("games_termination_check accepted a win by agreement")
}
if _, err := store.db.Exec(`UPDATE games SET termination = 'resignation' WHERE game_id = $1`,
ids[""]); err == nil {
t.Error("games_result_termination_check accepted a termination without a result")
}
}
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
store := openStore(t, pgtest.DSN(t))
missing := make(chan string, 4)
store.SetGameMissingHandler(func(gameID string) { missing <- gameID })
gameID := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: gameID, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatal(err)
}
// An administrator deletes the game while the server still holds it.
if _, err := store.db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
ended := time.Now()
writes := []func() error{
func() error {
return store.RecordMove(MovePersistence{Move: MoveRecord{
GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4", FENAfterMove: "after",
PlayerColor: "w", MoveTimeUTC: ended,
}})
},
func() error {
return store.RecordGameEnd(GameEnd{
GameID: gameID, Result: "black_wins", Termination: "resignation", EndTimeUTC: ended,
})
},
func() error { return store.RewindGame(gameID, 0) },
}
for i, write := range writes {
if err := write(); err != nil {
t.Fatalf("write %d not queued: %v", i, err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatalf("write %d degraded storage: %v", i, err)
}
select {
case id := <-missing:
if id != gameID {
t.Fatalf("handler got %s, want %s", id, gameID)
}
case <-time.After(5 * time.Second):
t.Fatalf("write %d: missing-game handler not called", i)
}
}
if !store.IsHealthy() {
t.Fatal("storage degraded by writes for a deleted game")
}
// Other games are unaffected.
other := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: other, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if _, _, err := store.GetGameHistory(other); err != nil {
t.Fatalf("other game: %v", err)
}
}
@@ -17,6 +17,8 @@ let gameState = {
username: null,
authBusy: false,
newGameBusy: false,
termination: '',
actionBusy: false, // a draw or resign request is in flight
};
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
@@ -55,6 +57,10 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
document.getElementById('undo-btn').addEventListener('click', undoMoves);
document.getElementById('draw-btn').addEventListener('click', offerDraw);
document.getElementById('resign-btn').addEventListener('click', showResignModal);
document.getElementById('resign-confirm-btn').addEventListener('click', resignGame);
document.getElementById('resign-cancel-btn').addEventListener('click', hideResignModal);
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal);
document.getElementById('copy-history').addEventListener('click', copyHistory);
@@ -79,6 +85,13 @@ document.getElementById('login-submit-btn').addEventListener('click', handleLogi
document.getElementById('register-submit-btn').addEventListener('click', handleRegister);
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
document.querySelectorAll('.promotion-choice').forEach(btn => {
btn.addEventListener('click', () => closePromotion(btn.dataset.piece));
});
document.getElementById('promotion-cancel-btn').addEventListener('click', () => closePromotion(null));
document.getElementById('promotion-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closePromotion(null);
});
// Auth functions
@@ -435,24 +448,20 @@ function updateTurnIndicator(state, turn) {
switch(state) {
case 'white wins':
status = 'white-wins';
tooltipText = 'White Wins';
break;
case 'black wins':
status = 'black-wins';
tooltipText = 'Black Wins';
break;
case 'stalemate':
status = 'stalemate';
tooltipText = 'Stalemate';
break;
case 'draw':
status = 'draw';
tooltipText = 'Draw';
break;
default:
status = 'unknown';
tooltipText = 'Game Over';
}
tooltipText = describeResult(state, gameState.termination);
} else if (state === 'stuck') {
status = 'degraded';
tooltipText = 'Engine Error';
@@ -705,6 +714,7 @@ function renderBoardFromFEN(fen) {
s.textContent = '';
s.classList.remove('white-piece', 'black-piece', 'mated-king');
delete s.dataset.pieceColor;
delete s.dataset.pieceType;
});
let rank = 7, file = 0;
@@ -765,11 +775,64 @@ function flashSquare(element, success = true) {
setTimeout(() => element.classList.remove(className), 400);
}
// A pawn stepping or capturing onto its last rank needs a promotion piece; UCI
// requires the suffix (e7e8q), and the server rejects the bare move. Other
// moves go to the server as-is, which remains the only legality check.
function isPromotionMove(from, to) {
const fromEl = document.querySelector(`[data-square="${from}"]`);
if (!fromEl || fromEl.dataset.pieceType !== 'p') return false;
const fileDelta = Math.abs(from.charCodeAt(0) - to.charCodeAt(0));
if (fileDelta > 1) return false;
const color = fromEl.dataset.pieceColor;
return (color === 'w' && from[1] === '7' && to[1] === '8') ||
(color === 'b' && from[1] === '2' && to[1] === '1');
}
// Resolves with 'q', 'r', 'b' or 'n', or null when cancelled. Keyboard: Q/R/B/N
// pick, Escape cancels, Tab/Enter work on the focused button.
let promotionResolve = null;
function choosePromotion(color) {
const overlay = document.getElementById('promotion-overlay');
const choices = overlay.querySelector('.promotion-choices');
choices.classList.toggle('white', color === 'w');
choices.classList.toggle('black', color !== 'w');
overlay.classList.add('show');
document.addEventListener('keydown', handlePromotionKeydown);
overlay.querySelector('.promotion-choice[data-piece="q"]').focus();
return new Promise(resolve => { promotionResolve = resolve; });
}
function closePromotion(piece) {
document.getElementById('promotion-overlay').classList.remove('show');
document.removeEventListener('keydown', handlePromotionKeydown);
const resolve = promotionResolve;
promotionResolve = null;
if (resolve) resolve(piece);
}
function handlePromotionKeydown(e) {
const key = e.key.toLowerCase();
if (key === 'q' || key === 'r' || key === 'b' || key === 'n') {
e.preventDefault();
closePromotion(key);
} else if (e.key === 'Escape') {
e.preventDefault();
closePromotion(null);
}
}
async function handleHumanMove(from, to) {
const move = from + to;
let move = from + to;
const fromEl = document.querySelector(`[data-square="${from}"]`);
const toEl = document.querySelector(`[data-square="${to}"]`);
if (isPromotionMove(from, to)) {
const piece = await choosePromotion(fromEl.dataset.pieceColor);
if (!piece) return;
move += piece;
}
try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST',
@@ -892,11 +955,13 @@ async function pollOnce() {
function lockBoard() {
gameState.isLocked = true;
updateTurnIndicator('pending', gameState.turn);
updateGameControls();
}
function unlockBoard() {
gameState.isLocked = false;
updateTurnIndicator('', gameState.turn);
updateTurnIndicator(gameState.state, gameState.turn);
updateGameControls();
}
async function undoMoves() {
@@ -993,9 +1058,11 @@ function renderMoveHistory(moves) {
}
function updateGameDisplay(game) {
const wasOver = isGameOver(gameState.state);
gameState.fen = game.fen;
gameState.turn = game.turn;
gameState.state = game.state;
gameState.termination = game.termination || '';
gameState.moveList = game.moves || [];
renderBoardFromFEN(game.fen);
@@ -1026,10 +1093,131 @@ function updateGameDisplay(game) {
// Update undo button
document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2;
// Handle checkmate visually
if (game.state === 'white wins' || game.state === 'black wins') {
// Mark the mated king; a resignation leaves the board as it was
if ((game.state === 'white wins' || game.state === 'black wins') &&
(game.termination || 'checkmate') === 'checkmate') {
markMatedKing(game);
}
updateGameControls();
if (!wasOver && isGameOver(game.state)) {
flashMessage(describeResult(game.state, game.termination), 'info', 4000);
}
}
// describeResult names a finished game's outcome and how it was reached.
function describeResult(state, termination) {
const winner = state === 'white wins' ? 'White' : 'Black';
const loser = state === 'white wins' ? 'Black' : 'White';
switch (termination) {
case 'resignation': return `${loser} resigned — ${winner} wins`;
case 'stalemate': return 'Stalemate — draw';
case 'insufficient_material': return 'Draw — insufficient material';
case 'threefold_repetition': return 'Draw — threefold repetition';
case 'fifty_move_rule': return 'Draw — fifty-move rule';
case 'agreement': return 'Draw agreed';
}
switch (state) {
case 'white wins':
case 'black wins': return `Checkmate — ${winner} wins`;
case 'stalemate': return 'Stalemate — draw';
case 'draw': return 'Draw';
}
return 'Game over';
}
// Draw needs a game in progress with the board free; resigning only needs
// an unfinished game (also while the computer thinks or the engine is stuck).
function updateGameControls() {
const hasGame = !!gameState.gameId;
const idle = !gameState.isLocked && !gameState.actionBusy;
document.getElementById('draw-btn').disabled =
!hasGame || !idle || gameState.state !== 'ongoing';
document.getElementById('resign-btn').disabled =
!hasGame || gameState.actionBusy || isGameOver(gameState.state);
}
function playerColor() {
return gameState.isPlayerWhite ? 'w' : 'b';
}
async function offerDraw() {
if (gameState.actionBusy || !gameState.gameId) return;
gameState.actionBusy = true;
updateGameControls();
try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/draw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'offer', color: playerColor() })
});
if (response.status === 409) {
flashErrorMessage('Make a move before offering again', 2500);
return;
}
if (!response.ok) {
handleApiError('offer draw', null, response);
return;
}
const game = await response.json();
if (game.drawOutcome === 'declined') {
flashMessage('Draw declined', 'info', 2500);
}
gameState.actionBusy = false;
updateGameDisplay(game);
} catch (error) {
handleApiError('offer draw', error);
} finally {
gameState.actionBusy = false;
updateGameControls();
}
}
function showResignModal() {
if (!gameState.gameId || isGameOver(gameState.state)) return;
document.getElementById('resign-overlay').classList.add('show');
document.addEventListener('keydown', handleResignKeydown);
document.getElementById('resign-cancel-btn').focus();
}
function hideResignModal() {
document.getElementById('resign-overlay').classList.remove('show');
document.removeEventListener('keydown', handleResignKeydown);
}
function handleResignKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
hideResignModal();
}
}
async function resignGame() {
hideResignModal();
if (gameState.actionBusy || !gameState.gameId) return;
gameState.actionBusy = true;
updateGameControls();
try {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/resign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: playerColor() })
});
if (!response.ok) {
handleApiError('resign', null, response);
return;
}
const game = await response.json();
stopPolling(); // a computer move in flight is discarded by the server
gameState.isLocked = false;
gameState.actionBusy = false;
updateGameDisplay(game);
} catch (error) {
handleApiError('resign', error);
} finally {
gameState.actionBusy = false;
updateGameControls();
}
}
function markMatedKing(game) {
@@ -1135,10 +1323,17 @@ function handleApiError(action, error, response = null) {
}
function flashErrorMessage(message, duration = 1500) {
flashMessage(message, 'error', duration);
}
// Shows a short message over the status indicators; 'info' for results and
// answers to draw offers, 'error' for failures.
function flashMessage(message, type = 'error', duration = 1500) {
const overlay = document.getElementById('error-flash-overlay');
const messageEl = document.getElementById('error-flash-message');
messageEl.textContent = message;
messageEl.classList.toggle('info', type === 'info');
overlay.classList.add('show');
// Clear any pending timeout to avoid premature hide on rapid calls
@@ -48,6 +48,8 @@
<div class="controls">
<button id="new-game-btn" class="btn btn-primary">New Game</button>
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
<button id="draw-btn" class="btn btn-secondary" title="Offer a draw" disabled>Draw</button>
<button id="resign-btn" class="btn btn-secondary" title="Resign this game" disabled>Resign</button>
</div>
<div class="move-history-container">
@@ -147,6 +149,33 @@
</div>
</div>
<!-- Resign Confirmation -->
<div id="resign-overlay" class="modal-overlay">
<div class="modal confirm-modal" role="dialog" aria-modal="true" aria-labelledby="resign-title">
<h2 id="resign-title">Resign this game?</h2>
<div class="modal-buttons">
<button id="resign-confirm-btn" class="btn btn-primary">Resign</button>
<button id="resign-cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<!-- Promotion Modal -->
<div id="promotion-overlay" class="modal-overlay">
<div class="modal promotion-modal" role="dialog" aria-modal="true" aria-labelledby="promotion-title">
<h2 id="promotion-title">Promote to</h2>
<div class="promotion-choices">
<button class="promotion-choice" data-piece="q" title="Queen (Q)" aria-label="Queen">♛</button>
<button class="promotion-choice" data-piece="r" title="Rook (R)" aria-label="Rook">♜</button>
<button class="promotion-choice" data-piece="b" title="Bishop (B)" aria-label="Bishop">♝</button>
<button class="promotion-choice" data-piece="n" title="Knight (N)" aria-label="Knight">♞</button>
</div>
<div class="modal-buttons">
<button id="promotion-cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
@@ -379,6 +379,10 @@ main {
padding: 0.5rem;
}
.error-flash-message.info {
color: var(--tokyo-cyan);
}
/* Move History */
.move-history-container {
flex: 1 1 auto;
@@ -849,20 +853,21 @@ input[type="range"]:disabled {
margin: 0;
}
/* Four controls as a 2x2 grid; the column is too short for one per row. */
.controls {
grid-column: 1;
grid-row: 2 / 4;
grid-template-columns: 1fr;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0.5rem;
gap: 0.4rem;
align-self: stretch;
height: auto;
min-height: 80px;
}
.btn {
padding: 0.5rem;
font-size: 0.85rem;
padding: 0.4rem 0.25rem;
font-size: 0.8rem;
min-height: 35px;
height: 100%;
width: 100%;
@@ -907,3 +912,55 @@ input[type="range"]:disabled {
min-width: 0;
}
}
/* Promotion picker */
.promotion-modal {
max-width: 320px;
}
.promotion-choices {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.promotion-choice {
width: 56px;
height: 56px;
display: flex;
justify-content: center;
align-items: center;
font-size: 36px;
line-height: 1;
background: var(--square-light);
border: 2px solid var(--tokyo-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.promotion-choice:hover,
.promotion-choice:focus-visible {
outline: none;
border-color: var(--host-royal);
background: var(--square-selected);
}
.promotion-choices.white .promotion-choice {
color: var(--host-white);
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.promotion-choices.black .promotion-choice {
color: var(--host-bg);
text-shadow: 1px 1px 2px rgba(255,255,255,0.2);
}
/* Resign confirmation */
.confirm-modal {
max-width: 320px;
}
.confirm-modal h2 {
font-size: 1.2rem;
}