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