Compare commits
46 Commits
ce6dd39af1
...
cec8aaf274
Author | SHA1 | Date |
---|---|---|
ekzyis | cec8aaf274 | |
ekzyis | f3cd10cbb8 | |
ekzyis | d7645edea9 | |
ekzyis | bf481bc452 | |
ekzyis | fe82dee3bb | |
ekzyis | ee4e1f0edf | |
ekzyis | 5729ae631a | |
ekzyis | 31d9ff02d5 | |
ekzyis | d1315b931b | |
ekzyis | 2dc7cbf213 | |
ekzyis | 0175ac077f | |
ekzyis | 7aa65f4ec0 | |
ekzyis | be247e1f46 | |
ekzyis | 908cc1cfda | |
ekzyis | aa072ca4aa | |
ekzyis | d98c0c221d | |
ekzyis | bbcd0b7725 | |
ekzyis | 4a3315eaa0 | |
ekzyis | bab667d0ed | |
ekzyis | e6f9c529f8 | |
ekzyis | 87856f694d | |
ekzyis | 6a60aee89a | |
ekzyis | cb92e66580 | |
ekzyis | 8f2dc00d9f | |
ekzyis | 6fefeb44f5 | |
ekzyis | a9a4216d4e | |
ekzyis | 8f70fcb552 | |
ekzyis | fddbd145f3 | |
ekzyis | d6ffd8a57d | |
ekzyis | 4d7ab3484d | |
ekzyis | e12946ca5d | |
ekzyis | 45c0dfce6f | |
ekzyis | d3966685db | |
ekzyis | 4e12b40478 | |
ekzyis | 516478d258 | |
ekzyis | 6468955c23 | |
ekzyis | 2d99320942 | |
ekzyis | 11b2238c86 | |
ekzyis | 9a6579b6f8 | |
ekzyis | 2c6018755f | |
ekzyis | 72da5d3afd | |
ekzyis | 677c50d007 | |
ekzyis | 7eea9d932f | |
ekzyis | e6b7582ae9 | |
ekzyis | 5a52d48ef8 | |
ekzyis | da103ef887 |
|
@ -0,0 +1,3 @@
|
|||
SN_BASE_URL=
|
||||
SN_API_TOKEN=
|
||||
SN_MEDIA_URL=
|
|
@ -1,2 +1,4 @@
|
|||
*.png
|
||||
!assets/*.png
|
||||
.env
|
||||
*.sqlite3
|
||||
|
|
1182
chess/board.go
1182
chess/board.go
File diff suppressed because it is too large
Load Diff
|
@ -4,9 +4,10 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ekzyis/sn-chess/chess"
|
||||
"github.com/ekzyis/chessbot/chess"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
@ -18,6 +19,8 @@ func init() {
|
|||
}
|
||||
|
||||
func TestBoardInitial(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertPiece(t, b, "a1", chess.Rook, chess.Light)
|
||||
|
@ -58,20 +61,24 @@ func TestBoardInitial(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBoardMovePawn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
b.Move("e4")
|
||||
assertParse(t, b, "e4")
|
||||
|
||||
assertNoPiece(t, b, "e2")
|
||||
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
|
||||
|
||||
b.Move("e5")
|
||||
assertParse(t, b, "e5")
|
||||
|
||||
assertNoPiece(t, b, "e7")
|
||||
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
|
||||
}
|
||||
|
||||
func TestBoardMovePawnInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "a5", "no pawn found that can move to a5")
|
||||
|
@ -83,7 +90,7 @@ func TestBoardMovePawnInvalid(t *testing.T) {
|
|||
assertMoveError(t, b, "g5", "no pawn found that can move to g5")
|
||||
assertMoveError(t, b, "h5", "no pawn found that can move to h5")
|
||||
|
||||
b.Move("d4")
|
||||
assertParse(t, b, "d4")
|
||||
|
||||
assertMoveError(t, b, "a4", "no pawn found that can move to a4")
|
||||
assertMoveError(t, b, "b4", "no pawn found that can move to b4")
|
||||
|
@ -95,83 +102,437 @@ func TestBoardMovePawnInvalid(t *testing.T) {
|
|||
assertMoveError(t, b, "h4", "no pawn found that can move to h4")
|
||||
}
|
||||
|
||||
func TestBoardMoveKnight(t *testing.T) {
|
||||
func TestBoardMovePawnCapture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
b.Move("Nf3")
|
||||
assertParse(t, b, "e4 d5 exd5")
|
||||
|
||||
assertNoPiece(t, b, "e4")
|
||||
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
|
||||
|
||||
// test ambiguous capture
|
||||
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "c4 d5 e4 e5 exd5")
|
||||
|
||||
assertNoPiece(t, b, "e4")
|
||||
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "c4", chess.Pawn, chess.Light)
|
||||
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "c4 d5 e4 e5 cxd5")
|
||||
|
||||
assertNoPiece(t, b, "c4")
|
||||
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
|
||||
}
|
||||
|
||||
func TestBoardPawnPromotion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=Q")
|
||||
|
||||
assertPiece(t, b, "a8", chess.Queen, chess.Light)
|
||||
assertNoPiece(t, b, "b7")
|
||||
|
||||
b = chess.NewBoard()
|
||||
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=R")
|
||||
|
||||
assertPiece(t, b, "a8", chess.Rook, chess.Light)
|
||||
assertNoPiece(t, b, "b7")
|
||||
|
||||
b = chess.NewBoard()
|
||||
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=B")
|
||||
|
||||
assertPiece(t, b, "a8", chess.Bishop, chess.Light)
|
||||
assertNoPiece(t, b, "b7")
|
||||
|
||||
b = chess.NewBoard()
|
||||
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=N")
|
||||
|
||||
assertPiece(t, b, "a8", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "b7")
|
||||
|
||||
b = chess.NewBoard()
|
||||
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3")
|
||||
|
||||
assertMoveError(t, b, "bxa8=K", "invalid promotion: K")
|
||||
}
|
||||
|
||||
func TestBoardMoveKnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "Nf3")
|
||||
assertPiece(t, b, "f3", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "g1")
|
||||
|
||||
b.Move("Nh6")
|
||||
|
||||
assertParse(t, b, "Nh6")
|
||||
assertPiece(t, b, "h6", chess.Knight, chess.Dark)
|
||||
assertNoPiece(t, b, "g8")
|
||||
|
||||
assertParse(t, b, "Nc3")
|
||||
assertPiece(t, b, "c3", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "b1")
|
||||
|
||||
assertParse(t, b, "Na6")
|
||||
assertPiece(t, b, "a6", chess.Knight, chess.Dark)
|
||||
assertNoPiece(t, b, "b8")
|
||||
|
||||
assertParse(t, b, "Nh4")
|
||||
assertPiece(t, b, "h4", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "f3")
|
||||
|
||||
assertParse(t, b, "Nf5")
|
||||
assertPiece(t, b, "f5", chess.Knight, chess.Dark)
|
||||
assertNoPiece(t, b, "h6")
|
||||
|
||||
assertParse(t, b, "Na4")
|
||||
assertPiece(t, b, "a4", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "c3")
|
||||
|
||||
assertParse(t, b, "Nc5")
|
||||
assertPiece(t, b, "c5", chess.Knight, chess.Dark)
|
||||
assertNoPiece(t, b, "a6")
|
||||
}
|
||||
|
||||
func TestBoardMoveKnightInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
// out of reach
|
||||
assertMoveError(t, b, "Ng3", "no knight found that can move to g3")
|
||||
assertMoveError(t, b, "Nb3", "no knight found that can move to b3")
|
||||
|
||||
// blocked by own piece
|
||||
assertMoveError(t, b, "Ng1", "g1 blocked by white knight")
|
||||
assertMoveError(t, b, "Nd2", "d2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Ne2", "e2 blocked by white pawn")
|
||||
|
||||
assertParse(t, b, "Nf3")
|
||||
|
||||
assertMoveError(t, b, "Ng6", "no knight found that can move to g6")
|
||||
assertMoveError(t, b, "Nb6", "no knight found that can move to b6")
|
||||
|
||||
assertMoveError(t, b, "Ne7", "e7 blocked by black pawn")
|
||||
assertMoveError(t, b, "Nd7", "d7 blocked by black pawn")
|
||||
|
||||
// ambiguous moves
|
||||
|
||||
b = chess.NewBoard()
|
||||
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4")
|
||||
assertMoveError(t, b, "Nxd4", "move ambiguous: 2 knights can move to d4")
|
||||
assertMoveError(t, b, "N4xd4", "no knight found that can move to d4")
|
||||
// disambiguate via file
|
||||
assertParse(t, b, "Nfxd4")
|
||||
|
||||
b = chess.NewBoard()
|
||||
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4")
|
||||
// disambiguate via rank
|
||||
assertParse(t, b, "N3xd4")
|
||||
}
|
||||
|
||||
func TestBoardMoveKnightCapture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 Nf6 d4 Nxe4")
|
||||
assertPiece(t, b, "e4", chess.Knight, chess.Dark)
|
||||
assertPiece(t, b, "d4", chess.Pawn, chess.Light)
|
||||
assertNoPiece(t, b, "g8")
|
||||
assertNoPiece(t, b, "e2")
|
||||
assertNoPiece(t, b, "d2")
|
||||
|
||||
// test ambiguous capture
|
||||
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4 Nbxd4")
|
||||
|
||||
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
|
||||
assertPiece(t, b, "d4", chess.Knight, chess.Light)
|
||||
assertPiece(t, b, "f3", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "g1")
|
||||
assertNoPiece(t, b, "b1")
|
||||
assertNoPiece(t, b, "c3")
|
||||
assertNoPiece(t, b, "d6")
|
||||
assertNoPiece(t, b, "d5")
|
||||
assertNoPiece(t, b, "b5")
|
||||
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4 Nfxd4")
|
||||
|
||||
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
|
||||
assertPiece(t, b, "d4", chess.Knight, chess.Light)
|
||||
assertPiece(t, b, "b5", chess.Knight, chess.Light)
|
||||
assertNoPiece(t, b, "g1")
|
||||
assertNoPiece(t, b, "b1")
|
||||
assertNoPiece(t, b, "c3")
|
||||
assertNoPiece(t, b, "d6")
|
||||
assertNoPiece(t, b, "d5")
|
||||
assertNoPiece(t, b, "f3")
|
||||
}
|
||||
|
||||
func TestBoardMoveBishop(t *testing.T) {
|
||||
b := chess.NewBoard()
|
||||
t.Parallel()
|
||||
|
||||
b.Move("Bc4")
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Bc4", "no bishop found that can move to c4")
|
||||
|
||||
b.Move("e3")
|
||||
b.Move("e6")
|
||||
assertParse(t, b, "e3")
|
||||
assertParse(t, b, "e6")
|
||||
|
||||
b.Move("Bc4")
|
||||
assertParse(t, b, "Bc4")
|
||||
|
||||
assertPiece(t, b, "c4", chess.Bishop, chess.Light)
|
||||
assertNoPiece(t, b, "f1")
|
||||
|
||||
b.Move("Bc5")
|
||||
assertParse(t, b, "Bc5")
|
||||
|
||||
assertPiece(t, b, "c5", chess.Bishop, chess.Dark)
|
||||
assertNoPiece(t, b, "f8")
|
||||
}
|
||||
|
||||
func TestBoardMoveRook(t *testing.T) {
|
||||
func TestBoardMoveBishopInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
b.Move("Ra3")
|
||||
assertMoveError(t, b, "Bc3", "no bishop found that can move to c3")
|
||||
assertMoveError(t, b, "Bc2", "c2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Bb2", "b2 blocked by white pawn")
|
||||
|
||||
// path blocked by white pawn at e2
|
||||
assertMoveError(t, b, "Bd3", "no bishop found that can move to d3")
|
||||
}
|
||||
|
||||
func TestBoardMoveBishopCapture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Bc4 d5 Bxd5")
|
||||
|
||||
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
|
||||
assertPiece(t, b, "d5", chess.Bishop, chess.Light)
|
||||
assertNoPiece(t, b, "f1")
|
||||
assertNoPiece(t, b, "e2")
|
||||
assertNoPiece(t, b, "e7")
|
||||
assertNoPiece(t, b, "d7")
|
||||
|
||||
// bishop captures are never ambiguous because they can only move on same color
|
||||
}
|
||||
|
||||
func TestBoardMoveRook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Ra3", "no rook found that can move to a3")
|
||||
|
||||
b.Move("a4")
|
||||
b.Move("a5")
|
||||
|
||||
b.Move("Ra3")
|
||||
assertParse(t, b, "a4 a5 Ra3")
|
||||
|
||||
assertPiece(t, b, "a3", chess.Rook, chess.Light)
|
||||
assertNoPiece(t, b, "a1")
|
||||
|
||||
b.Move("Ra6")
|
||||
assertParse(t, b, "Ra6")
|
||||
|
||||
assertPiece(t, b, "a6", chess.Rook, chess.Dark)
|
||||
assertNoPiece(t, b, "a8")
|
||||
}
|
||||
|
||||
func TestBoardMoveQueen(t *testing.T) {
|
||||
func TestBoardMoveRookCapture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
b.Move("Qd3")
|
||||
assertParse(t, b, "a4 e6 h4 e5 Ra3 e4 Rhh3 e3 Raxe3")
|
||||
|
||||
assertPiece(t, b, "e3", chess.Rook, chess.Light)
|
||||
assertPiece(t, b, "h3", chess.Rook, chess.Light)
|
||||
assertNoPiece(t, b, "a3")
|
||||
}
|
||||
|
||||
func TestBoardMoveRookInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Rb2", "b2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Rb1", "b1 blocked by white knight")
|
||||
assertMoveError(t, b, "Ra2", "a2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Ra3", "no rook found that can move to a3")
|
||||
|
||||
assertParse(t, b, "e3 e6 a4 d6 Ra3")
|
||||
|
||||
// path blocked by pawn at d3
|
||||
assertMoveError(t, b, "Rh3", "no rook found that can move to h3")
|
||||
|
||||
// ambiguous moves
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "a4 e6 h4 e5 Ra3 e4")
|
||||
|
||||
assertMoveError(t, b, "Rh3", "move ambiguous: 2 rooks can move to h3")
|
||||
assertParse(t, b, "Rhh3")
|
||||
}
|
||||
|
||||
func TestBoardMoveQueen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Qd3", "no queen found that can move to d3")
|
||||
|
||||
b.Move("d4")
|
||||
b.Move("d5")
|
||||
assertParse(t, b, "d4")
|
||||
assertParse(t, b, "d5")
|
||||
|
||||
b.Move("Qd3")
|
||||
assertParse(t, b, "Qd3")
|
||||
|
||||
assertPiece(t, b, "d3", chess.Queen, chess.Light)
|
||||
assertNoPiece(t, b, "d1")
|
||||
|
||||
b.Move("Qd6")
|
||||
assertParse(t, b, "Qd6")
|
||||
|
||||
assertPiece(t, b, "d6", chess.Queen, chess.Dark)
|
||||
assertNoPiece(t, b, "d8")
|
||||
}
|
||||
|
||||
func TestBoardMoveQueenInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Qd2", "d2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Qd1", "d1 blocked by white queen")
|
||||
assertMoveError(t, b, "Qe1", "e1 blocked by white king")
|
||||
assertMoveError(t, b, "Qc1", "c1 blocked by white bishop")
|
||||
|
||||
// path blocked by white pawn at d2
|
||||
assertMoveError(t, b, "Qd3", "no queen found that can move to d3")
|
||||
|
||||
// TODO: ambiguous queen moves require pawn promotion
|
||||
}
|
||||
|
||||
func TestBoardMoveKing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Ke2 Ke7 Kf3 Kd6 Kg3 Kc6")
|
||||
assertNoPiece(t, b, "e1")
|
||||
assertNoPiece(t, b, "e8")
|
||||
assertNoPiece(t, b, "e2")
|
||||
assertNoPiece(t, b, "e7")
|
||||
assertNoPiece(t, b, "f3")
|
||||
assertNoPiece(t, b, "d6")
|
||||
|
||||
assertPiece(t, b, "g3", chess.King, chess.Light)
|
||||
assertPiece(t, b, "c6", chess.King, chess.Dark)
|
||||
}
|
||||
|
||||
func TestBoardMoveKingInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertMoveError(t, b, "Ke1", "e1 blocked by white king")
|
||||
assertMoveError(t, b, "Ke2", "e2 blocked by white pawn")
|
||||
assertMoveError(t, b, "Ke3", "no king found that can move to e3")
|
||||
}
|
||||
|
||||
func TestBoardCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assert.False(t, b.InCheck())
|
||||
|
||||
assertParse(t, b, "e4 e5 Qh5 Nc6 Qxf7")
|
||||
|
||||
assert.True(t, b.InCheck())
|
||||
assert.True(t, strings.HasSuffix(b.Moves[len(b.Moves)-1], "+"), "check move should end with +")
|
||||
|
||||
assertMoveError(t, b, "Nf6", "invalid move Nf6: king is in check")
|
||||
assertMoveError(t, b, "Ke7", "invalid move Ke7: king is in check")
|
||||
|
||||
assertParse(t, b, "Kxf7")
|
||||
}
|
||||
|
||||
func TestBoardPin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "d4 e5 Nc3 Bb4")
|
||||
|
||||
assertMoveError(t, b, "Ne4", "invalid move Ne4: king is in check")
|
||||
}
|
||||
|
||||
func TestBoardCastle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Nf3 Nf6 Be2 Be7 O-O")
|
||||
|
||||
assertPiece(t, b, "f1", chess.Rook, chess.Light)
|
||||
assertPiece(t, b, "g1", chess.King, chess.Light)
|
||||
assertNoPiece(t, b, "h1")
|
||||
assertNoPiece(t, b, "e1")
|
||||
|
||||
assertParse(t, b, "O-O")
|
||||
|
||||
assertPiece(t, b, "f8", chess.Rook, chess.Dark)
|
||||
assertPiece(t, b, "g8", chess.King, chess.Dark)
|
||||
assertNoPiece(t, b, "h8")
|
||||
assertNoPiece(t, b, "e8")
|
||||
|
||||
b = chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "e4 e5 Qg4 d6 d3 d5 Be3 d4 Nc3 dxe3 O-O-O")
|
||||
|
||||
assertPiece(t, b, "d1", chess.Rook, chess.Light)
|
||||
assertPiece(t, b, "c1", chess.King, chess.Light)
|
||||
assertNoPiece(t, b, "a1")
|
||||
assertNoPiece(t, b, "e1")
|
||||
}
|
||||
|
||||
func TestBoardParseAlgebraicNotation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
assertParse(t, b, "1.d4 d5 2.c4 e6")
|
||||
|
||||
assertPiece(t, b, "d4", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "d5", chess.Pawn, chess.Dark)
|
||||
assertPiece(t, b, "c4", chess.Pawn, chess.Light)
|
||||
assertPiece(t, b, "e6", chess.Pawn, chess.Dark)
|
||||
|
||||
assertNoPiece(t, b, "d2")
|
||||
assertNoPiece(t, b, "d7")
|
||||
assertNoPiece(t, b, "c2")
|
||||
assertNoPiece(t, b, "e7")
|
||||
}
|
||||
|
||||
func assertParse(t *testing.T, b *chess.Board, moves string) {
|
||||
assert.NoError(t, b.Parse(moves))
|
||||
}
|
||||
|
||||
func assertPiece(t *testing.T, b *chess.Board, position string, name chess.PieceName, color chess.Color) {
|
||||
p := b.At(position)
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package chess_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ekzyis/chessbot/chess"
|
||||
)
|
||||
|
||||
func TestGame001(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := chess.NewBoard()
|
||||
|
||||
// this used to not parse because of the + at the end
|
||||
assertParse(t, b, "d4 d5 Bf4 Nf6 e3 Ne4 Nc3 Nf2 Kxf2 e6 Qg4 Be7 Re1 O-O Kg3 Bh4+")
|
||||
|
||||
// this used to crash because of an out of bounds error (position was parsed as '6')
|
||||
assertMoveError(t, b, "Qex6", "square does not exist")
|
||||
}
|
|
@ -16,6 +16,34 @@ type Piece struct {
|
|||
Image image.Image
|
||||
}
|
||||
|
||||
func (p *Piece) String() string {
|
||||
n := ""
|
||||
switch p.Name {
|
||||
case Pawn:
|
||||
n = "pawn"
|
||||
case Knight:
|
||||
n = "knight"
|
||||
case Bishop:
|
||||
n = "bishop"
|
||||
case Rook:
|
||||
n = "rook"
|
||||
case Queen:
|
||||
n = "queen"
|
||||
case King:
|
||||
n = "king"
|
||||
}
|
||||
|
||||
c := ""
|
||||
switch p.Color {
|
||||
case Light:
|
||||
c = "white"
|
||||
case Dark:
|
||||
c = "black"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s", c, n)
|
||||
}
|
||||
|
||||
type PieceName string
|
||||
|
||||
const (
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package chess
|
||||
|
||||
type Square struct {
|
||||
X int
|
||||
Y int
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
sn "github.com/ekzyis/snappy"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
db = getDb()
|
||||
)
|
||||
|
||||
func getDb() *sql.DB {
|
||||
var (
|
||||
db *sql.DB
|
||||
err error
|
||||
)
|
||||
if db, err = sql.Open("sqlite3", "chessbot.sqlite3?_foreign_keys=on"); err != nil {
|
||||
log.Fatal(err)
|
||||
} else {
|
||||
if err = migrate(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES items(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ItemHasReply(parentId int, userId int) (bool, error) {
|
||||
var (
|
||||
count int
|
||||
err error
|
||||
)
|
||||
|
||||
if err = db.QueryRow(`SELECT COUNT(1) FROM items WHERE parent_id = ? AND user_id = ?`, parentId, userId).Scan(&count); err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// check if parent already exists, this means we ignored it
|
||||
if err = db.QueryRow(`SELECT COUNT(1) FROM items WHERE id = ?`, parentId).Scan(&count); err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log.Printf("ignoring known item %d", parentId)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func InsertItem(item *sn.Item) error {
|
||||
if _, err := db.Exec(``+
|
||||
`INSERT INTO items(id, user_id, text, parent_id) VALUES (?, ?, ?, NULLIF(?, 0)) `+
|
||||
`ON CONFLICT DO UPDATE SET text = EXCLUDED.text, updated_at = CURRENT_TIMESTAMP`,
|
||||
item.Id, item.User.Id, item.Text, item.ParentId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetThread(id int) ([]sn.Item, error) {
|
||||
var (
|
||||
items []sn.Item
|
||||
err error
|
||||
)
|
||||
|
||||
var item sn.Item
|
||||
item.ParentId = id
|
||||
|
||||
for item.ParentId > 0 {
|
||||
// TODO: can't select created_at because sqlite3 doesn't support timestamps natively
|
||||
// see https://github.com/mattn/go-sqlite3/issues/142
|
||||
if err = db.QueryRow(
|
||||
`SELECT id, user_id, text, COALESCE(parent_id, 0) FROM items WHERE id = ?`, item.ParentId).
|
||||
Scan(&item.Id, &item.User.Id, &item.Text, &item.ParentId); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.New("item not found in db")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = append([]sn.Item{item}, items...)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
5
go.mod
5
go.mod
|
@ -1,8 +1,10 @@
|
|||
module github.com/ekzyis/sn-chess
|
||||
module github.com/ekzyis/chessbot
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/image v0.20.0
|
||||
)
|
||||
|
@ -10,5 +12,6 @@ require (
|
|||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/guregu/null.v4 v4.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
6
go.sum
6
go.sum
|
@ -1,5 +1,9 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13 h1:yxDqMo4HzCVzhxPNTBpST0KVVHVMwGouzwfXsb4WYw4=
|
||||
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13/go.mod h1:UksYI0dU0+cnzz0LQjWB1P0QQP/ghx47e4atP99a5Lk=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
|
@ -8,5 +12,7 @@ golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
|||
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
|
||||
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
271
main.go
271
main.go
|
@ -1,13 +1,278 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/ekzyis/sn-chess/chess"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ekzyis/chessbot/chess"
|
||||
"github.com/ekzyis/chessbot/db"
|
||||
"github.com/ekzyis/chessbot/sn"
|
||||
)
|
||||
|
||||
var (
|
||||
c = sn.GetClient()
|
||||
// TODO: fetch our id from SN API
|
||||
meId = 21858
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
for {
|
||||
tickGameStart(c)
|
||||
tickGameProgress(c)
|
||||
time.Sleep(15 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func tickGameStart(c *sn.Client) {
|
||||
var (
|
||||
b = chess.NewBoard()
|
||||
mentions []sn.Notification
|
||||
err error
|
||||
)
|
||||
|
||||
b.Save("board.png")
|
||||
if mentions, err = c.Mentions(); err != nil {
|
||||
log.Printf("failed to fetch mentions: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("fetched %d mention(s)\n", len(mentions))
|
||||
|
||||
for _, n := range mentions {
|
||||
|
||||
if exists, err := db.ItemHasReply(n.Item.Id, meId); err != nil {
|
||||
log.Printf("failed to check for existing reply to game start in item %d: %v\n", n.Item.Id, err)
|
||||
continue
|
||||
} else if exists {
|
||||
// TODO: check if move changed
|
||||
log.Printf("reply to game start in item %d already exists\n", n.Item.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = handleGameStart(&n.Item); err != nil {
|
||||
|
||||
// don't reply to mentions that we failed to parse as a game start
|
||||
// to support unrelated mentions
|
||||
if err.Error() == "failed to parse game start" {
|
||||
log.Printf("ignoring error for item %d: %v\n", n.Item.Id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err2 := createComment(n.Item.Id, fmt.Sprintf("`%v`", err)); err2 != nil {
|
||||
log.Printf("failed to reply with error to item %d: %v\n", n.Item.Id, err2)
|
||||
} else {
|
||||
log.Printf("replied to game start in item %d with error: %v\n", n.Item.Id, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("started new game via item %d\n", n.Item.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tickGameProgress(c *sn.Client) {
|
||||
var (
|
||||
replies []sn.Notification
|
||||
err error
|
||||
)
|
||||
|
||||
if replies, err = c.Replies(); err != nil {
|
||||
log.Printf("failed to fetch replies: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("fetched %d replies\n", len(replies))
|
||||
|
||||
for _, n := range replies {
|
||||
|
||||
if exists, err := db.ItemHasReply(n.Item.Id, meId); err != nil {
|
||||
log.Printf("failed to check for existing reply to game update in item %d: %v\n", n.Item.Id, err)
|
||||
continue
|
||||
} else if exists {
|
||||
// TODO: check if move changed
|
||||
log.Printf("reply to game update in item %d already exists\n", n.Item.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = handleGameProgress(&n.Item); err != nil {
|
||||
|
||||
if err.Error() == "failed to parse game update" {
|
||||
log.Printf("ignoring error for item %d: %v\n", n.Item.Id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err2 := createComment(n.Item.Id, fmt.Sprintf("`%v`", err)); err2 != nil {
|
||||
log.Printf("failed to reply with error to item %d: %v\n", n.Item.Id, err2)
|
||||
} else {
|
||||
log.Printf("replied to game start in item %d with error: %v\n", n.Item.Id, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("updated game via item %d\n", n.Item.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleGameStart(req *sn.Item) error {
|
||||
var (
|
||||
move string
|
||||
b *chess.Board
|
||||
imgUrl string
|
||||
res string
|
||||
err error
|
||||
)
|
||||
|
||||
// Immediately save game start request to db so we can store our reply to it in case of error.
|
||||
// We set parentId to 0 such that parent_id will be NULL in the db and not hit foreign key constraints.
|
||||
req.ParentId = 0
|
||||
|
||||
if err = db.InsertItem(req); err != nil {
|
||||
return fmt.Errorf("failed to insert item %d into db: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
if move, err = parseGameStart(req.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create board with initial move(s)
|
||||
if b, err = chess.NewGame(move); err != nil {
|
||||
if rand.Float32() > 0.99 {
|
||||
// easter egg error message
|
||||
return errors.New("Nice try, fed.")
|
||||
}
|
||||
return fmt.Errorf("failed to create new game from item %d: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
// upload image of board
|
||||
if imgUrl, err = c.UploadImage(b.Image()); err != nil {
|
||||
return fmt.Errorf("failed to upload image for item %d: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
// reply with algebraic notation and image
|
||||
res = strings.Trim(fmt.Sprintf("%s\n\n%s", b.AlgebraicNotation(), imgUrl), " ")
|
||||
if _, err = createComment(req.Id, res); err != nil {
|
||||
return fmt.Errorf("failed to reply to item %d: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGameProgress(req *sn.Item) error {
|
||||
var (
|
||||
thread []sn.Item
|
||||
b = chess.NewBoard()
|
||||
move string = strings.Trim(req.Text, " ")
|
||||
imgUrl string
|
||||
res string
|
||||
err error
|
||||
)
|
||||
|
||||
// immediately save game update request to db so we can store our reply to it in case of error
|
||||
if err = db.InsertItem(req); err != nil {
|
||||
return fmt.Errorf("failed to insert item %d into db: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
// fetch thread to reconstruct all moves so far
|
||||
if thread, err = db.GetThread(req.ParentId); err != nil {
|
||||
return fmt.Errorf("failed to fetch thread for item %d: %v\n", req.ParentId, err)
|
||||
}
|
||||
|
||||
for _, item := range thread {
|
||||
if item.User.Id == meId {
|
||||
continue
|
||||
}
|
||||
|
||||
var moves string
|
||||
if moves, err = parseGameProgress(item.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse and execute existing moves
|
||||
if err = b.Parse(moves); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// parse and execute new move
|
||||
|
||||
if move, err = parseGameProgress(move); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = b.Parse(move); err != nil {
|
||||
if rand.Float32() > 0.99 {
|
||||
// easter egg error message
|
||||
return errors.New("Nice try, fed.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// upload image of updated board
|
||||
if imgUrl, err = c.UploadImage(b.Image()); err != nil {
|
||||
return fmt.Errorf("failed to upload image for item %d: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
// reply with algebraic notation and image
|
||||
res = strings.Trim(fmt.Sprintf("%s\n\n%s", b.AlgebraicNotation(), imgUrl), " ")
|
||||
if _, err = createComment(req.Id, res); err != nil {
|
||||
return fmt.Errorf("failed to reply to item %d: %v\n", req.Id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createComment(parentId int, text string) (*sn.Item, error) {
|
||||
var (
|
||||
commentId int
|
||||
err error
|
||||
)
|
||||
|
||||
if commentId, err = c.CreateComment(parentId, text); err != nil {
|
||||
return nil, fmt.Errorf("failed to reply to item %d: %v\n", parentId, err)
|
||||
}
|
||||
|
||||
var comment *sn.Item
|
||||
if comment, err = c.Item(commentId); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch item %d: %v\n", commentId, err)
|
||||
}
|
||||
|
||||
if err = db.InsertItem(comment); err != nil {
|
||||
return nil, fmt.Errorf("failed to insert item %d into db: %v\n", err, comment.Id)
|
||||
}
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func parseGameStart(input string) (string, error) {
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
line = strings.Trim(line, " ")
|
||||
|
||||
if !strings.HasPrefix(line, "@chess") {
|
||||
continue
|
||||
}
|
||||
|
||||
return strings.Trim(strings.ReplaceAll(line, "@chess", ""), " "), nil
|
||||
}
|
||||
|
||||
return "", errors.New("failed to parse game start")
|
||||
}
|
||||
|
||||
func parseGameProgress(input string) (string, error) {
|
||||
lines := strings.Split(input, "\n")
|
||||
words := strings.Split(input, " ")
|
||||
|
||||
if len(lines) == 1 && len(words) == 1 {
|
||||
return strings.Trim(input, " "), nil
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
if !strings.Contains(line, "@chess") {
|
||||
continue
|
||||
}
|
||||
|
||||
return strings.Trim(strings.ReplaceAll(line, "@chess", ""), " "), nil
|
||||
}
|
||||
|
||||
return "", errors.New("failed to parse game update")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package sn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
snappy "github.com/ekzyis/snappy"
|
||||
)
|
||||
|
||||
type Client = snappy.Client
|
||||
type Notification = snappy.Notification
|
||||
type Item = snappy.Item
|
||||
|
||||
var (
|
||||
c *Client
|
||||
)
|
||||
|
||||
func GetClient() *Client {
|
||||
loadEnv()
|
||||
|
||||
if c == nil {
|
||||
c = snappy.NewClient(
|
||||
snappy.WithBaseUrl(os.Getenv("SN_BASE_URL")),
|
||||
snappy.WithApiKey(os.Getenv("SN_API_KEY")),
|
||||
snappy.WithMediaUrl(os.Getenv("SN_MEDIA_URL")),
|
||||
)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func loadEnv() {
|
||||
var (
|
||||
f *os.File
|
||||
s *bufio.Scanner
|
||||
err error
|
||||
)
|
||||
|
||||
if f, err = os.Open(".env"); err != nil {
|
||||
log.Fatalf("error opening .env: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s = bufio.NewScanner(f)
|
||||
s.Split(bufio.ScanLines)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
|
||||
// Check if we have exactly 2 parts (key and value)
|
||||
if len(parts) == 2 {
|
||||
os.Setenv(parts[0], parts[1])
|
||||
} else {
|
||||
log.Fatalf(".env: invalid line: %s\n", line)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors during scanning
|
||||
if err = s.Err(); err != nil {
|
||||
fmt.Println("error scanning .env:", err)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue