Skip to content

Commit 0835989

Browse files
meruidenclaude
andauthored
fix: comply with FIDE 9.2.2 in samePosition en passant check (#98)
* fix: only consider en passant square in samePosition when capture is legal Per FIDE Article 9.2.2, positions are the same if "the possible moves of all the pieces are the same". The en passant square should only be considered when determining position equality if an en passant capture is actually possible — that is, there is an opponent pawn on an adjacent file that could make the capture. Previously, samePosition compared en passant squares directly, which could treat positions as different even when no pawn could actually capture en passant. This caused incorrect threefold repetition detection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reference correct FIDE article (9.2.3, not 9.2.2) * fix: reference correct FIDE article 9.2.3 in test --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 88e53c8 commit 0835989

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

position.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,10 +441,59 @@ func (pos *Position) updateEnPassantSquare(m *Move) Square {
441441
return NoSquare
442442
}
443443

444-
// samePosition returns true if the two positions are the same.
444+
// samePosition returns true if the two positions are the same
445+
// according to FIDE Article 9.2.3. The en passant square is only
446+
// considered if an en passant capture is actually possible (i.e.,
447+
// there is an opponent pawn on an adjacent file that could capture)
448+
// per FIDE Article 9.2.3.1.
445449
func (pos *Position) samePosition(pos2 *Position) bool {
446450
return pos.board.String() == pos2.board.String() &&
447451
pos.turn == pos2.turn &&
448452
pos.castleRights.String() == pos2.castleRights.String() &&
449-
pos.enPassantSquare == pos2.enPassantSquare
453+
pos.relevantEnPassantSquare() == pos2.relevantEnPassantSquare()
454+
}
455+
456+
// relevantEnPassantSquare returns the en passant square only if
457+
// an en passant capture is actually possible. Per FIDE rules,
458+
// the en passant square is only relevant if there is an opponent
459+
// pawn that can make the capture.
460+
func (pos *Position) relevantEnPassantSquare() Square {
461+
if pos.enPassantSquare == NoSquare {
462+
return NoSquare
463+
}
464+
// The en passant square is the square the capturing pawn moves TO.
465+
// The capturing pawn must be on an adjacent file, on the same rank
466+
// as the pawn that just advanced two squares.
467+
//
468+
// If the en passant square is on rank 3, the capturing pawn (black)
469+
// must be on rank 4. If on rank 6, the capturing pawn (white)
470+
// must be on rank 5.
471+
epFile := pos.enPassantSquare.File()
472+
epRank := pos.enPassantSquare.Rank()
473+
474+
var captureRank Rank
475+
var capturingPawn Piece
476+
if epRank == Rank3 {
477+
captureRank = Rank4
478+
capturingPawn = BlackPawn
479+
} else {
480+
captureRank = Rank5
481+
capturingPawn = WhitePawn
482+
}
483+
484+
// Check adjacent files for a pawn that could capture
485+
if epFile > FileA {
486+
sq := NewSquare(epFile-1, captureRank)
487+
if pos.board.Piece(sq) == capturingPawn {
488+
return pos.enPassantSquare
489+
}
490+
}
491+
if epFile < FileH {
492+
sq := NewSquare(epFile+1, captureRank)
493+
if pos.board.Piece(sq) == capturingPawn {
494+
return pos.enPassantSquare
495+
}
496+
}
497+
498+
return NoSquare
450499
}

position_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,65 @@ func TestPositionPly(t *testing.T) {
8585
}
8686
}
8787
}
88+
89+
func TestSamePositionEnPassantFIDECompliance(t *testing.T) {
90+
// FIDE Article 9.2.3: positions are the same only if "the possible
91+
// moves of all the pieces are the same". Per Article 9.2.3.1, an en
92+
// passant square should only matter when a pawn could have been
93+
// captured en passant (i.e., the capture is actually possible).
94+
95+
// Position with en passant square set but no pawn can capture:
96+
// White pawn on e4 (just pushed e2-e4), en passant square e3,
97+
// but no black pawn on d4 or f4 to capture.
98+
posWithIrrelevantEP, err := decodeFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
103+
// Same board position but without en passant square set.
104+
posWithoutEP, err := decodeFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1")
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
109+
// These should be considered the same position because no en passant
110+
// capture is possible (no black pawn on d4 or f4).
111+
if !posWithIrrelevantEP.samePosition(posWithoutEP) {
112+
t.Error("positions with irrelevant en passant square should be considered the same")
113+
}
114+
115+
// Position where en passant IS possible:
116+
// White pawn on e4, black pawn on d4. En passant square e3.
117+
// Black pawn on d4 can capture en passant on e3.
118+
posWithRelevantEP, err := decodeFEN("rnbqkbnr/ppp1pppp/8/8/3pP3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
123+
// Same board but without en passant square.
124+
posWithRelevantNoEP, err := decodeFEN("rnbqkbnr/ppp1pppp/8/8/3pP3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1")
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
129+
// These should NOT be considered the same because the en passant
130+
// capture is actually possible.
131+
if posWithRelevantEP.samePosition(posWithRelevantNoEP) {
132+
t.Error("positions with relevant en passant square should be considered different")
133+
}
134+
135+
// Test with black pawn on f4 (right side of e4 pawn).
136+
posWithRelevantEPRight, err := decodeFEN("rnbqkbnr/pppp1ppp/8/8/4Pp2/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
141+
posWithRelevantEPRightNoEP, err := decodeFEN("rnbqkbnr/pppp1ppp/8/8/4Pp2/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1")
142+
if err != nil {
143+
t.Fatal(err)
144+
}
145+
146+
if posWithRelevantEPRight.samePosition(posWithRelevantEPRightNoEP) {
147+
t.Error("positions with relevant en passant square (right adjacent pawn) should be considered different")
148+
}
149+
}

0 commit comments

Comments
 (0)