From 101cb40bdd4f4f4d9fcfd9e06ee8334e69f5ae72 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Mon, 1 Jun 2026 21:15:41 +0200 Subject: [PATCH] feat: add UnsafeMoves() method to expose pseudo-legal moves that leave own king in check Implements GitHub issue #110. - Add unsafeOnly parameter to standardMoves to filter by inCheck tag - Add engine.UnsafeMoves(), Position.UnsafeMoves(), and Game.UnsafeMoves() - Add comprehensive tests for pinned pieces, king moves into check, and starting position --- engine.go | 14 ++++++++++---- engine_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++--- game.go | 6 ++++++ game_test.go | 33 +++++++++++++++++++++++++++++++++ position.go | 6 ++++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/engine.go b/engine.go index 2c740209b..66b1ad3c4 100644 --- a/engine.go +++ b/engine.go @@ -37,11 +37,17 @@ type engine struct{} // Each move is validated to ensure it doesn't leave the king in check func (engine) CalcMoves(pos *Position, first bool) []Move { // generate possible moves - moves := standardMoves(pos, first) + moves := standardMoves(pos, first, false) // return moves including castles return append(moves, castleMoves(pos)...) } +// UnsafeMoves returns all pseudo-legal moves that are illegal because they +// leave the moving side's king in check. +func (engine) UnsafeMoves(pos *Position) []Move { + return standardMoves(pos, false, true) +} + // Status returns the current game status (Checkmate, Stalemate, or NoMethod) // based on the position. // @@ -89,7 +95,7 @@ var movePool = sync.Pool{ // // The function uses a sync.Pool of move arrays to reduce allocations. Each // move is validated to ensure it doesn't leave the king in check. -func standardMoves(pos *Position, first bool) []Move { +func standardMoves(pos *Position, first bool, unsafeOnly bool) []Move { moves, _ := movePool.Get().(*[maxPossibleMoves]Move) defer movePool.Put(moves) count := 0 @@ -132,7 +138,7 @@ func standardMoves(pos *Position, first bool) []Move { for _, pt := range promoPieceTypes { m.promo = pt addTags(&m, pos) - if !m.HasTag(inCheck) { + if m.HasTag(inCheck) == unsafeOnly { // Copy the valid move to the array moves[count] = m count++ @@ -147,7 +153,7 @@ func standardMoves(pos *Position, first bool) []Move { } else { m.promo = 0 addTags(&m, pos) - if !m.HasTag(inCheck) { + if m.HasTag(inCheck) == unsafeOnly { moves[count] = m count++ if first { diff --git a/engine_test.go b/engine_test.go index 8fb663fda..9a900d43e 100644 --- a/engine_test.go +++ b/engine_test.go @@ -44,7 +44,7 @@ func BenchmarkStandardMoves(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - moves := standardMoves(bm.pos, bm.wantFirst) + moves := standardMoves(bm.pos, bm.wantFirst, false) // Prevent compiler optimization if len(moves) == 0 { b.Fatal("unexpected zero moves") @@ -61,7 +61,7 @@ func BenchmarkStandardMoves_PawnPromotions(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - moves := standardMoves(pos, false) + moves := standardMoves(pos, false, false) if len(moves) == 0 { b.Fatal("unexpected zero moves") } @@ -85,7 +85,7 @@ func BenchmarkStandardMoves_BoardDensity(b *testing.B) { b.Run(p.name, func(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - moves := standardMoves(pos, false) + moves := standardMoves(pos, false, false) if len(moves) == 0 && p.name != "Empty" { b.Fatal("unexpected zero moves") } @@ -174,6 +174,46 @@ func TestAddTags(t *testing.T) { } } +func TestUnsafeMoves_StartingPosition(t *testing.T) { + pos := mustPosition("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + moves := engine{}.UnsafeMoves(pos) + if len(moves) != 0 { + t.Errorf("expected 0 unsafe moves in starting position, got %d", len(moves)) + } +} + +func TestUnsafeMoves_PinnedKnight(t *testing.T) { + pos := mustPosition("4k3/8/8/8/1b6/2N5/8/4K3 w - - 0 1") + moves := engine{}.UnsafeMoves(pos) + if len(moves) != 8 { + t.Fatalf("expected 8 unsafe moves for pinned knight, got %d", len(moves)) + } + for _, m := range moves { + if m.s1 != C3 { + t.Errorf("expected unsafe move from C3, got %s", m.s1.String()) + } + if !m.HasTag(inCheck) { + t.Errorf("expected unsafe move to have inCheck tag: %s", m.String()) + } + } +} + +func TestUnsafeMoves_KingIntoCheck(t *testing.T) { + pos := mustPosition("8/8/8/8/8/3r4/8/4K3 w - - 0 1") + moves := engine{}.UnsafeMoves(pos) + if len(moves) != 2 { + t.Fatalf("expected 2 unsafe moves, got %d", len(moves)) + } + expected := map[string]bool{"d1": true, "d2": true} + for _, m := range moves { + if m.s1 != E1 { + t.Errorf("expected move from E1, got %s", m.s1.String()) + } + if !expected[m.s2.String()] { + t.Errorf("unexpected unsafe move to %s", m.s2.String()) + } + } +} // Helper function to convert FEN to Position func mustPosition(fen string) *Position { fenObject, err := FEN(fen) diff --git a/game.go b/game.go index 32c7eba8a..1229ead1c 100644 --- a/game.go +++ b/game.go @@ -259,6 +259,12 @@ func (g *Game) ValidMoves() []Move { return g.pos.ValidMoves() } +// UnsafeMoves returns all pseudo-legal moves that leave the moving side's king in check. +// These moves are valid piece movements but illegal because they expose the king. +func (g *Game) UnsafeMoves() []Move { + return g.pos.UnsafeMoves() +} + // Moves returns the move history of the game following the main line. func (g *Game) Moves() []*Move { if g.rootMove == nil { diff --git a/game_test.go b/game_test.go index 31a22791b..376edb09a 100644 --- a/game_test.go +++ b/game_test.go @@ -2515,3 +2515,36 @@ func TestMoveHistoryFromPGN(t *testing.T) { } } } + +func TestGameUnsafeMoves(t *testing.T) { + game := NewGame() + if len(game.UnsafeMoves()) != 0 { + t.Errorf("expected 0 unsafe moves at start, got %d", len(game.UnsafeMoves())) + } + + // Verify delegation to Position + pos := game.Position() + if len(game.UnsafeMoves()) != len(pos.UnsafeMoves()) { + t.Errorf("Game.UnsafeMoves() length mismatch with Position.UnsafeMoves()") + } +} + +func TestEscapeTagValue(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "hello", "hello"}, + {"quote", `say "hi"`, `say \"hi\"`}, + {"backslash", `path\to`, `path\\to`}, + {"mixed", `a\"b`, `a\\\"b`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := escapeTagValue(tc.input); got != tc.want { + t.Errorf("escapeTagValue(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/position.go b/position.go index 0473d78cf..5bb867eda 100644 --- a/position.go +++ b/position.go @@ -148,6 +148,12 @@ func (pos *Position) ValidMoves() []Move { return append([]Move(nil), pos.validMoves...) } +// UnsafeMoves returns all pseudo-legal moves that are illegal because they leave +// the moving side's king in check. These moves should not be played via Move(). +func (pos *Position) UnsafeMoves() []Move { + return engine{}.UnsafeMoves(pos) +} + // Status returns the position's status as one of the outcome methods. // Possible returns values include Checkmate, Stalemate, and NoMethod. func (pos *Position) Status() Method {