From 712f228eaa9ceaa5bcfa26fbb9784a16d418c6ac Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:16:11 +0200 Subject: [PATCH 01/12] refactor: remove unnecessary break statements in addTags function --- engine.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/engine.go b/engine.go index c7ac540..2c74020 100644 --- a/engine.go +++ b/engine.go @@ -187,10 +187,8 @@ func addTags(m *Move, pos *Position) { switch m.s2 { case C1, C8: m.AddTag(QueenSideCastle) - break case G1, G8: m.AddTag(KingSideCastle) - break } } // determine if in check after move (makes move invalid) From 9879d58a20080be57554ebe665ef590d961731d6 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:16:46 +0200 Subject: [PATCH 02/12] refactor: simplify evaluatePositionStatus method and optimize path collection in Split --- game.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/game.go b/game.go index e8946b1..e87526c 100644 --- a/game.go +++ b/game.go @@ -671,10 +671,11 @@ func (g *Game) RemoveTagPair(k string) bool { // evaluatePositionStatus updates the game's outcome and method based on the current position. func (g *Game) evaluatePositionStatus() { method := g.pos.Status() - if method == Stalemate { + switch method { + case Stalemate: g.method = Stalemate g.outcome = Draw - } else if method == Checkmate { + case Checkmate: g.method = Checkmate g.outcome = WhiteWon if g.pos.Turn() == White { @@ -977,9 +978,7 @@ func (g *Game) Split() []*Game { // Collect all move paths starting from the root's children var paths [][]*Move for _, m := range g.rootMove.children { - for _, p := range collectPaths(m) { - paths = append(paths, p) - } + paths = append(paths, collectPaths(m)...) } // Build a Game for each path From 5f4e0140dbbd12a1c01b38bb653d6c5cd66d2b86 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:16:55 +0200 Subject: [PATCH 03/12] refactor: simplify cloneChildren method by removing unnecessary nil check --- move.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/move.go b/move.go index 6ee41c4..1911464 100644 --- a/move.go +++ b/move.go @@ -180,7 +180,7 @@ func (m *Move) Clone() *Move { } func (m *Move) cloneChildren(srcChildren []*Move) { - if srcChildren == nil || len(srcChildren) == 0 { + if len(srcChildren) == 0 { return } From 71f7da869c78fe4721efa807771948daea7e858f Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:17:35 +0200 Subject: [PATCH 04/12] refactor: optimize token handling in parseMoveText --- pgn.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pgn.go b/pgn.go index 86f28f8..8d9eb71 100644 --- a/pgn.go +++ b/pgn.go @@ -204,12 +204,14 @@ func (p *Parser) parseMoveText() error { ply++ // Collect all NAGs and comments that follow the move + collectLoop: for { tok := p.currentToken() - if tok.Type == NAG { + switch tok.Type { + case NAG: p.currentMove.nag = tok.Value p.advance() - } else if tok.Type == CommentStart { + case CommentStart: comment, commandMap, err := p.parseComment() if err != nil { return err @@ -226,8 +228,8 @@ func (p *Parser) parseMoveText() error { p.currentMove.comments = comment } } - } else { - break + default: + break collectLoop } } From 503a6d7cf386d9ceaa650ff8cbab5f8142411250 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:17:47 +0200 Subject: [PATCH 05/12] refactor: improve error handling in TestBytesBookSource and enhance string comparison in TestChessHasher --- polyglot_test.go | 4 ++-- zobrist_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/polyglot_test.go b/polyglot_test.go index f606c2d..5e17813 100644 --- a/polyglot_test.go +++ b/polyglot_test.go @@ -60,7 +60,7 @@ func TestBytesBookSource(t *testing.T) { // Test EOF source.index = 32 - n, err = source.Read(buf) + _, err = source.Read(buf) if !errors.Is(err, io.EOF) { t.Errorf("Read() error = %v, want EOF", err) } @@ -362,7 +362,7 @@ func TestPolyglotMoveEncode(t *testing.T) { Promotion: 0, CastlingMove: false, }, - expected: uint16((0 << 9) | (0 << 6) | (1 << 3) | 0), + expected: uint16((0 << 9) | (0 << 6) | (1 << 3)), }, { name: "Move with promotion", diff --git a/zobrist_test.go b/zobrist_test.go index 5044be2..3608764 100644 --- a/zobrist_test.go +++ b/zobrist_test.go @@ -71,7 +71,7 @@ func TestChessHasher(t *testing.T) { if err != nil { t.Errorf("Expected no error, got %v", err) } - if strings.ToLower(hash) != strings.ToLower(tc.hash) { + if !strings.EqualFold(hash, tc.hash) { t.Errorf("Expected hash %s, got %s", tc.hash, hash) } }) From a5089866f70c6c08ebd94419730f138dd2e5fce7 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:23:55 +0200 Subject: [PATCH 06/12] refactor: streamline variable initialization in parseMoveText function --- pgn.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pgn.go b/pgn.go index 8d9eb71..fb511e7 100644 --- a/pgn.go +++ b/pgn.go @@ -172,7 +172,7 @@ func (p *Parser) parseTagPair() error { func (p *Parser) parseMoveText() error { var moveNumber uint64 - var ply int = 1 + var ply = 1 for p.position < len(p.tokens) { token := p.currentToken() @@ -204,7 +204,7 @@ func (p *Parser) parseMoveText() error { ply++ // Collect all NAGs and comments that follow the move - collectLoop: + collectLoop: for { tok := p.currentToken() switch tok.Type { From f9102d2b34e480b2ce14b4a767964f7489fbcb0b Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:24:03 +0200 Subject: [PATCH 07/12] docs: improve formatting and readability in README.md --- README.md | 199 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 121 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index c9c4650..2e65eb6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Chess Library + [![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://godoc.org/github.com/corentings/chess) [![Go Report Card](https://goreportcard.com/badge/corentings/chess)](https://goreportcard.com/report/corentings/chess) [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/corentings/chess/master/LICENSE) @@ -8,40 +9,52 @@ ## Introduction -**chess** is a set of go packages which provide common chess utilities such as move generation, turn management, checkmate detection, PGN encoding, UCI interoperability, image generation, opening book exploration, and others. It is well tested and optimized for performance. +**chess** is a set of go packages which provide common chess utilities such as move generation, turn management, +checkmate detection, PGN encoding, UCI interoperability, image generation, opening book exploration, and others. It is +well tested and optimized for performance. ![rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1](example.png) ## Recent Updates -**Comprehensive Move Validation**: All move methods now properly validate moves according to chess rules, returning descriptive errors for invalid moves. This ensures consistent game correctness across all move APIs. +**Comprehensive Move Validation**: All move methods now properly validate moves according to chess rules, returning +descriptive errors for invalid moves. This ensures consistent game correctness across all move APIs. **Performance Options**: Added unsafe variants for high-performance scenarios: -- `UnsafeMove()` - ~1.5x faster than `Move()` + +- `UnsafeMove()` - ~1.5x faster than `Move()` - `UnsafePushNotationMove()` - ~1.1x faster than `PushNotationMove()` -**API Consistency**: Refactored move methods for clear validation behavior and consistent performance options across all move APIs. +**API Consistency**: Refactored move methods for clear validation behavior and consistent performance options across all +move APIs. ## Why I Forked + I forked the original ![notnil/chess](https://github.com/notnil/chess) package for several reasons: - Update Rate: The original package was not being updated at the pace I needed for my projects. -- Pending PRs: There were numerous pull requests that needed to be merged to make the package production-ready for my work. -- Performance and Allocations: I wanted to improve overall performance and reduce memory allocations. +- Pending PRs: There were numerous pull requests that needed to be merged to make the package production-ready for my + work. +- Performance and Allocations: I wanted to improve overall performance and reduce memory allocations. - Customization: I had specific changes in mind that would not be easily integrated into the original package. ## Credits -I want to extend my gratitude to the original author of notnil/chess for their amazing work. -This fork is not intended to steal or replace their work but to build upon it, providing an alternative for the open-source community and allowing for faster development. +I want to extend my gratitude to the original author of notnil/chess for their amazing work. +This fork is not intended to steal or replace their work but to build upon it, providing an alternative for the +open-source community and allowing for faster development. ## Disclaimer -**Breaking Changes**: This package is under the `/v2` namespace to signify that it might not be backward compatible with the original package. + +**Breaking Changes**: This package is under the `/v2` namespace to signify that it might not be backward compatible with +the original package. While some parts might work as plug-and-play, others might require changes. -Unfortunately, I do not plan to maintain a breaking change list at this time, but I expect in-code comments and the compiler/linter to assist with migration. +Unfortunately, I do not plan to maintain a breaking change list at this time, but I expect in-code comments and the +compiler/linter to assist with migration. **Maintenance**: This package is primarily maintained for my current work and projects. It is shared as a respect for the original work and to contribute to the community. My main focus is: + - Rewriting code to reduce allocations - Replacing strings with more efficient data structures where possible - Improving performance @@ -50,22 +63,22 @@ It is shared as a respect for the original work and to contribute to the communi - Potential major changes to the game representation to support variations ## Contributions + I am open to suggestions, pull requests, and contributions from anyone interested in improving this library. If you have ideas or want to help make this package more robust and widely usable, please feel free to: + - Open issues for bugs or feature requests - Submit pull requests with improvements or fixes - Contact me directly for discussions or ideas - - ## Repo Structure -| Package | Docs Link | Description | -| ------------- | ------------- | ------------- | -| **chess** | [corentings/chess](README.md) | Move generation, serialization / deserialization, turn management, checkmate detection | -| **image** | [corentings/chess/image](image/README.md) | SVG chess board image generation | -| **opening** | [corentings/chess/opening](opening/README.md) | Opening book interactivity | -| **uci** | [corentings/chess/uci](uci/README.md) | Universal Chess Interface client | +| Package | Docs Link | Description | +|-------------|-----------------------------------------------|----------------------------------------------------------------------------------------| +| **chess** | [corentings/chess](README.md) | Move generation, serialization / deserialization, turn management, checkmate detection | +| **image** | [corentings/chess/image](image/README.md) | SVG chess board image generation | +| **opening** | [corentings/chess/opening](opening/README.md) | Opening book interactivity | +| **uci** | [corentings/chess/uci](uci/README.md) | Universal Chess Interface client | ## Installation @@ -125,6 +138,7 @@ func main() { ``` ### Example Stockfish v. Stockfish + ```go package main @@ -168,55 +182,61 @@ func main() { ### Movement -Chess provides multiple ways of making moves: direct move execution, valid move generation, and notation parsing. All move methods include proper validation to ensure game correctness. +Chess provides multiple ways of making moves: direct move execution, valid move generation, and notation parsing. All +move methods include proper validation to ensure game correctness. #### Move Methods The library offers two move execution methods to balance safety and performance: **Move()** - Validates moves before execution (recommended for general use): + ```go game := chess.NewGame() moves := game.ValidMoves() err := game.Move(&moves[0], nil) if err != nil { - // Handle invalid move error +// Handle invalid move error } ``` **UnsafeMove()** - High-performance move execution without validation: + ```go game := chess.NewGame() moves := game.ValidMoves() // Only use when you're certain the move is valid err := game.UnsafeMove(&moves[0], nil) if err != nil { - // Handle error (should not occur with valid moves) +// Handle error (should not occur with valid moves) } ``` **PushNotationMove()** - Validates moves using any notation (recommended for general use): + ```go game := chess.NewGame() err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil) if err != nil { - // Handle invalid move or notation error +// Handle invalid move or notation error } ``` **UnsafePushNotationMove()** - High-performance notation parsing without move validation: + ```go game := chess.NewGame() // Only use when you're certain the move is valid err := game.UnsafePushNotationMove("e4", chess.AlgebraicNotation{}, nil) if err != nil { - // Handle notation parsing error (should not occur with valid notation) +// Handle notation parsing error (should not occur with valid notation) } ``` -> **Performance Note**: +> **Performance Note**: > - `UnsafeMove()` provides ~1.5x performance improvement over `Move()` by skipping validation -> - `UnsafePushNotationMove()` provides ~1.1x performance improvement over `PushNotationMove()` by skipping move validation +> - `UnsafePushNotationMove()` provides ~1.1x performance improvement over `PushNotationMove()` by skipping move + validation > - Use unsafe variants only when moves are pre-validated or known to be legal #### Valid Moves @@ -237,13 +257,14 @@ PushNotationMove method accepts string input using any supported notation: ```go game := chess.NewGame() if err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { - // handle error +// handle error } ``` #### Move Validation -All move methods automatically validate moves according to chess rules. The `Move()` method validates moves before execution and returns descriptive errors for invalid moves: +All move methods automatically validate moves according to chess rules. The `Move()` method validates moves before +execution and returns descriptive errors for invalid moves: ```go game := chess.NewGame() @@ -251,25 +272,25 @@ game := chess.NewGame() // Get valid moves from current position validMoves := game.ValidMoves() if len(validMoves) > 0 { - // This will succeed - move is known to be valid - if err := game.Move(&validMoves[0], nil); err != nil { - fmt.Println("Move failed:", err) - } else { - fmt.Println("Move succeeded") - } +// This will succeed - move is known to be valid +if err := game.Move(&validMoves[0], nil); err != nil { +fmt.Println("Move failed:", err) +} else { +fmt.Println("Move succeeded") +} } // Using notation parsing with validation if err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { - fmt.Println("Move failed:", err) +fmt.Println("Move failed:", err) } else { - fmt.Println("e4 move succeeded") +fmt.Println("e4 move succeeded") } // Invalid notation will be caught if err := game.PushNotationMove("e5", chess.AlgebraicNotation{}, nil); err != nil { - fmt.Println("Move failed:", err) - // Output: Move failed: [invalid move error] +fmt.Println("Move failed:", err) +// Output: Move failed: [invalid move error] } ``` @@ -282,18 +303,19 @@ game := chess.NewGame() validMoves := game.ValidMoves() selectedMove := &validMoves[0] // We know this is valid if err := game.UnsafeMove(selectedMove, nil); err != nil { - panic(err) // Should not happen with pre-validated moves +panic(err) // Should not happen with pre-validated moves } // Option 2: Using notation (~1.1x faster) if err := game.UnsafePushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { - panic(err) // Should not happen with valid notation/moves +panic(err) // Should not happen with valid notation/moves } ``` ### Outcome -The outcome of the match is calculated automatically from the inputted moves if possible. Draw agreements, resignations, and other human initiated outcomes can be inputted as well. +The outcome of the match is calculated automatically from the inputted moves if possible. Draw agreements, resignations, +and other human initiated outcomes can be inputted as well. #### Checkmate @@ -330,7 +352,7 @@ fen, _ := chess.FEN(fenStr) game := chess.NewGame(fen) game.PushNotationMove("Qb6", chess.AlgebraicNotation{}, nil) fmt.Println(game.Outcome()) // 1/2-1/2 -fmt.Println(game.Method()) // Stalemate +fmt.Println(game.Method()) // Stalemate /* A B C D E F G H 8♚ - ♔ - - - - - @@ -364,36 +386,39 @@ Draw by mutual agreement: game := chess.NewGame() game.Draw(chess.DrawOffer) fmt.Println(game.Outcome()) // 1/2-1/2 -fmt.Println(game.Method()) // DrawOffer +fmt.Println(game.Method()) // DrawOffer ``` #### Threefold Repetition -[Threefold repetition](https://en.wikipedia.org/wiki/Threefold_repetition) occurs when the position repeats three times (not necessarily in a row). If this occurs both players have the option of taking a draw, but aren't required until Fivefold Repetition. +[Threefold repetition](https://en.wikipedia.org/wiki/Threefold_repetition) occurs when the position repeats three +times (not necessarily in a row). If this occurs both players have the option of taking a draw, but aren't required +until Fivefold Repetition. ```go game := chess.NewGame() moves := []string{"Nf3", "Nf6", "Ng1", "Ng8", "Nf3", "Nf6", "Ng1", "Ng8"} for _, m := range moves { - game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) +game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) } fmt.Println(game.EligibleDraws()) // [DrawOffer ThreefoldRepetition] ``` #### Fivefold Repetition -According to the [FIDE Laws of Chess](http://www.fide.com/component/handbook/?id=171&view=article) if a position repeats five times then the game is drawn automatically. +According to the [FIDE Laws of Chess](http://www.fide.com/component/handbook/?id=171&view=article) if a position repeats +five times then the game is drawn automatically. ```go game := chess.NewGame() moves := []string{ - "Nf3", "Nf6", "Ng1", "Ng8", - "Nf3", "Nf6", "Ng1", "Ng8", - "Nf3", "Nf6", "Ng1", "Ng8", - "Nf3", "Nf6", "Ng1", "Ng8", +"Nf3", "Nf6", "Ng1", "Ng8", +"Nf3", "Nf6", "Ng1", "Ng8", +"Nf3", "Nf6", "Ng1", "Ng8", +"Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { - game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) +game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) } fmt.Println(game.Outcome()) // 1/2-1/2 fmt.Println(game.Method()) // FivefoldRepetition @@ -401,7 +426,8 @@ fmt.Println(game.Method()) // FivefoldRepetition #### Fifty Move Rule -[Fifty-move rule](https://en.wikipedia.org/wiki/Fifty-move_rule) allows either player to claim a draw if no capture has been made and no pawn has been moved in the last fifty moves. +[Fifty-move rule](https://en.wikipedia.org/wiki/Fifty-move_rule) allows either player to claim a draw if no capture has +been made and no pawn has been moved in the last fifty moves. ```go fen, _ := chess.FEN("2r3k1/1q1nbppp/r3p3/3pP3/pPpP4/P1Q2N2/2RN1PPP/2R4K b - b3 100 23") @@ -413,7 +439,9 @@ fmt.Println(game.Method()) // FiftyMoveRule #### Seventy Five Move Rule -According to [FIDE Laws of Chess Rule 9.6b](http://www.fide.com/component/handbook/?id=171&view=article) if 75 consecutive moves have been made without movement of any pawn or any capture, the game is drawn unless the last move was checkmate. +According to [FIDE Laws of Chess Rule 9.6b](http://www.fide.com/component/handbook/?id=171&view=article) if 75 +consecutive moves have been made without movement of any pawn or any capture, the game is drawn unless the last move was +checkmate. ```go fen, _ := chess.FEN("2r3k1/1q1nbppp/r3p3/3pP3/pPpP4/P1Q2N2/2RN1PPP/2R4K b - b3 149 23") @@ -425,7 +453,8 @@ fmt.Println(game.Method()) // SeventyFiveMoveRule #### Insufficient Material -[Impossibility of checkmate](https://en.wikipedia.org/wiki/Draw_%28chess%29#Draws_in_all_games), or insufficient material, results when neither white or black has the pieces remaining to checkmate the opponent. +[Impossibility of checkmate](https://en.wikipedia.org/wiki/Draw_%28chess%29#Draws_in_all_games), or insufficient +material, results when neither white or black has the pieces remaining to checkmate the opponent. ```go fen, _ := chess.FEN("8/2k5/8/8/8/3K4/8/8 w - - 1 1") @@ -436,7 +465,9 @@ fmt.Println(game.Method()) // InsufficientMaterial ### PGN -[PGN](https://en.wikipedia.org/wiki/Portable_Game_Notation), or Portable Game Notation, is the most common serialization format for chess matches. PGNs include move history and metadata about the match. Chess includes the ability to read and write the PGN format. +[PGN](https://en.wikipedia.org/wiki/Portable_Game_Notation), or Portable Game Notation, is the most common serialization +format for chess matches. PGNs include move history and metadata about the match. Chess includes the ability to read and +write the PGN format. #### Example PGN @@ -466,7 +497,7 @@ PGN supplied as an optional parameter to the NewGame constructor: ```go pgn, err := chess.PGN(pgnReader) if err != nil { - // handle error +// handle error } game := chess.NewGame(pgn) ``` @@ -495,19 +526,19 @@ For parsing large PGN database files use Scanner: ```go f, err := os.Open("lichess_db_standard_rated_2013-01.pgn") if err != nil { - panic(err) +panic(err) } defer f.Close() scanner := chess.NewScanner(f) // Read all games for scanner.HasNext() { - game, err := scanner.ParseNext() - if err != nil { - log.Fatal("Failed to parse game: %v", err) - } - fmt.Println(game.GetTagPair("Site")) - // Output &{Site https://lichess.org/8jb5kiqw} +game, err := scanner.ParseNext() +if err != nil { +log.Fatal("Failed to parse game: %v", err) +} +fmt.Println(game.GetTagPair("Site")) +// Output &{Site https://lichess.org/8jb5kiqw} } ``` @@ -518,25 +549,27 @@ To expand every variation into a distinct Game: ```go f, err := os.Open("lichess_db_standard_rated_2013-01.pgn") if err != nil { - panic(err) +panic(err) } defer f.Close() scanner := chess.NewScanner(f, chess.WithExpandVariations()) // Read all games for scanner.HasNext() { - game, err := scanner.ParseNext() - if err != nil { - log.Fatal("Failed to parse game: %v", err) - } - fmt.Println(game.GetTagPair("Site")) - // Output &{Site https://lichess.org/8jb5kiqw} +game, err := scanner.ParseNext() +if err != nil { +log.Fatal("Failed to parse game: %v", err) +} +fmt.Println(game.GetTagPair("Site")) +// Output &{Site https://lichess.org/8jb5kiqw} } ``` ### FEN -[FEN](https://en.wikipedia.org/wiki/Forsyth–Edwards_Notation), or Forsyth–Edwards Notation, is the standard notation for describing a board position. FENs include piece positions, turn, castle rights, en passant square, half move counter (for [50 move rule](https://en.wikipedia.org/wiki/Fifty-move_rule)), and full move counter. +[FEN](https://en.wikipedia.org/wiki/Forsyth–Edwards_Notation), or Forsyth–Edwards Notation, is the standard notation for +describing a board position. FENs include piece positions, turn, castle rights, en passant square, half move counter ( +for [50 move rule](https://en.wikipedia.org/wiki/Fifty-move_rule)), and full move counter. #### Read FEN @@ -545,7 +578,7 @@ FEN supplied as an optional parameter to the NewGame constructor: ```go fen, err := chess.FEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") if err != nil { - // handle error +// handle error } game := chess.NewGame(fen) ``` @@ -562,10 +595,13 @@ fmt.Println(pos.String()) // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq ### Notations -[Chess Notation](https://en.wikipedia.org/wiki/Chess_notation) define how moves are encoded in a serialized format. Chess uses a notation when converting to and from PGN and for accepting move text. +[Chess Notation](https://en.wikipedia.org/wiki/Chess_notation) define how moves are encoded in a serialized format. +Chess uses a notation when converting to and from PGN and for accepting move text. + #### Algebraic Notation -[Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_(chess)) (or Standard Algebraic Notation) is the official chess notation used by FIDE. Examples: e2, e5, O-O (short castling), e8=Q (promotion) +[Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_(chess)) (or Standard Algebraic Notation) is the +official chess notation used by FIDE. Examples: e2, e5, O-O (short castling), e8=Q (promotion) ```go game := chess.NewGame() @@ -576,7 +612,9 @@ fmt.Println(game) // 1.e4 e5 * #### Long Algebraic Notation -[Long Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_(chess)#Long_algebraic_notation) LongAlgebraicNotation is a more beginner friendly alternative to algebraic notation, where the origin of the piece is visible as well as the destination. Examples: Rd1xd8+, Ng8f6. +[Long Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_(chess)#Long_algebraic_notation) +LongAlgebraicNotation is a more beginner friendly alternative to algebraic notation, where the origin of the piece is +visible as well as the destination. Examples: Rd1xd8+, Ng8f6. ```go game := chess.NewGame() @@ -589,7 +627,8 @@ fmt.Println(game) // 1.f2f3 e7e5 2.g2g4 Qd8h4# 0-1 #### UCI Notation -UCI notation is a more computer friendly alternative to algebraic notation. This notation is the Universal Chess Interface notation. Examples: e2e4, e7e5, e1g1 (white short castling), e7e8q (for promotion) +UCI notation is a more computer friendly alternative to algebraic notation. This notation is the Universal Chess +Interface notation. Examples: e2e4, e7e5, e1g1 (white short castling), e7e8q (for promotion) ```go game := chess.NewGame() @@ -620,8 +659,8 @@ fmt.Println(game.Position().Board().Draw()) ### Move History -Move History is a convenient API for accessing aligned positions, moves, and comments. Move -History is useful when trying to understand detailed information about a game. Below is an +Move History is a convenient API for accessing aligned positions, moves, and comments. Move +History is useful when trying to understand detailed information about a game. Below is an example showing how to see which side castled first. ```go @@ -665,16 +704,20 @@ func main() { ## Performance -Chess has been performance tuned, using [pprof](https://golang.org/pkg/runtime/pprof/), with the goal of being fast enough for use by chess bots. The original map based board representation was replaced by [bitboards](https://chessprogramming.wikispaces.com/Bitboards) resulting in a large performance increase. +Chess has been performance tuned, using [pprof](https://golang.org/pkg/runtime/pprof/), with the goal of being fast +enough for use by chess bots. The original map based board representation was replaced +by [bitboards](https://chessprogramming.wikispaces.com/Bitboards) resulting in a large performance increase. ### Benchmarks The benchmarks can be run with the following command: + ``` go test -bench=. ``` Results from the baseline 2015 MBP: + ``` BenchmarkBitboardReverse-4 2000000000 1.01 ns/op BenchmarkStalemateStatus-4 500000 3116 ns/op From 6a3f038ffcdb1142d5ac3aa48c1b128f9640cab2 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:24:09 +0200 Subject: [PATCH 08/12] refactor: reorganize fields in Scanner and SearchResults structs for clarity --- scanner.go | 4 ++-- uci/info.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scanner.go b/scanner.go index 2880762..1d0abc2 100644 --- a/scanner.go +++ b/scanner.go @@ -68,11 +68,11 @@ func TokenizeGame(game *GameScanned) ([]Token, error) { // It supports streaming processing of multiple games and proper handling // of PGN syntax. type Scanner struct { + lastError error // Store last error scanner *bufio.Scanner nextGame *GameScanned // Buffer for peeked game - lastError error // Store last error + nextParsedGames []*Game // only valid when ExpandVariations==true opts ScannerOpts - nextParsedGames []*Game // only valid when ExpandVariations==true } type ScannerOption func(*Scanner) diff --git a/uci/info.go b/uci/info.go index 0cd4954..78260da 100644 --- a/uci/info.go +++ b/uci/info.go @@ -18,8 +18,8 @@ var missingWdlErr = errors.New("uci: wdl unavailable; this is mostly likely beca type SearchResults struct { BestMove *chess.Move Ponder *chess.Move - Info Info MultiPVInfo []Info + Info Info } // Info corresponds to the "info" engine output: From eefa43f51852dca0b31891f831c0b1e6c796d136 Mon Sep 17 00:00:00 2001 From: CorentinGS Date: Thu, 18 Sep 2025 11:27:16 +0200 Subject: [PATCH 09/12] refactor: clean up code by removing unnecessary blank lines and optimizing variable initialization --- game.go | 11 ++--- game_test.go | 3 +- lexer_test.go | 105 +++++++++++++++++++++++++++++++++++--------- move.go | 1 - opening/eco.go | 5 --- opening/eco_data.go | 2 +- pgn.go | 3 +- pgn_test.go | 1 - polyglot_test.go | 2 +- position_test.go | 1 + scanner_test.go | 4 +- zobrist.go | 1 - zobrist_test.go | 3 -- 13 files changed, 97 insertions(+), 45 deletions(-) diff --git a/game.go b/game.go index e87526c..49cee8d 100644 --- a/game.go +++ b/game.go @@ -338,7 +338,7 @@ func (g *Game) FEN() string { func (g *Game) String() string { var sb strings.Builder - var tagPairList = make([]sortableTagPair, len(g.tagPairs)) + tagPairList := make([]sortableTagPair, len(g.tagPairs)) var idx uint = 0 for tag, value := range g.tagPairs { @@ -442,7 +442,8 @@ func cmpTags(a, b sortableTagPair) int { // ensuring that the output adheres to standard PGN conventions. Future enhancements may include support for all NAG values. // the function returns whether or not a trailing space was added to the output func writeMoves(node *Move, moveNum int, isWhite bool, sb *strings.Builder, - subVariation, closedVariation, isRoot bool) bool { + subVariation, closedVariation, isRoot bool, +) bool { trailingSpace := false // If no moves remain, stop. @@ -477,7 +478,7 @@ func writeMoves(node *Move, moveNum int, isWhite bool, sb *strings.Builder, writeCommands(currentMove, sb) - //TODO: Add support for all nags values in the future + // TODO: Add support for all nags values in the future if len(node.children) > 1 || len(currentMove.children) > 0 { sb.WriteString(" ") @@ -506,8 +507,8 @@ func writeMoves(node *Move, moveNum int, isWhite bool, sb *strings.Builder, } func writeMoveNumber(moveNum int, isWhite bool, subVariation, closedVariation, - isRoot bool, sb *strings.Builder) { - + isRoot bool, sb *strings.Builder, +) { if closedVariation { sb.WriteString(" ") } diff --git a/game_test.go b/game_test.go index 1430598..6e53e72 100644 --- a/game_test.go +++ b/game_test.go @@ -659,7 +659,6 @@ func TestPushMove(t *testing.T) { // Go back one move if needed for the test if tt.goBack && game.currentMove != nil && game.currentMove.parent != nil { game.GoBack() - } // Test the move @@ -834,7 +833,6 @@ func TestCloneGameState(t *testing.T) { if len(clone.Positions()) == len(original.Positions()) { t.Errorf("modifying the clone incorrectly mutates the original positions") } - } func TestCloneGameStateWithNilComments(t *testing.T) { @@ -970,6 +968,7 @@ func TestRemoveTagPairFromEmptyTagPairs(t *testing.T) { t.Fatalf("expected tag pair not to be removed") } } + func TestAddTagPairWhenKeyExists(t *testing.T) { g := NewGame() g.AddTagPair("Event", "Test Event") diff --git a/lexer_test.go b/lexer_test.go index 3383929..629aa75 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -1030,30 +1030,93 @@ func TestSingleFromPosPGN(t *testing.T) { value string }{ // Tags - {TagStart, "["}, {TagKey, "Event"}, {TagValue, "Slav Defense: Chebanenko Variation, 5. cxd5"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Site"}, {TagValue, ""}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Date"}, {TagValue, "2025.07.12"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Round"}, {TagValue, "1"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "White"}, {TagValue, ""}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Black"}, {TagValue, ""}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Result"}, {TagValue, "*"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "UTCDate"}, {TagValue, "2025.07.12"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "UTCTime"}, {TagValue, "15:51:01"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "Variant"}, {TagValue, "Standard"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "ECO"}, {TagValue, "D15"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "FEN"}, {TagValue, "rnbqkb1r/1p2pppp/p1p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 0 5"}, {TagEnd, "]"}, - {TagStart, "["}, {TagKey, "SetUp"}, {TagValue, "1"}, {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Event"}, + {TagValue, "Slav Defense: Chebanenko Variation, 5. cxd5"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Site"}, + {TagValue, ""}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Date"}, + {TagValue, "2025.07.12"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Round"}, + {TagValue, "1"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "White"}, + {TagValue, ""}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Black"}, + {TagValue, ""}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Result"}, + {TagValue, "*"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "UTCDate"}, + {TagValue, "2025.07.12"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "UTCTime"}, + {TagValue, "15:51:01"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "Variant"}, + {TagValue, "Standard"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "ECO"}, + {TagValue, "D15"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "FEN"}, + {TagValue, "rnbqkb1r/1p2pppp/p1p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 0 5"}, + {TagEnd, "]"}, + {TagStart, "["}, + {TagKey, "SetUp"}, + {TagValue, "1"}, + {TagEnd, "]"}, // Moves - {MoveNumber, "5"}, {DOT, "."}, - {FILE, "c"}, {CAPTURE, "x"}, {SQUARE, "d5"}, + {MoveNumber, "5"}, + {DOT, "."}, + {FILE, "c"}, + {CAPTURE, "x"}, + {SQUARE, "d5"}, {VariationStart, "("}, - {MoveNumber, "5"}, {DOT, "."}, {SQUARE, "e3"}, {SQUARE, "e6"}, - {MoveNumber, "6"}, {DOT, "."}, {FILE, "c"}, {CAPTURE, "x"}, {SQUARE, "d5"}, - {FILE, "c"}, {CAPTURE, "x"}, {SQUARE, "d5"}, + {MoveNumber, "5"}, + {DOT, "."}, + {SQUARE, "e3"}, + {SQUARE, "e6"}, + {MoveNumber, "6"}, + {DOT, "."}, + {FILE, "c"}, + {CAPTURE, "x"}, + {SQUARE, "d5"}, + {FILE, "c"}, + {CAPTURE, "x"}, + {SQUARE, "d5"}, {VariationEnd, ")"}, - {MoveNumber, "5"}, {ELLIPSIS, "..."}, {FILE, "c"}, {CAPTURE, "x"}, {SQUARE, "d5"}, - {MoveNumber, "6"}, {DOT, "."}, {SQUARE, "e3"}, {SQUARE, "e6"}, - {CommentStart, "{"}, {CommandStart, "[%"}, {CommandName, "eval"}, {CommandParam, "0.11"}, {CommandEnd, "]"}, {CommentEnd, "}"}, + {MoveNumber, "5"}, + {ELLIPSIS, "..."}, + {FILE, "c"}, + {CAPTURE, "x"}, + {SQUARE, "d5"}, + {MoveNumber, "6"}, + {DOT, "."}, + {SQUARE, "e3"}, + {SQUARE, "e6"}, + {CommentStart, "{"}, + {CommandStart, "[%"}, + {CommandName, "eval"}, + {CommandParam, "0.11"}, + {CommandEnd, "]"}, + {CommentEnd, "}"}, {RESULT, "*"}, } diff --git a/move.go b/move.go index 1911464..0d88e5c 100644 --- a/move.go +++ b/move.go @@ -126,7 +126,6 @@ func (m *Move) Number() int { } return ret - } // FullMoveNumber returns the full move number (increments after Black's move). diff --git a/opening/eco.go b/opening/eco.go index d3b1b3e..550ac8e 100644 --- a/opening/eco.go +++ b/opening/eco.go @@ -155,11 +155,6 @@ func (b *BookECO) nodeList(root *node) []*node { return nodes } -var ( - //TODO: This is a legacy counter for generating unique labels. (will be removed in the future) - labelCount = 0 //nolint:gochecknoglobals // this is a counter for generating unique labels. (will be removed in the future) -) - func (b *BookECO) label() string { s := "a" + strconv.Itoa(b.labelCount) b.labelCount++ diff --git a/opening/eco_data.go b/opening/eco_data.go index 6b95806..0b47d26 100644 --- a/opening/eco_data.go +++ b/opening/eco_data.go @@ -5,4 +5,4 @@ import _ "embed" //go:embed eco_lichess.tsv var ecoData []byte -//TODO: change this so that it is not a global variable +// TODO: change this so that it is not a global variable diff --git a/pgn.go b/pgn.go index fb511e7..7c41a5f 100644 --- a/pgn.go +++ b/pgn.go @@ -149,7 +149,6 @@ func (p *Parser) parseTagPair() error { TokenValue: p.currentToken().Value, Position: p.position, } - } value := p.currentToken().Value p.advance() @@ -172,7 +171,7 @@ func (p *Parser) parseTagPair() error { func (p *Parser) parseMoveText() error { var moveNumber uint64 - var ply = 1 + ply := 1 for p.position < len(p.tokens) { token := p.currentToken() diff --git a/pgn_test.go b/pgn_test.go index e12a6e1..0fcc9aa 100644 --- a/pgn_test.go +++ b/pgn_test.go @@ -403,7 +403,6 @@ func TestLichessMultipleCommand(t *testing.T) { if game.Moves()[4].command["clk"] != "0:02:58" { t.Fatalf("game move 5 is not correct, expected clock, got %s", game.Moves()[4].command["clk"]) } - } func TestParseMoveWithNAGAndComment(t *testing.T) { diff --git a/polyglot_test.go b/polyglot_test.go index 5e17813..1bd1a3e 100644 --- a/polyglot_test.go +++ b/polyglot_test.go @@ -101,7 +101,7 @@ func TestFileBookSource(t *testing.T) { tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.bin") - if err := os.WriteFile(tmpFile, bookData, 0666); err != nil { + if err := os.WriteFile(tmpFile, bookData, 0o666); err != nil { t.Fatalf("Failed to create test file: %v", err) } diff --git a/position_test.go b/position_test.go index aa7254f..13d6575 100644 --- a/position_test.go +++ b/position_test.go @@ -58,6 +58,7 @@ func TestPositionUpdate(t *testing.T) { } } } + func TestPositionPly(t *testing.T) { tests := []struct { moveCount int diff --git a/scanner_test.go b/scanner_test.go index 289d712..312dcd5 100644 --- a/scanner_test.go +++ b/scanner_test.go @@ -233,8 +233,8 @@ func TestHasNextDoesntConsume(t *testing.T) { } func validateExpand(t *testing.T, scanner *Scanner, expectedLastLines []string, - expectedFinalPos []string) { - + expectedFinalPos []string, +) { count := 0 for scanner.HasNext() { game, err := scanner.ParseNext() diff --git a/zobrist.go b/zobrist.go index 1c401ef..986fc45 100644 --- a/zobrist.go +++ b/zobrist.go @@ -279,7 +279,6 @@ func ZobristHashToUint64(hash string) uint64 { result, err := strconv.ParseUint(hash, 16, 64) if err != nil { return 0 - } return result diff --git a/zobrist_test.go b/zobrist_test.go index 3608764..38f0aaa 100644 --- a/zobrist_test.go +++ b/zobrist_test.go @@ -12,7 +12,6 @@ func TestChessHasher(t *testing.T) { t.Run("should correctly hash the starting position", func(t *testing.T) { startPos := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" hash, err := hasher.HashPosition(startPos) - if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -27,7 +26,6 @@ func TestChessHasher(t *testing.T) { t.Run("should handle empty board position", func(t *testing.T) { emptyPos := "8/8/8/8/8/8/8/8 w - - 0 1" hash, err := hasher.HashPosition(emptyPos) - if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -258,7 +256,6 @@ func TestZobristHashToUint64(t *testing.T) { t.Run("empty hash returns error", func(t *testing.T) { hash := "" _ = ZobristHashToUint64(hash) - }) } From 34f57bcd66348a9a5b89527132e44fa09c516969 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:07:05 +0200 Subject: [PATCH 10/12] fix: Potential fix for code scanning alert no. 1: Workflow does not contain permissions (#74) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c7cd13e..2aa6604 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,6 +13,8 @@ on: jobs: setup: runs-on: ubuntu-latest + permissions: + contents: read outputs: go-version: '^1.22' steps: From 362a8972eee3d8695985b0bbdc0ee082fe194b7b Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 25 Sep 2025 18:13:20 +0200 Subject: [PATCH 11/12] fix: pgn disambiguation squares (#75) * fix: implement full square disambiguation for PGN parsing * fix: remove redundant full square disambiguation logic in PGN parsing * fix: add support for full square disambiguation in PGN parsing * fix: remove unused nextToken function from PGN parser * Update lexer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lexer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: correct expected token types for queen with full square disambiguation test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lexer.go | 71 ++++++++++++--------- pgn.go | 9 +++ pgn_disambiguation_test.go | 125 +++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 pgn_disambiguation_test.go diff --git a/lexer.go b/lexer.go index b1151fa..fb363c6 100644 --- a/lexer.go +++ b/lexer.go @@ -31,35 +31,36 @@ type TokenType int const ( EOF TokenType = iota Undefined - TagStart // [ - TagEnd // ] - TagKey // The key part of a tag (e.g., "Site") - TagValue // The value part of a tag (e.g., "Internet") - MoveNumber // 1, 2, 3, etc. - DOT // . - ELLIPSIS // ... - PIECE // N, B, R, Q, K - SQUARE // e4, e5, etc. - CommentStart // { - CommentEnd // } - COMMENT // The comment text - RESULT // 1-0, 0-1, 1/2-1/2, * - CAPTURE // 'x' in moves - FILE // a-h in moves when used as disambiguation - RANK // 1-8 in moves when used as disambiguation - KingsideCastle // 0-0 - QueensideCastle // 0-0-0 - PROMOTION // = in moves - PromotionPiece // The piece being promoted to (Q, R, B, N) - CHECK // + in moves - CHECKMATE // # in moves - NAG // Numeric Annotation Glyph (e.g., $1, $2, etc.) - VariationStart // ( for starting a variation - VariationEnd // ) for ending a variation - CommandStart // [% - CommandName // The command name (e.g., clk, eval) - CommandParam // Command parameter - CommandEnd // ] + TagStart // [ + TagEnd // ] + TagKey // The key part of a tag (e.g., "Site") + TagValue // The value part of a tag (e.g., "Internet") + MoveNumber // 1, 2, 3, etc. + DOT // . + ELLIPSIS // ... + PIECE // N, B, R, Q, K + SQUARE // e4, e5, etc. + CommentStart // { + CommentEnd // } + COMMENT // The comment text + RESULT // 1-0, 0-1, 1/2-1/2, * + CAPTURE // 'x' in moves + FILE // a-h in moves when used as disambiguation + RANK // 1-8 in moves when used as disambiguation + KingsideCastle // 0-0 + QueensideCastle // 0-0-0 + PROMOTION // = in moves + PromotionPiece // The piece being promoted to (Q, R, B, N) + CHECK // + in moves + CHECKMATE // # in moves + NAG // Numeric Annotation Glyph (e.g., $1, $2, etc.) + VariationStart // ( for starting a variation + VariationEnd // ) for ending a variation + CommandStart // [% + CommandName // The command name (e.g., clk, eval) + CommandParam // Command parameter + CommandEnd // ] + DeambiguationSquare // Full square disambiguation (e.g., e8 in Qe8f7) ) func (t TokenType) String() string { @@ -317,6 +318,7 @@ func (l *Lexer) readPieceMove() Token { func (l *Lexer) readMove() Token { const disambiguationLength = 3 + const disambiguationSquareLength = 4 position := l.position @@ -348,6 +350,17 @@ func (l *Lexer) readMove() Token { // Get the total length of what we read length := l.position - position + // Handle full square disambiguation (e.g., "e8f7" -> "e8" then "f7") + if length == disambiguationSquareLength && + isFile(l.input[position]) && isDigit(l.input[position+1]) && + isFile(l.input[position+2]) && isDigit(l.input[position+3]) { + // Reset to return just the first square + l.position = position + 2 + l.readPosition = position + 3 + l.ch = l.input[l.position] // set current char to the first character of the second square + return Token{Type: DeambiguationSquare, Value: l.input[position : position+2]} + } + // If we read 3 characters, first one is disambiguation if length == disambiguationLength { l.readPosition = position + 1 diff --git a/pgn.go b/pgn.go index 7c41a5f..dc364aa 100644 --- a/pgn.go +++ b/pgn.go @@ -338,11 +338,20 @@ func (p *Parser) parseMove() (*Move, error) { } else if p.currentToken().Type == RANK { moveData.originRank = p.currentToken().Value p.advance() + } else if p.currentToken().Type == DeambiguationSquare { + // Full square disambiguation (e.g., "Qe8f7" -> piece: Q, origin: e8, dest: f7) + originSquare := p.currentToken().Value + if len(originSquare) == 2 { + moveData.originFile = string(originSquare[0]) + moveData.originRank = string(originSquare[1]) + } + p.advance() } case FILE: moveData.originFile = p.currentToken().Value p.advance() + } // Handle capture diff --git a/pgn_disambiguation_test.go b/pgn_disambiguation_test.go new file mode 100644 index 0000000..d8e878c --- /dev/null +++ b/pgn_disambiguation_test.go @@ -0,0 +1,125 @@ +package chess + +import ( + "strings" + "testing" +) + +// Test cases for PGN disambiguation parsing issue #73 +// The issue is that moves like "Qe8f7" (queen from e8 to f7) fail to parse +// because the parser doesn't handle full square disambiguation properly. + +func TestPGNDisambiguationSquares(t *testing.T) { + tests := []struct { + name string + pgn string + shouldFail bool + description string + }{ + { + name: "multiple_pieces_same_type_complex", + pgn: `1. h4 d6 2. g4 Kd7 3. f4 Kc6 4. f5 Kd7 5. g5 Ke8 6. h5 Kd7 7. h6 Kc6 8. g6 Kb6 9. f6 Kc6 10. hxg7 Kd7 11. gxf7 Kc6 12. fxe7 Kb6 13. gxf8=Q Kc6 14. fxg8=Q Kb6 15. e8=Q Ka6 16. Qfe7 Kb6 17. Qe8f7`, + shouldFail: false, + description: "Complex game without full square disambiguation (should work)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reader := strings.NewReader(tc.pgn) + scanner := NewScanner(reader) + + game, err := scanner.ParseNext() + if err != nil { + if !tc.shouldFail { + t.Errorf("Expected PGN parsing to succeed but got error: %v", err) + } + // If we expected it to fail, verify it's the right kind of error + if tc.shouldFail && !strings.Contains(err.Error(), "invalid destination square") { + t.Logf("Expected 'invalid destination square' error but got: %v", err) + } + return + } + + if tc.shouldFail { + t.Errorf("Expected test case '%s' to fail but it succeeded. Description: %s", tc.name, tc.description) + } + + // Additional validation for successful cases + if !tc.shouldFail { + if game == nil { + t.Errorf("Game should not be nil for successful parsing") + } + } + }) + } +} + +// TestTokenizerDisambiguationSquares tests that the lexer correctly tokenizes disambiguation squares +func TestTokenizerDisambiguationSquares(t *testing.T) { + tests := []struct { + name string + input string + expected []TokenType + }{ + { + name: "queen_with_full_square_disambiguation", + input: "Qe8f7", + expected: []TokenType{PIECE, DeambiguationSquare, SQUARE}, + }, + { + name: "rook_with_full_square_disambiguation", + input: "Ra1d1", + expected: []TokenType{PIECE, DeambiguationSquare, SQUARE}, + }, + { + name: "knight_with_full_square_disambiguation", + input: "Nb1c3", + expected: []TokenType{PIECE, DeambiguationSquare, SQUARE}, + }, + { + name: "standard_piece_move", + input: "Nf3", + expected: []TokenType{PIECE, SQUARE}, + }, + { + name: "piece_with_file_disambiguation", + input: "Nbd2", + expected: []TokenType{PIECE, FILE, SQUARE}, + }, + { + name: "piece_with_rank_disambiguation", + input: "R1d2", + expected: []TokenType{PIECE, RANK, SQUARE}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lexer := NewLexer(tc.input) + var actualTypes []TokenType + + for { + token := lexer.NextToken() + if token.Type == EOF { + break + } + actualTypes = append(actualTypes, token.Type) + t.Logf("Token: %s, Value: %s", token.Type, token.Value) + } + + if len(actualTypes) != len(tc.expected) { + t.Errorf("Expected %d tokens, got %d", len(tc.expected), len(actualTypes)) + t.Errorf("Expected: %v", tc.expected) + t.Errorf("Actual: %v", actualTypes) + return + } + + for i, expected := range tc.expected { + if actualTypes[i] != expected { + t.Errorf("Token %d: expected %s, got %s", i, expected, actualTypes[i]) + } + } + }) + } +} From 13e7c292766ba13b8d3cf3d840dc78919070f799 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Thu, 25 Sep 2025 18:15:28 +0200 Subject: [PATCH 12/12] chore(version): v2.3.1 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c463409..281310b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. - - - +## v2.3.1 - 2025-09-25 +#### Bug Fixes +- pgn disambiguation squares (#75) - (362a897) - Corentin Giaufer Saubert +- Potential fix for code scanning alert no. 1: Workflow does not contain permissions (#74) - (34f57bc) - Corentin Giaufer Saubert +#### Documentation +- improve formatting and readability in README.md - (f9102d2) - CorentinGS +#### Refactoring +- clean up code by removing unnecessary blank lines and optimizing variable initialization - (eefa43f) - CorentinGS +- reorganize fields in Scanner and SearchResults structs for clarity - (6a3f038) - CorentinGS +- streamline variable initialization in parseMoveText function - (a508986) - CorentinGS +- improve error handling in TestBytesBookSource and enhance string comparison in TestChessHasher - (503a6d7) - CorentinGS +- optimize token handling in parseMoveText - (71f7da8) - CorentinGS +- simplify cloneChildren method by removing unnecessary nil check - (5f4e014) - CorentinGS +- simplify evaluatePositionStatus method and optimize path collection in Split - (9879d58) - CorentinGS +- remove unnecessary break statements in addTags function - (712f228) - CorentinGS + +- - - + ## v2.3.0 - 2025-09-18 #### Bug Fixes - experiment for issue #61 - (5231e8f) - CorentinGS