diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5aa3029c..dd83fb47 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: go-version: '^1.26' steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go id: setup-go @@ -34,7 +34,7 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 @@ -45,7 +45,7 @@ jobs: run: go test -v ./... -coverprofile=coverage.txt -covermode=atomic - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: file: ./coverage.txt token: ${{ secrets.CODECOV_TOKEN }} @@ -57,7 +57,7 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 @@ -79,7 +79,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Generate changelog uses: release-drafter/release-drafter@v7 @@ -93,7 +93,7 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Generate Go Report Card uses: creekorful/goreportcard-action@v1.0 diff --git a/.gitignore b/.gitignore index 702292ea..f42e87a3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ _testmain.go # Benchmarking cpu.out + +# Coverage +coverage.out .idea stockfish .worktrees/ diff --git a/.golangci.yml b/.golangci.yml index 4c79773f..4870fdb4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,371 +1,212 @@ -# This code is licensed under the terms of the MIT license https://opensource.org/license/mit -# Copyright (c) 2021 Marat Reymers - -## Golden config for golangci-lint v1.62.2 -# -# This is the best config for golangci-lint based on my experience and opinion. -# It is very strict, but not extremely strict. -# Feel free to adapt and change it for your needs. - -run: - # Timeout for analysis, e.g. 30s, 5m. - # Default: 1m - timeout: 3m - - -# This file contains only configs which differ from defaults. -# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml -linters-settings: - cyclop: - # The maximal code complexity to report. - # Default: 10 - max-complexity: 30 - # The maximal average package complexity. - # If it's higher than 0.0 (float) the check is enabled - # Default: 0.0 - package-average: 10.0 - - errcheck: - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. - # Such cases aren't reported by default. - # Default: false - check-type-assertions: true - - exhaustive: - # Program elements to check for exhaustiveness. - # Default: [ switch ] - check: - - switch - - map - - exhaustruct: - # List of regular expressions to exclude struct packages and their names from checks. - # Regular expressions must match complete canonical struct package/name/structname. - # Default: [] - exclude: - # std libs - - "^net/http.Client$" - - "^net/http.Cookie$" - - "^net/http.Request$" - - "^net/http.Response$" - - "^net/http.Server$" - - "^net/http.Transport$" - - "^net/url.URL$" - - "^os/exec.Cmd$" - - "^reflect.StructField$" - # public libs - - "^github.com/Shopify/sarama.Config$" - - "^github.com/Shopify/sarama.ProducerMessage$" - - "^github.com/mitchellh/mapstructure.DecoderConfig$" - - "^github.com/prometheus/client_golang/.+Opts$" - - "^github.com/spf13/cobra.Command$" - - "^github.com/spf13/cobra.CompletionOptions$" - - "^github.com/stretchr/testify/mock.Mock$" - - "^github.com/testcontainers/testcontainers-go.+Request$" - - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" - - "^golang.org/x/tools/go/analysis.Analyzer$" - - "^google.golang.org/protobuf/.+Options$" - - "^gopkg.in/yaml.v3.Node$" - - funlen: - # Checks the number of lines in a function. - # If lower than 0, disable the check. - # Default: 60 - lines: 100 - # Checks the number of statements in a function. - # If lower than 0, disable the check. - # Default: 40 - statements: 50 - # Ignore comments when counting lines. - # Default false - ignore-comments: true - - gochecksumtype: - # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. - # Default: true - default-signifies-exhaustive: false - - gocognit: - # Minimal code complexity to report. - # Default: 30 (but we recommend 10-20) - min-complexity: 20 - - gocritic: - # Settings passed to gocritic. - # The settings key is the name of a supported gocritic checker. - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - captLocal: - # Whether to restrict checker to params only. - # Default: true - paramsOnly: false - underef: - # Whether to skip (*x).method() calls where x is a pointer receiver. - # Default: true - skipRecvDeref: false - - gomodguard: - blocked: - # List of blocked modules. - # Default: [] - modules: - - github.com/golang/protobuf: - recommendations: - - google.golang.org/protobuf - reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" - - github.com/satori/go.uuid: - recommendations: - - github.com/google/uuid - reason: "satori's package is not maintained" - - github.com/gofrs/uuid: - recommendations: - - github.com/gofrs/uuid/v5 - reason: "gofrs' package was not go module before v5" - - govet: - # Enable all analyzers. - # Default: false - enable-all: true - # Disable analyzers by name. - # Run `go tool vet help` to see all analyzers. - # Default: [] - disable: - - fieldalignment # too strict - # Settings per analyzer. - settings: - shadow: - # Whether to be strict about shadowing; can be noisy. - # Default: false - strict: true - - inamedparam: - # Skips check for interface methods with only a single parameter. - # Default: false - skip-single-param: true - - mnd: - # List of function patterns to exclude from analysis. - # Values always ignored: `time.Date`, - # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, - # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. - # Default: [] - ignored-functions: - - args.Error - - flag.Arg - - flag.Duration.* - - flag.Float.* - - flag.Int.* - - flag.Uint.* - - os.Chmod - - os.Mkdir.* - - os.OpenFile - - os.WriteFile - - prometheus.ExponentialBuckets.* - - prometheus.LinearBuckets - - nakedret: - # Make an issue if func has more lines of code than this setting, and it has naked returns. - # Default: 30 - max-func-lines: 0 - - nolintlint: - # Exclude following linters from requiring an explanation. - # Default: [] - allow-no-explanation: [ funlen, gocognit, lll ] - # Enable to require an explanation of nonzero length after each nolint directive. - # Default: false - require-explanation: true - # Enable to require nolint directives to mention the specific linter being suppressed. - # Default: false - require-specific: true - - perfsprint: - # Optimizes into strings concatenation. - # Default: true - strconcat: false - - reassign: - # Patterns for global variable names that are checked for reassignment. - # See https://github.com/curioswitch/go-reassign#usage - # Default: ["EOF", "Err.*"] - patterns: - - ".*" - - rowserrcheck: - # database/sql is always checked - # Default: [] - packages: - - github.com/jmoiron/sqlx - - sloglint: - # Enforce not using global loggers. - # Values: - # - "": disabled - # - "all": report all global loggers - # - "default": report only the default slog logger - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global - # Default: "" - no-global: "all" - # Enforce using methods that accept a context. - # Values: - # - "": disabled - # - "all": report all contextless calls - # - "scope": report only if a context exists in the scope of the outermost function - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only - # Default: "" - context: "scope" - - tenv: - # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. - # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. - # Default: false - all: true - - +version: "2" linters: - disable-all: true + default: none enable: - ## enabled by default - - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - - gosimple # specializes in simplifying a code - - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - - ineffassign # detects when assignments to existing variables are not used - - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - - typecheck # like the front-end of a Go compiler, parses and type-checks Go code - #- unused # checks for unused constants, variables, functions and types - ## disabled by default - - asasalint # checks for pass []any as any in variadic func(...any) - - asciicheck # checks that your code does not contain non-ASCII identifiers - - bidichk # checks for dangerous unicode character sequences - - bodyclose # checks whether HTTP response body is closed successfully - - canonicalheader # checks whether net/http.Header uses canonical header - - copyloopvar # detects places where loop variables are copied (Go 1.22+) - - cyclop # checks function and package cyclomatic complexity - - dupl # tool for code clone detection - - durationcheck # checks for two durations multiplied together - - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - #- exhaustive # checks exhaustiveness of enum switch statements - - fatcontext # detects nested contexts in loops - - forbidigo # forbids identifiers - - funlen # tool for detection of long functions - - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - #- gochecknoglobals # checks that no global variables exist - - gochecknoinits # checks that no init functions are present in Go code - - gochecksumtype # checks exhaustiveness on Go "sum types" - - gocognit # computes and checks the cognitive complexity of functions - - goconst # finds repeated strings that could be replaced by a constant - - gocritic # provides diagnostics that check for bugs, performance and style issues - - gocyclo # computes and checks the cyclomatic complexity of functions - - godot # checks if comments end in a period - - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations - - goprintffuncname # checks that printf-like functions are named with f at the end - - gosec # inspects source code for security problems - - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution - - intrange # finds places where for loops could make use of an integer range - #- lll # reports long lines - - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - - makezero # finds slice declarations with non-zero initial length - - mirror # reports wrong mirror patterns of bytes/strings usage - - mnd # detects magic numbers - - musttag # enforces field tags in (un)marshaled structs - - nakedret # finds naked returns in functions greater than a specified function length - - nestif # reports deeply nested if statements - - nilerr # finds the code that returns nil even if it checks that the error is not nil - - nilnil # checks that there is no simultaneous return of nil error and an invalid value - - noctx # finds sending http request without context.Context - - nolintlint # reports ill-formed or insufficient nolint directives - - nonamedreturns # reports all named returns - - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL - - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative - - predeclared # finds code that shadows one of Go's predeclared identifiers - - promlinter # checks Prometheus metrics naming via promlint - - protogetter # reports direct reads from proto message fields when getters should be used - - reassign # checks that package variables are not reassigned - - recvcheck # checks for receiver type consistency - - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint - - rowserrcheck # checks whether Err of rows is checked successfully - - sloglint # ensure consistent code style when using log/slog - - spancheck # checks for mistakes with OpenTelemetry/Census spans - - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - - stylecheck # is a replacement for golint - - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 - - testableexamples # checks if examples are testable (have an expected output) - - testifylint # checks usage of github.com/stretchr/testify - #- testpackage # makes you use a separate _test package - - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - - unconvert # removes unnecessary type conversions - - unparam # reports unused function parameters - - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - - wastedassign # finds wasted assignment statements - - whitespace # detects leading and trailing whitespace - - ## you may want to enable - #- decorder # checks declaration order and count of types, constants, variables and functions - #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized - #- gci # controls golang package import order and makes it always deterministic - #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega - #- godox # detects FIXME, TODO and other comment keywords - #- goheader # checks is file header matches to pattern - #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters - #- interfacebloat # checks the number of methods inside an interface - #- ireturn # accept interfaces, return concrete types - #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated - #- tagalign # checks that struct tags are well aligned - #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope - #- wrapcheck # checks that errors returned from external packages are wrapped - #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event - - ## disabled - #- containedctx # detects struct contained context.Context field - #- contextcheck # [too many false positives] checks the function whether use a non-inherited context - #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages - #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) - #- dupword # [useless without config] checks for duplicate words in the source code - #- err113 # [too strict] checks the errors handling expressions - #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted - #- exportloopref # [not necessary from Go 1.22] checks for pointers to enclosing loop variables - #- forcetypeassert # [replaced by errcheck] finds forced type assertions - #- gofmt # [replaced by goimports] checks whether code was gofmt-ed - #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed - #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase - #- grouper # analyzes expression groups - #- importas # enforces consistent import aliases - #- maintidx # measures the maintainability index of each function - #- misspell # [useless] finds commonly misspelled English words in comments - #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity - #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test - #- tagliatelle # checks the struct tags - #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers - #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines - - -issues: - # Maximum count of issues with the same text. - # Set to 0 to disable. - # Default: 3 - max-same-issues: 50 - - exclude-rules: - - source: "(noinspection|TODO)" - linters: [ godot ] - - source: "//noinspection" - linters: [ gocritic ] - - path: "_test\\.go" - linters: - - bodyclose - - dupl - - errcheck + - asasalint + - asciicheck + - bidichk + - bodyclose + - canonicalheader + - copyloopvar + - cyclop + - dupl + - durationcheck + - errcheck + - errname + - errorlint + - fatcontext + - forbidigo + - funlen + - gocheckcompilerdirectives + - gochecknoinits + - gochecksumtype + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - gomoddirectives + - gomodguard_v2 + - goprintffuncname + - gosec + - govet + - iface + - ineffassign + - intrange + - loggercheck + - makezero + - mirror + - musttag + - nakedret + - nestif + - nilerr + - nilnil + - noctx + - nolintlint + - nonamedreturns + - nosprintfhostport + - perfsprint + - predeclared + - promlinter + - protogetter + - reassign + - recvcheck + - revive + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - staticcheck + - testableexamples + - testifylint + - tparallel + - unconvert + - unparam + - usestdlibvars + - usetesting + - wastedassign + - whitespace + settings: + cyclop: + max-complexity: 30 + package-average: 10 + errcheck: + check-type-assertions: true + exhaustive: + check: + - switch + - map + exhaustruct: + exclude: + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + funlen: + lines: 100 + statements: 50 + ignore-comments: true + gocognit: + min-complexity: 20 + gochecksumtype: + default-signifies-exhaustive: false + gocritic: + settings: + captLocal: + paramsOnly: false + underef: + skipRecvDeref: false + gomodguard_v2: + blocked: + - module: github.com/golang/protobuf + recommendations: + - google.golang.org/protobuf + reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - module: github.com/satori/go.uuid + recommendations: + - github.com/google/uuid + reason: satori's package is not maintained + - module: github.com/gofrs/uuid + recommendations: + - github.com/gofrs/uuid/v5 + reason: gofrs' package was not go module before v5 + govet: + disable: + - fieldalignment + enable-all: true + settings: + shadow: + strict: true + inamedparam: + skip-single-param: true + nakedret: + max-func-lines: 0 + nolintlint: + require-explanation: true + require-specific: true + allow-no-explanation: - funlen - - goconst - - gosec - - noctx - - wrapcheck - - govet - - itrange - - staticcheck - - gochecknoglobals - - godot - gocognit + - lll + perfsprint: + strconcat: false + reassign: + patterns: + - .* + rowserrcheck: + packages: + - github.com/jmoiron/sqlx + sloglint: + no-global: all + context: scope + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - godot + source: (noinspection|TODO) + - linters: + - gocritic + source: //noinspection + # G115: integer overflow conversions. Files/ranks are 0-7 and squares 0-63, + # so narrowing casts in bitboard math are provably in range. + - linters: + - gosec + text: G115 + # G404: math/rand is intentional for weighted polyglot book picks, not security. + - linters: + - gosec + text: G404 + - linters: + - bodyclose + - dupl + - errcheck + - funlen + - gochecknoglobals + - gocognit + - goconst + - godot + - gosec + - govet + - itrange + - noctx + - staticcheck + - wrapcheck + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-same-issues: 50 +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9adc76..44624250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ All notable changes to this project will be documented in this file. See [conven - - - +## v3.0.0-beta.2 - 2026-06-29 + +#### Performance +- speed up full PGN-to-`Game` decoding by resolving SAN moves from direct candidate origins instead of scanning every legal move. +- reduce parsed position snapshot allocation count by storing board state directly inside internal `Position` values while keeping public defensive-copy APIs unchanged. +- keep full PGN decode behavior compatible for move tags, checks, castling, promotions, en passant, variations, and parser errors. + +#### Documentation +- document the v3 beta PGN decode performance profile and benchmark workflow. + +- - - + +## v3.0.0-beta.1 - 2026-06-19 + +v3 is a major redesign implementing [RFC-001](docs/adr/RFC-001-v3-redesign.md). +See [MIGRATION.md](MIGRATION.md) for a detailed guide to upgrading from v2. + +#### Breaking Changes +- module path changed to `github.com/corentings/chess/v3`. +- `Move` is now a 4-field value type (`s1`, `s2`, `promo`, `tags`), passed by value everywhere — `Move()` and `UnsafeMove()` accept `Move` not `*Move`. +- UCI command globals replaced with struct literals: `CmdUCI{}`, `CmdIsReady{}`, `CmdUCINewGame{}`, etc. +- `Game.Resign(color)` now returns `error`. +- `Game.Outcome` and `Game.Method` merged into `Outcome` + `OutcomeMethodPair` with `SetOutcomeMethod`/`ClearOutcome`. +- `Opening.Game()` returns a caller-owned clone of a pre-computed game. +- `Position.Hash()` (MD5) deprecated in favour of `Position.ZobristHash()` (uint64). + +#### Features +- move tree with full variation support (`Variations`, `AddVariation`, `Split`). +- extracted PGN renderer (`PGNRenderer`, `DefaultPGNRenderer`, `RenderGameTo`). +- `Game.WritePGN(w)` for direct writer output. +- UCI Adapter pattern (`Adapter`, `SubprocessAdapter`, `FakeAdapter`) for testability. +- SVG image generation API: `SVG(w, *Position, *SVGOptions)` with `//go:embed` piece assets. +- opening book: `NewBook(io.Reader)` for custom data, `DefaultBook()` singleton. +- notation `Encode`/`Decode` with value semantics. + +#### Performance +- incremental Zobrist hashing with `Position.ZobristHash()`. +- mailbox `[64]Piece` for O(1) `Board.Piece()` lookups. +- zero-allocation `BookECO.Find` via compact `uint32` move keys. +- non-allocating `Position.SamePosition` fallback (direct struct comparison). +- `Position.ValidMovesUnsafe()` and `Position.ValidMovesIter()` for allocation-sensitive callers. + ## v2.5.1 - 2026-06-19 #### Features - preserve PGN annotation block structure and add `Move.CommentBlocks()` for ordered comment items. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b42935d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing + +## Development + +```bash +git clone https://github.com/corentings/chess/v3 +cd chess +go test ./... +``` + +## Linting + +Run `golangci-lint` before submitting changes: + +```bash +golangci-lint run +``` + +## Benchmarks + +Benchmarks live alongside the code in `_test.go` files. Run them with: + +```bash +go test -bench=. +``` + +### Comparative benchmarking with benchstat + +To compare performance between two versions (e.g., before and after a change), +use [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat): + +```bash +# Install benchstat (once) +go install golang.org/x/perf/cmd/benchstat@latest + +# Capture baseline on the reference commit +git stash +git checkout +go test -bench=. -count=10 > base.txt +git checkout - +git stash pop + +# Capture new measurements on your branch +go test -bench=. -count=10 > new.txt + +# Compare +benchstat base.txt new.txt +``` + +`-count=10` runs each benchmark ten times so benchstat can compute statistical +significance. For noisy environments, increase the count. + +### Perft regression testing + +The `perft_test.go` file contains perft (performance test) cases that verify +move-generation correctness against known node counts. These run as part of +`go test ./...` and should always pass. + +The `benchcmp/` directory contains tooling for comparing perft performance +against a C++ reference implementation. See `benchcmp/cpp/pgn_bench.cpp` and +`benchcmp/perft/bench_test.go`. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..3067f5ca --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,215 @@ +# Migrating to v3 + +v3 is a breaking release focused on a smaller `Move` value type, extracted PGN +rendering, terminal-outcome guards, and modernized `opening`, `image`, and `uci` +packages. This guide lists every breaking change and how to update your code. + +## Module path + +The module path now reflects major version 3: + +```bash +go get -u github.com/corentings/chess/v3 +``` + +```go +import "github.com/corentings/chess/v3" +``` + +Update import paths in every sub-package you use (`/v3/image`, `/v3/opening`, +`/v3/uci`). + +## Move is a value, not a pointer + +`Move` is now a 4-field value (`s1`, `s2`, `promo`, `tags`). Tree navigation and +annotations live on the separate `MoveNode` type. `ValidMoves()` returns `[]Move` +and the move methods accept `Move` by value. + +```go +// v2 +moves := game.ValidMoves() +if err := game.Move(&moves[0], nil); err != nil { /* ... */ } + +// v3 +moves := game.ValidMoves() +node, err := game.Move(moves[0], nil) +if err != nil { /* ... */ } +_ = node +``` + +The same change applies to `UnsafeMove`, `PushMove`, `PushMoveText`, and +`UnsafePushMoveText`: they now return `(*MoveNode, error)`. The old +`PushMoveOptions` type is replaced by `MoveInsertOptions` with +`PromoteToMainLine`. + +Active move insertion remains a `Game` responsibility. Use the `Game` move +methods above to advance the current position so legality checks, terminal game +guards, and outcome evaluation stay in sync. `MoveTree` is exposed for +topology, cursor navigation, and variation traversal/editing; it does not have +a public active-move insertion method. +`Move.Clone()` has been removed — copy by assignment (`m2 := m1`) if you need a +value copy. + +In v2, `*Move` carried both the move data and its position/context. In v3 these +are split: `Move` is the bare value; `MoveNode` holds the tree position, +children, comments, and NAGs. Code that iterated moves and accessed position +data must now navigate via `MoveNode`: + +```go +// v2 +for _, m := range game.Moves() { + fmt.Println(m.Position().Turn()) // Move carried the resulting position +} + +// v3 +for _, node := range game.MoveTree().MainLine() { + fmt.Println(node.Position().Turn()) // position is on MoveNode +} +``` + +`game.Moves()` still returns `[]Move` (bare values, main line only). Use +`game.MoveTree()` for tree access: `tree.Root()`, `tree.Current()`, +`tree.MainLine()`, `tree.Continuations(node)`, `tree.Variations(node)`, +`node.Comments()`, `node.SetCommand(key, val)`, `node.SetComment(text)`, +`node.AddComment(text)`, `node.NAG()`, and `node.Children()` / `node.Parent()` +for variation traversal. + +## Notation APIs moved to move text codecs + +v3 removes the old notation structs and game notation methods. Use +`MoveTextCodec` values instead: + +- `AlgebraicNotation{}` -> `chess.SAN()` +- PGN import leniency -> `chess.PGNImportSAN()` +- `LongAlgebraicNotation{}` -> `chess.LongAlgebraic()` +- `UCINotation{}` -> `chess.UCI()` +- `Game.PushNotationMove(text, notation, opts)` -> `Game.PushMoveText(text, codec, opts)` +- `Game.UnsafePushNotationMove(text, notation, opts)` -> `Game.UnsafePushMoveText(text, codec, opts)` + +```go +game := chess.NewGame() +_, err := game.PushMoveText("e4", chess.SAN(), nil) +_, err = game.PushMoveText("e2e4", chess.UCI(), nil) +_, err = game.UnsafePushMoveText("e2e4", chess.UCI(), nil) +``` + +`PushMove` remains a strict SAN shorthand. Unsafe move text supports only fully +specified formats such as UCI and long algebraic notation; SAN and PGN import +SAN require legal resolution and are rejected by `UnsafePushMoveText`. + +## UCI commands are struct literals + +Global command values are now struct types. Pass them as struct literals: + +```go +// v2 +eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame) + +// v3 +eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}) +``` + +Commands that carry data (`CmdPosition`, `CmdGo`, `CmdSetOption`) are still +constructed as struct values: + +```go +eng.Run(uci.CmdPosition{Position: game.Position()}, uci.CmdGo{MoveTime: time.Second / 100}) +``` + +A new `Adapter` interface (`SubprocessAdapter`, `FakeAdapter`) backs the engine +and is injectable via `uci.NewWithAdapter`. `eng.SearchResults().BestMove` is now +a `chess.Move` value. + +## Image takes a Position and options + +`image.SVG` now accepts `*chess.Position` (instead of `*Board`) and an +`*SVGOptions` struct: + +```go +// v2 +image.SVG(w, board, image.Perspective(chess.Black)) + +// v3 +image.SVG(w, game.Position(), &image.SVGOptions{Perspective: chess.Black}) +``` + +`SVGOptions` exposes `SquareSize` (squares are no longer fixed at 45px). Piece +SVGs are embedded with `//go:embed` and cached at init. + +## Opening returns errors + +`NewBookECO()` parsed the ECO table on every call and called `log.Fatal` on +failure. It is replaced by a lazily-initialized singleton and a reader-based +constructor, both returning errors: + +```go +// v2 +book := opening.NewBookECO() // *BookECO, panics on error + +// v3 +book, err := opening.DefaultBook() // *BookECO, error, sync.Once +// or +book, err := opening.NewBook(r) // *BookECO, error, from any io.Reader +``` + +## Resign returns an error + +`Game.Resign` now validates state and returns an error instead of silently +no-oping after the game has ended: + +```go +// v2 +game.Resign(chess.Black) + +// v3 +if err := game.Resign(chess.Black); err != nil { + // game already ended, etc. +} +``` + +## Terminal outcome guards + +`Move`, `UnsafeMove`, and `PushMoveText` reject moves once the game has +reached a terminal outcome and return `chess.ErrGameAlreadyEnded`. Call +`Game.ClearOutcome()` to resume, or `Game.SetOutcomeMethod(method, outcome)` to +set an outcome explicitly with validation. `Split()` now recomputes each line's +outcome from its leaf position instead of copying the parent outcome. + +## Null moves + +v3 adds explicit null move support. A null move flips the side to move without +moving any piece — used by engines for null-move pruning and by PGN +annotations to indicate a side passed. + +```go +g := chess.NewGame() +node, err := g.NullMove() // flips turn, serializes as "Z0" in PGN +``` + +`NewNullMove()` returns a `Move` carrying the `Null` tag. Null moves are never +returned by `ValidMoves()` and are rejected by `Game.Move`. Insert them via +`Game.NullMove()` or `Game.UnsafeMove(NewNullMove(), nil)`. When reading PGN, +the parser accepts five spellings: `Z0`, `Z1`, `--`, `@@`, and `0000`. The +renderer always writes `Z0` (ChessBase/Scid convention). + +## PGN rendering + +`Game.String()` still returns PGN and now delegates to `chess.DefaultPGNRenderer`. +For custom rendering use the extracted renderer: + +```go +r := &chess.PGNRenderer{} +fmt.Println(r.Render(game)) // string +r.RenderGameTo(game, w) // write to an io.Writer +``` + +`Game.WritePGN(w)` is also available. The renderer is stateless. + +## New position helpers + +- `Position.ZobristHash() uint64` — cached Zobrist hash for O(1) position + comparison and repetition detection. The legacy `Position.Hash() [16]byte` is + kept as deprecated for compatibility. +- `Position.ValidMovesUnsafe()` and `Position.ValidMovesIter()` — + allocation-sensitive access for hot paths; see package docs for safety notes. +- `Board.Piece()` uses an internal mailbox for O(1) lookup. diff --git a/README.md b/README.md index ff696265..75a22bf7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,59 @@ -# Chess Library +# Chess — Go (Golang) chess library -[![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://godoc.org/github.com/corentings/chess) +[![pkg.go.dev](https://pkg.go.dev/badge/github.com/corentings/chess/v3.svg)](https://pkg.go.dev/github.com/corentings/chess/v3) [![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) [![codecov](https://codecov.io/gh/corentings/chess/branch/main/graph/badge.svg)](https://codecov.io/gh/corentings/chess) [![CI](https://github.com/corentings/chess/actions/workflows/ci.yaml/badge.svg)](https://github.com/corentings/chess/actions/workflows/ci.yaml) [![Go Version](https://img.shields.io/github/go-mod/go-version/corentings/chess)](https://golang.org/doc/devel/release.html) +> A fast, well-tested Go chess library for move generation, PGN/FEN, UCI/Stockfish, +> opening books, and board image generation. + +## Features + +- Legal **move generation** via [bitboards](https://chessprogramming.org/Bitboards) +- **PGN** encode/decode, plus a streaming **PGNDecoder** for large game databases +- **FEN** encode/decode for board positions +- **UCI** client to drive engines like [Stockfish](https://stockfishchess.org/) +- **Opening book** exploration (ECO — Encyclopaedia of Chess Openings) +- SVG board **image generation** +- **Notations**: Algebraic (SAN), Long Algebraic, and UCI +- Checkmate, stalemate, repetition, and draw detection +- **Variations** support in the move tree +- `Unsafe*` fast-path APIs for performance-critical paths + +## Table of Contents + +- [Introduction](#introduction) +- [Recent Updates](#recent-updates) +- [Why I Forked](#why-i-forked) +- [Credits](#credits) +- [Disclaimer](#disclaimer) +- [Contributions](#contributions) +- [Repo Structure](#repo-structure) +- [Installation](#installation) +- [Usage](#usage) + - [Example Random Game](#example-random-game) + - [Example Stockfish v. Stockfish](#example-stockfish-v-stockfish) + - [Movement](#movement) + - [Outcome](#outcome) + - [PGN](#pgn) + - [FEN](#fen) + - [Notations](#notations) + - [Moves](#moves) +- [Perft](#perft) +- [Variation Tree](#variation-tree) +- [Performance](#performance) + - [Benchmarks](#benchmarks) + ## 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. -![rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1](example.png) +![Chess board rendered from the starting position by the Go chess library](example.png) ## Recent Updates @@ -23,7 +63,7 @@ descriptive errors for invalid moves. This ensures consistent game correctness a **Performance Options**: Added unsafe variants for high-performance scenarios: - `UnsafeMove()` - ~1.5x faster than `Move()` -- `UnsafePushNotationMove()` - ~1.1x faster than `PushNotationMove()` +- `UnsafePushMoveText()` - ~1.1x faster than `PushMoveText()` **API Consistency**: Refactored move methods for clear validation behavior and consistent performance options across all move APIs. @@ -46,11 +86,8 @@ 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. -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. +**Breaking Changes**: v3 is a major release with API changes that are not backward compatible with v2. See the +[MIGRATION.md](MIGRATION.md) guide for a detailed list of breaking changes and how to update your code. **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: @@ -85,7 +122,7 @@ If you have ideas or want to help make this package more robust and widely usabl **chess** can be installed using "go get". ```bash -go get -u github.com/corentings/chess/v2 +go get -u github.com/corentings/chess/v3 ``` ## Usage @@ -99,7 +136,7 @@ import ( "fmt" "math/rand" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) func main() { @@ -109,7 +146,7 @@ func main() { // select a random move moves := game.ValidMoves() move := moves[rand.Intn(len(moves))] - if err := game.Move(&move, nil); err != nil { + if err := game.Move(move, nil); err != nil { panic(err) // Should not happen with valid moves } } @@ -146,8 +183,8 @@ import ( "fmt" "time" - "github.com/corentings/chess/v2" - "github.com/corentings/chess/v2/uci" + "github.com/corentings/chess/v3" + "github.com/corentings/chess/v3/uci" ) func main() { @@ -158,7 +195,7 @@ func main() { } defer eng.Close() // initialize uci with new game - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}); err != nil { panic(err) } // have stockfish play speed chess against itself (10 msec per move) @@ -194,7 +231,7 @@ The library offers two move execution methods to balance safety and performance: ```go game := chess.NewGame() moves := game.ValidMoves() -err := game.Move(&moves[0], nil) +err := game.Move(moves[0], nil) if err != nil { // Handle invalid move error } @@ -206,28 +243,28 @@ if err != nil { game := chess.NewGame() moves := game.ValidMoves() // Only use when you're certain the move is valid -err := game.UnsafeMove(&moves[0], nil) +err := game.UnsafeMove(moves[0], nil) if err != nil { // Handle error (should not occur with valid moves) } ``` -**PushNotationMove()** - Validates moves using any notation (recommended for general use): +**PushMoveText()** - Validates moves using any notation (recommended for general use): ```go game := chess.NewGame() -err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil) +err := game.PushMoveText("e4", chess.SAN(), nil) if err != nil { // Handle invalid move or notation error } ``` -**UnsafePushNotationMove()** - High-performance notation parsing without move validation: +**UnsafePushMoveText()** - 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) +err := game.UnsafePushMoveText("e2e4", chess.UCI(), nil) if err != nil { // Handle notation parsing error (should not occur with valid notation) } @@ -235,7 +272,7 @@ if err != nil { > **Performance Note**: > - `UnsafeMove()` provides ~1.5x performance improvement over `Move()` by skipping validation -> - `UnsafePushNotationMove()` provides ~1.1x performance improvement over `PushNotationMove()` by skipping move +> - `UnsafePushMoveText()` provides ~1.1x performance improvement over `PushMoveText()` by skipping move validation > - Use unsafe variants only when moves are pre-validated or known to be legal @@ -246,17 +283,17 @@ Valid moves generated from the game's current position: ```go game := chess.NewGame() moves := game.ValidMoves() -game.Move(&moves[0], nil) +game.Move(moves[0], nil) fmt.Println(moves[0]) // b1a3 ``` -#### Parse Notation +#### Parse Move Text -PushNotationMove method accepts string input using any supported notation: +PushMoveText method accepts move text using an explicit codec: ```go game := chess.NewGame() -if err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { +if err := game.PushMoveText("e4", chess.SAN(), nil); err != nil { // handle error } ``` @@ -273,7 +310,7 @@ game := chess.NewGame() 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 { +if err := game.Move(validMoves[0], nil); err != nil { fmt.Println("Move failed:", err) } else { fmt.Println("Move succeeded") @@ -281,14 +318,14 @@ fmt.Println("Move succeeded") } // Using notation parsing with validation -if err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { +if err := game.PushMoveText("e4", chess.SAN(), nil); err != nil { fmt.Println("Move failed:", err) } else { fmt.Println("e4 move succeeded") } // Invalid notation will be caught -if err := game.PushNotationMove("e5", chess.AlgebraicNotation{}, nil); err != nil { +if err := game.PushMoveText("e5", chess.SAN(), nil); err != nil { fmt.Println("Move failed:", err) // Output: Move failed: [invalid move error] } @@ -301,13 +338,13 @@ game := chess.NewGame() // Option 1: Using Move structs directly (~1.5x faster) validMoves := game.ValidMoves() -selectedMove := &validMoves[0] // We know this is valid +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 } // Option 2: Using notation (~1.1x faster) -if err := game.UnsafePushNotationMove("e4", chess.AlgebraicNotation{}, nil); err != nil { +if err := game.UnsafePushMoveText("e4", chess.SAN(), nil); err != nil { panic(err) // Should not happen with valid notation/moves } ``` @@ -323,10 +360,10 @@ Black wins by checkmate (Fool's Mate): ```go game := chess.NewGame() -game.PushNotationMove("f3", chess.AlgebraicNotation{}, nil) -game.PushNotationMove("e6", chess.AlgebraicNotation{}, nil) -game.PushNotationMove("g4", chess.AlgebraicNotation{}, nil) -game.PushNotationMove("Qh4", chess.AlgebraicNotation{}, nil) +game.PushMoveText("f3", chess.SAN(), nil) +game.PushMoveText("e6", chess.SAN(), nil) +game.PushMoveText("g4", chess.SAN(), nil) +game.PushMoveText("Qh4", chess.SAN(), nil) fmt.Println(game.Outcome()) // 0-1 fmt.Println(game.Method()) // Checkmate /* @@ -350,7 +387,7 @@ Black king has no safe move: fenStr := "k1K5/8/8/8/8/8/8/1Q6 w - - 0 1" fen, _ := chess.FEN(fenStr) game := chess.NewGame(fen) -game.PushNotationMove("Qb6", chess.AlgebraicNotation{}, nil) +game.PushMoveText("Qb6", chess.SAN(), nil) fmt.Println(game.Outcome()) // 1/2-1/2 fmt.Println(game.Method()) // Stalemate /* @@ -372,8 +409,10 @@ Black resigns and white wins: ```go game := chess.NewGame() -game.PushNotationMove("f3", chess.AlgebraicNotation{}, nil) -game.Resign(chess.Black) +game.PushMoveText("f3", chess.SAN(), nil) +if err := game.Resign(chess.Black); err != nil { + panic(err) +} fmt.Println(game.Outcome()) // 1-0 fmt.Println(game.Method()) // Resignation ``` @@ -399,7 +438,7 @@ until Fivefold Repetition. game := chess.NewGame() moves := []string{"Nf3", "Nf6", "Ng1", "Ng8", "Nf3", "Nf6", "Ng1", "Ng8"} for _, m := range moves { -game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) +game.PushMoveText(m, chess.SAN(), nil) } fmt.Println(game.EligibleDraws()) // [DrawOffer ThreefoldRepetition] ``` @@ -418,7 +457,7 @@ moves := []string{ "Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { -game.PushNotationMove(m, chess.AlgebraicNotation{}, nil) +game.PushMoveText(m, chess.SAN(), nil) } fmt.Println(game.Outcome()) // 1/2-1/2 fmt.Println(game.Method()) // FivefoldRepetition @@ -446,7 +485,7 @@ checkmate. ```go fen, _ := chess.FEN("2r3k1/1q1nbppp/r3p3/3pP3/pPpP4/P1Q2N2/2RN1PPP/2R4K b - b3 149 23") game := chess.NewGame(fen) -game.PushNotationMove("Kf8", chess.AlgebraicNotation{}, nil) +game.PushMoveText("Kf8", chess.SAN(), nil) fmt.Println(game.Outcome()) // 1/2-1/2 fmt.Println(game.Method()) // SeventyFiveMoveRule ``` @@ -509,8 +548,8 @@ Moves and tag pairs added to the PGN output: ```go game := chess.NewGame() game.AddTagPair("Event", "F/S Return Match") -game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil) -game.PushNotationMove("e5", chess.AlgebraicNotation{}, nil) +game.PushMoveText("e4", chess.SAN(), nil) +game.PushMoveText("e5", chess.SAN(), nil) fmt.Println(game) /* [Event "F/S Return Match"] @@ -522,14 +561,14 @@ fmt.Println(game) #### PGN comment annotations Parsed PGN comments preserve comment block boundaries, command annotation order, and duplicate command annotations. Use -`Move.CommentBlocks()` when an importer needs structured access to each `{...}` block and the ordered text or command +`MoveNode.CommentBlocks()` when an importer needs structured access to each `{...}` block and the ordered text or command items inside it. The returned blocks are defensive copies. ```go game := chess.NewGame(pgn) -move := game.Moves()[0] +node := game.MoveTree().MainLine()[0] -for _, block := range move.CommentBlocks() { +for _, block := range node.CommentBlocks() { for _, item := range block.Items { switch item.Kind { case chess.CommentText: @@ -541,32 +580,58 @@ for _, block := range move.CommentBlocks() { } ``` -The legacy helpers `Move.Comments()`, `Move.GetCommand()`, `Move.SetCommand()`, `Move.SetComment()`, and -`Move.AddComment()` remain available for callers that only need a flattened text comment or single command value. +The helpers `MoveNode.Comments()`, `MoveNode.GetCommand()`, `MoveNode.SetCommand()`, `MoveNode.SetComment()`, and +`MoveNode.AddComment()` remain available for callers that only need a flattened text comment or single command value. #### Scan PGN -For parsing large PGN database files use Scanner: +For parsing large PGN database files, use `PGNGames` (Go 1.23+ iterator): ```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() +for game, err := range chess.PGNGames(f) { + if err != nil { + log.Fatal("Failed to parse game: %v", err) + } + fmt.Println(game.GetTagPair("Site")) + // Output &{Site https://lichess.org/8jb5kiqw} +} +``` + +Or use `PGNDecoder` directly for index/offset access: + +```go +f, err := os.Open("lichess_db_standard_rated_2013-01.pgn") if err != nil { -log.Fatal("Failed to parse game: %v", err) + panic(err) } -fmt.Println(game.GetTagPair("Site")) -// Output &{Site https://lichess.org/8jb5kiqw} +defer f.Close() + +dec := chess.NewPGNDecoder(f) +for { + game, err := dec.Decode() + if err == io.EOF { + break + } + if err != nil { + log.Fatal("Failed to parse game: %v", err) + } + fmt.Println(game.GetTagPair("Site")) } ``` +`PGNDecoder` fully decodes each record into a `Game`, including move tree +positions, SAN resolution, checks, castling, promotions, en passant, comments, +NAGs, and tag pairs. In v3.0.0-beta.2 this path was tuned for large PGN +imports: SAN moves are resolved from direct candidate origins, and internal +position snapshots avoid a separate heap-allocated board copy while public +defensive-copy APIs remain unchanged. + #### Scan PGN expanding all variations To expand every variation into a distinct Game: @@ -574,19 +639,16 @@ 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} +for game, err := range chess.PGNGames(f, chess.WithPGNExpandVariations()) { + if err != nil { + log.Fatal("Failed to parse game: %v", err) + } + fmt.Println(game.GetTagPair("Site")) + // Output &{Site https://lichess.org/8jb5kiqw} } ``` @@ -618,10 +680,10 @@ pos := game.Position() fmt.Println(pos.String()) // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 ``` -### Notations +### Move Text Codecs [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 uses move text codecs when converting to and from PGN and for accepting move text. #### Algebraic Notation @@ -630,23 +692,23 @@ official chess notation used by FIDE. Examples: e2, e5, O-O (short castling), e8 ```go game := chess.NewGame() -game.PushNotationMove("e4", chess.AlgebraicNotation{}, nil) -game.PushNotationMove("e5", chess.AlgebraicNotation{}, nil) +game.PushMoveText("e4", chess.SAN(), nil) +game.PushMoveText("e5", chess.SAN(), nil) 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 +LongAlgebraic() 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() -game.PushNotationMove("f2f3", chess.LongAlgebraicNotation{}, nil) -game.PushNotationMove("e7e5", chess.LongAlgebraicNotation{}, nil) -game.PushNotationMove("g2g4", chess.LongAlgebraicNotation{}, nil) -game.PushNotationMove("Qd8h4", chess.LongAlgebraicNotation{}, nil) +game.PushMoveText("f2f3", chess.LongAlgebraic(), nil) +game.PushMoveText("e7e5", chess.LongAlgebraic(), nil) +game.PushMoveText("g2g4", chess.LongAlgebraic(), nil) +game.PushMoveText("Qd8h4", chess.LongAlgebraic(), nil) fmt.Println(game) // 1.f2f3 e7e5 2.g2g4 Qd8h4# 0-1 ``` @@ -657,9 +719,9 @@ Interface notation. Examples: e2e4, e7e5, e1g1 (white short castling), e7e8q (fo ```go game := chess.NewGame() -game.PushNotationMove("e2e4", chess.UCINotation{}, nil) -game.PushNotationMove("e7e5", chess.UCINotation{}, nil) -fmt.Println(game) // 1.e2e4 e7e5 * +game.PushMoveText("e2e4", chess.UCI(), nil) +game.PushMoveText("e7e5", chess.UCI(), nil) +fmt.Println(game) // 1.e4 e5 * ``` #### Text Representation @@ -684,7 +746,7 @@ fmt.Println(game.Position().Board().Draw()) ### Moves -Moves is a convenient API for accessing aligned positions, moves, and comments. Moves is useful when trying to understand detailed information about a game. Below is an +MoveHistory is a convenient API for accessing aligned positions, moves, and comments. MoveHistory is useful when trying to understand detailed information about a game. Below is an example showing how to see which side castled first. ```go @@ -694,7 +756,7 @@ import ( "fmt" "os" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) func main() { @@ -709,9 +771,9 @@ func main() { } game := chess.NewGame(pgn) color := chess.NoColor - for _, mh := range game.Moves() { - if mh.HasTag(chess.KingSideCastle) || mh.HasTag(chess.QueenSideCastle) { - color = mh.Position().Turn() + for _, mh := range game.MoveHistory() { + if mh.Move.HasTag(chess.KingSideCastle) || mh.Move.HasTag(chess.QueenSideCastle) { + color = mh.PrePosition.Turn() break } } @@ -726,6 +788,171 @@ func main() { } ``` +## Perft + +[Perft](https://www.chessprogramming.org/Perft) (performance test) is the +standard correctness and performance benchmark for a chess move generator: it +counts every legal move sequence of a given length reachable from a starting +position. This package exposes Perft on `*Position`; the traversal uses the +same legality rules that govern normal play while applying moves through an +internal make/unmake path for performance. + +### Bulk node count + +```go +package main + +import ( + "fmt" + + "github.com/corentings/chess/v3" +) + +func main() { + pos := chess.StartingPosition() + fmt.Println("startpos d5:", pos.Perft(5)) // 4 865 609 + fmt.Println("startpos d6:", pos.Perft(6)) // 119 060 324 +} +``` + +A depth of 0 returns 1 (the position itself counts as a leaf). A position with +no legal moves (checkmate or stalemate) returns 0 for any depth greater than 0. + +### Per-move breakdown (`Divide`) + +`Divide` returns the per-root-move Perft breakdown as a map from each legal +move to its leaf count. It is the standard way to localize a divergence when +two move generators disagree at a given depth. + +```go +package main + +import ( + "fmt" + "sort" + + "github.com/corentings/chess/v3" +) + +func main() { + pos := chess.StartingPosition() + results := pos.Divide(1) + + keys := make([]chess.Move, 0, len(results)) + for m := range results { + keys = append(keys, m) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + for _, m := range keys { + fmt.Printf("%s: %d\n", m, results[m]) + } +} +``` + +Map iteration order is unspecified; sort by `Move.String()` for a stable +display. Move strings are long-algebraic (`s1s2` plus a promotion suffix) and +are intended for debugging, not for chess notation. + +### Canonical positions + +The package's Perft tests verify six canonical positions from +[chessprogramming.org/Perft_Results](https://www.chessprogramming.org/Perft_Results) +through depth 4–6. For a side-by-side performance comparison against another +Go chess library, see [`benchcmp/perft/`](./benchcmp/perft/). + +## Move Tree + +A `Game` owns a `MoveTree` containing the moves that have been played. The root +is a synthetic position node before any move has been played. Each non-root +`MoveNode` holds the `Move` it represents, its `Position` after the move, its +parent and ordered continuations, plus comments, NAGs, and `[%clk ...]`-style +command annotations. The first continuation is the main line; later +continuations are variations. + +Use `Game.Move`, `Game.UnsafeMove`, `Game.PushMove`, or +`Game.PushMoveText` to play moves on the active cursor. Those methods keep +game legality, terminal outcome guards, and result evaluation in sync with the +tree. `MoveTree` exposes traversal, cursor navigation, and variation editing; +it does not expose a public API for advancing the active game state directly. + +```go +package main + +import ( + "fmt" + + "github.com/corentings/chess/v3" +) + +func main() { + g := chess.NewGame() + g.PushMove("e4", nil) + g.PushMove("e5", nil) + + // Walk the main line. + for _, n := range g.MoveTree().MainLine() { + fmt.Printf("%s ", n.Move()) + } + fmt.Println() +} +``` + +```go +package main + +import ( + "fmt" + "strings" + + "github.com/corentings/chess/v3" +) + +func main() { + g, _ := chess.ParsePGN(strings.NewReader( + `[Result "*"] 1. e4 e5 (1... c5 {Sicilian}) 2. Nf3 *`, + )) + + tree := g.MoveTree() + e4 := tree.MainChild(tree.Root()) // first move of the main line + + // Walk the main line by following the main continuation at each node. + fmt.Print("main: ") + for n := e4; n != nil; n = tree.MainChild(n) { + fmt.Printf("%s ", n.Move()) + } + fmt.Println() + + // Enumerate sideline variations at e4. + fmt.Print("variations at e4: ") + for _, v := range tree.Variations(e4) { + fmt.Printf("%s ", v.Move()) + } + fmt.Println() +} +``` + +Useful APIs over the tree: + +- `Game.MoveTree() *MoveTree` — returns the game tree and active cursor. +- `MoveTree.Root() *MoveNode` — root position node. +- `MoveTree.Current() *MoveNode` — active cursor node. +- `MoveTree.MainLine() []*MoveNode` — main-line nodes, excluding the root. +- `MoveTree.MainChild(node *MoveNode) *MoveNode` — main continuation. +- `MoveTree.Continuations(node *MoveNode) []*MoveNode` — all continuations. +- `MoveTree.Variations(node *MoveNode) []*MoveNode` — sideline continuations. +- `MoveTree.AddVariation(parent *MoveNode, move Move) (*MoveNode, error)` — + validate and append a sideline. +- `MoveTree.GoBack()`, `MoveTree.GoForward()`, and + `MoveTree.NavigateToMainLine()` — move the active cursor without changing + game outcome metadata. +- `node.Move()`, `node.Position()`, `node.Comments()`, `node.NAG()`, + `node.Children()`, and `node.Parent()` — per-node data. + +Perft does not store its results in the game tree; it walks a temporary move +tree internally, so it does not affect the `MoveTree` you see from `Game`. + ## Performance Chess has been performance tuned, using [pprof](https://golang.org/pkg/runtime/pprof/), with the goal of being fast @@ -740,13 +967,22 @@ The benchmarks can be run with the following command: go test -bench=. ``` -Results from the baseline 2015 MBP: +Results vary by hardware. For comparative benchmarking use +[`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat): ``` -BenchmarkBitboardReverse-4 2000000000 1.01 ns/op -BenchmarkStalemateStatus-4 500000 3116 ns/op -BenchmarkInvalidStalemateStatus-4 500000 2290 ns/op -BenchmarkPositionHash-4 1000000 1864 ns/op -BenchmarkValidMoves-4 100000 13445 ns/op -BenchmarkPGN-4 300 5549192 ns/op +go test -bench=. -count=10 > new.txt +benchstat base.txt new.txt ``` + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full benchmark workflow. + +The v3.0.0-beta.2 PGN decode work was measured with: + +``` +go test -run '^$' -bench '^BenchmarkPGN_FullGameDecode_(BigBig|Big|CompleteGameDetails|ExpandVariations)$' -benchmem -count=6 ./ +``` + +On the project `big_big.pgn` fixture, `BenchmarkPGN_FullGameDecode_BigBig` +improved from roughly `1.48s/op` and `6.62M allocs/op` to roughly `0.91s/op` +and `5.55M allocs/op` on the maintainer benchmark machine. diff --git a/attacks.go b/attacks.go new file mode 100644 index 00000000..74eda74e --- /dev/null +++ b/attacks.go @@ -0,0 +1,164 @@ +package chess + +func pawnCheckers(board *Board, kingSq Square, attacker Color) bitboard { + pawns := board.bbForPiece(NewPiece(Pawn, attacker)) + var checkers bitboard + for pawnBits := pawns; pawnBits != 0; pawnBits &= pawnBits - 1 { + pawnSq := squareFromBit(pawnBits & -pawnBits) + if pawnAttacks(attacker, pawnSq)&bbForSquare(kingSq) != 0 { + checkers |= bbForSquare(pawnSq) + } + } + return checkers +} + +func pawnAttacks(c Color, sq Square) bitboard { + bb := bbForSquare(sq) + if c == White { + return ((bb & ^bbFileH & ^bbRank8) >> 9) | ((bb & ^bbFileA & ^bbRank8) >> 7) + } + return ((bb & ^bbFileH & ^bbRank1) << 7) | ((bb & ^bbFileA & ^bbRank1) << 9) +} + +func pinnedRayForPiece(pos *Position, s1 Square) bitboard { + kingSq := pos.board.kingSquare(pos.turn) + if kingSq == NoSquare || alignedMasks[kingSq]&bbForSquare(s1) == 0 { + return 0 + } + fileStep := int(rayFileSteps[kingSq][s1]) + rankStep := int(rayRankSteps[kingSq][s1]) + if fileStep == 0 && rankStep == 0 { + return 0 + } + if betweenMasks[kingSq][s1]&^pos.board.emptySqs != 0 { + return 0 + } + diagonal := rayDiagonals[kingSq][s1] + file := int(s1.File()) + fileStep + rank := int(s1.Rank()) + rankStep + for file >= 0 && file < numOfSquaresInRow && rank >= 0 && rank < numOfSquaresInRow { + sq := NewSquare(File(file), Rank(rank)) + p := pos.board.Piece(sq) + if p != NoPiece { + if p.Color() != pos.turn.Other() || !piecePinsAlong(p.Type(), diagonal) { + return 0 + } + return betweenMasks[kingSq][sq] | bbForSquare(sq) + } + file += fileStep + rank += rankStep + } + return 0 +} + +func sliderBitboards(board *Board, c Color) (bitboard, bitboard, bitboard) { + if c == White { + return board.bbWhiteQueen, board.bbWhiteRook, board.bbWhiteBishop + } + return board.bbBlackQueen, board.bbBlackRook, board.bbBlackBishop +} + +func squaresAligned(a Square, b Square) bool { + fileDelta := int(a.File()) - int(b.File()) + if fileDelta < 0 { + fileDelta = -fileDelta + } + rankDelta := int(a.Rank()) - int(b.Rank()) + if rankDelta < 0 { + rankDelta = -rankDelta + } + return a.File() == b.File() || a.Rank() == b.Rank() || fileDelta == rankDelta +} + +// isInCheck returns true if the side to move is in check in the given position. +func isInCheck(pos *Position) bool { + kingSq := pos.board.kingSquare(pos.Turn()) + // king should only be missing in tests / examples + if kingSq == NoSquare { + return false + } + return squaresAreAttacked(pos, kingSq) +} + +// isSquareAttackedBy returns true if the given square is attacked by the specified color. +// This is a board-level operation that does not require a full Position. +// +//nolint:mnd // this is a formula to determine if a square is attacked +func isSquareAttackedBy(board *Board, sq Square, attacker Color) bool { + occ := ^board.emptySqs + + // hot path check to see if attack vector is possible + s2BB := board.blackSqs + if attacker == White { + s2BB = board.whiteSqs + } + diagAttacks := diaAttack(occ, sq) + orthogonalAttacks := hvAttack(occ, sq) + if ((diagAttacks|orthogonalAttacks)&s2BB)|(bbKnightMoves[sq]&s2BB) == 0 { + return false + } + + // check queen attack vector + queenBB := board.bbForPiece(NewPiece(Queen, attacker)) + bb := (diagAttacks | orthogonalAttacks) & queenBB + if bb != 0 { + return true + } + // check rook attack vector + rookBB := board.bbForPiece(NewPiece(Rook, attacker)) + bb = orthogonalAttacks & rookBB + if bb != 0 { + return true + } + // check bishop attack vector + bishopBB := board.bbForPiece(NewPiece(Bishop, attacker)) + bb = diagAttacks & bishopBB + if bb != 0 { + return true + } + // check knight attack vector + knightBB := board.bbForPiece(NewPiece(Knight, attacker)) + bb = bbKnightMoves[sq] & knightBB + if bb != 0 { + return true + } + // check pawn attack vector + if attacker == Black { + capRight := (board.bbBlackPawn & ^bbFileH & ^bbRank1) << 7 + capLeft := (board.bbBlackPawn & ^bbFileA & ^bbRank1) << 9 + bb = (capRight | capLeft) & bbForSquare(sq) + if bb != 0 { + return true + } + } else { + capRight := (board.bbWhitePawn & ^bbFileH & ^bbRank8) >> 9 + capLeft := (board.bbWhitePawn & ^bbFileA & ^bbRank8) >> 7 + bb = (capRight | capLeft) & bbForSquare(sq) + if bb != 0 { + return true + } + } + // check king attack vector + kingBB := board.bbForPiece(NewPiece(King, attacker)) + bb = bbKingMoves[sq] & kingBB + return bb != 0 +} + +// squaresAreAttacked returns true if the opponent attacks any of the given squares +// +// in the given position. +// +// The function checks attacks from: +// - Sliding pieces (queen, rook, bishop) +// - Knights +// - Pawns +// - King +func squaresAreAttacked(pos *Position, sqs ...Square) bool { + otherColor := pos.Turn().Other() + for _, sq := range sqs { + if isSquareAttackedBy(&pos.board, sq, otherColor) { + return true + } + } + return false +} diff --git a/benchcmp/cpp/pgn_bench.cpp b/benchcmp/cpp/pgn_bench.cpp new file mode 100644 index 00000000..ef45e9ba --- /dev/null +++ b/benchcmp/cpp/pgn_bench.cpp @@ -0,0 +1,113 @@ +// Standalone PGN parse benchmark using chess-library (Disservin/chess-library). +// +// Build: +// g++ -std=c++17 -O3 -DNDEBUG -march=native -flto \ +// -I /tmp/chess-library/include \ +// benchcmp/cpp/pgn_bench.cpp -o benchcmp/cpp/pgn_bench +// +// Run: +// ./benchcmp/cpp/pgn_bench /path/to/file.pgn [iterations] +// +// Mirrors the Go BenchmarkPGN_Stream_Big{Big,} workload: full SAN parse with +// move validation and a chess::Board maintained across each game. +#include +#include +#include +#include +#include +#include +#include + +#include "chess.hpp" + +using namespace chess; + +class FullVisitor : public pgn::Visitor { + public: + void startPgn() override {} + void header(std::string_view key, std::string_view value) override { + if (key == "FEN") board.setFen(value); + } + void startMoves() override { games_++; } + void move(std::string_view move, std::string_view) override { + Move m = uci::parseSan(board, move, moves); + if (m == Move::NO_MOVE) { + skipped_++; + this->skipPgn(true); + return; + } + board.makeMove(m); + positions_++; + } + void endPgn() override { board.setFen(constants::STARTPOS); } + + std::uint64_t games() const { return games_; } + std::uint64_t positions() const { return positions_; } + std::uint64_t skipped() const { return skipped_; } + + private: + Board board; + Movelist moves; + std::uint64_t games_ = 0; + std::uint64_t positions_ = 0; + std::uint64_t skipped_ = 0; +}; + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::fprintf(stderr, "Usage: %s [iterations]\n", argv[0]); + return 1; + } + + const std::filesystem::path file = argv[1]; + const int iterations = argc >= 3 ? std::atoi(argv[2]) : 1; + + std::error_code ec; + const auto size_bytes = std::filesystem::file_size(file, ec); + if (ec) { + std::fprintf(stderr, "stat %s: %s\n", file.c_str(), ec.message().c_str()); + return 1; + } + const double size_mb = static_cast(size_bytes) / (1000.0 * 1000.0); + + std::uint64_t total_games = 0; + std::uint64_t total_pos = 0; + std::uint64_t total_skipped = 0; + double best_seconds = 1e9; + + for (int i = 0; i < iterations; i++) { + FullVisitor vis; + std::ifstream in(file.string(), std::ios::binary); + if (!in) { + std::fprintf(stderr, "open %s failed\n", file.c_str()); + return 1; + } + + const auto t0 = std::chrono::high_resolution_clock::now(); + pgn::StreamParser parser(in); + const auto err = parser.readGames(vis); + const auto t1 = std::chrono::high_resolution_clock::now(); + + if (err) { + std::fprintf(stderr, "parse error: %s\n", err.message().c_str()); + return 1; + } + + const double seconds = + std::chrono::duration(t1 - t0).count(); + best_seconds = std::min(best_seconds, seconds); + total_games = vis.games(); + total_pos = vis.positions(); + total_skipped = vis.skipped(); + } + + const double mbps = size_mb / best_seconds; + std::printf( + "file=%s size=%.2fMB games=%llu pos=%llu skipped=%llu time=%.4fs mbps=%.2f iter=%d\n", + file.filename().string().c_str(), size_mb, + static_cast(total_games), + static_cast(total_pos), + static_cast(total_skipped), best_seconds, mbps, + iterations); + return 0; +} \ No newline at end of file diff --git a/benchcmp/perft/bench_test.go b/benchcmp/perft/bench_test.go new file mode 100644 index 00000000..47cc7686 --- /dev/null +++ b/benchcmp/perft/bench_test.go @@ -0,0 +1,154 @@ +// Package perftbench compares the Perft performance and correctness of +// github.com/corentings/chess/v3 against github.com/dylhunn/dragontoothmg on +// the six canonical Perft positions from +// https://www.chessprogramming.org/Perft_Results. +// +// dragontoothmg is GPL v3 — linking it is acceptable here because this is a +// developer-only benchmark sandbox, not a redistributable artifact. It is +// kept in a separate Go module so the main module's permissive license is +// unaffected. +package perftbench + +import ( + "fmt" + "testing" + + chess "github.com/corentings/chess/v3" + "github.com/dylhunn/dragontoothmg" +) + +// perftCase pairs a position (as FEN) with the expected node counts at each +// depth (1-indexed). Depths included are capped per case to keep the bench +// wall-clock reasonable on commodity hardware. +type perftCase struct { + name string + fen string + nodes []uint64 + maxDepth int +} + +var perftCases = []perftCase{ + { + name: "startpos", + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + nodes: []uint64{20, 400, 8902, 197281, 4865609, 119060324}, + maxDepth: 6, + }, + { + name: "kiwipete", + fen: "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + nodes: []uint64{48, 2039, 97862, 4085603, 193690690}, + maxDepth: 5, + }, + { + name: "pos3", + fen: "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + nodes: []uint64{14, 191, 2812, 43238, 674624, 11030083}, + maxDepth: 6, + }, + { + name: "pos4", + fen: "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", + nodes: []uint64{6, 264, 9467, 422333, 15833292}, + maxDepth: 5, + }, + { + name: "pos5", + fen: "r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1", + nodes: []uint64{6, 264, 9467, 422333, 15833292}, + maxDepth: 5, + }, + { + name: "pos6", + fen: "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", + nodes: []uint64{44, 1486, 62379, 2103487, 89941194}, + maxDepth: 5, + }, +} + +// TestCorrectness verifies both libraries agree with the canonical Perft +// node counts at every included depth. This is a hard gate — if either +// library disagrees with the canonical values, the benchmarks are meaningless. +func TestCorrectness(t *testing.T) { + for _, c := range perftCases { + t.Run(c.name, func(t *testing.T) { + for i, want := range c.nodes { + depth := i + 1 + if depth > c.maxDepth { + break + } + // chess/v3 + opt, err := chess.FEN(c.fen) + if err != nil { + t.Fatalf("chess FEN(%q): %v", c.fen, err) + } + g := chess.NewGame(opt) + chessGot := g.Position().Perft(depth) + + // dragontoothmg + dtb := dragontoothmg.ParseFen(c.fen) + dtGot := dragontoothmg.Perft(&dtb, depth) + + if uint64(chessGot) != want { + t.Errorf("chess depth %d: got %d, want %d", depth, chessGot, want) + } + if uint64(dtGot) != want { + t.Errorf("dragontoothmg depth %d: got %d, want %d", depth, dtGot, want) + } + if uint64(chessGot) != uint64(dtGot) { + t.Errorf("libraries disagree at depth %d: chess=%d, dragontoothmg=%d", depth, chessGot, dtGot) + } + } + }) + } +} + +// chessPerftAt is a benchmark helper that warms and resets the position +// per iteration so we measure a single Perft traversal, not allocator reuse. +func chessPerftAt(b *testing.B, fen string, depth int) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + opt, _ := chess.FEN(fen) + g := chess.NewGame(opt) + g.Position().Perft(depth) + } +} + +// dtPerftAt is the dragontoothmg equivalent. +func dtPerftAt(b *testing.B, fen string, depth int) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + dtb := dragontoothmg.ParseFen(fen) + dragontoothmg.Perft(&dtb, depth) + } +} + +// Benchmark suites are emitted programmatically so the per-position × per-depth +// grid is easy to scan and stable across refactors. +func BenchmarkChessPerft(b *testing.B) { + for _, c := range perftCases { + for i, want := range c.nodes { + depth := i + 1 + if depth > c.maxDepth { + break + } + b.Run(fmt.Sprintf("%s/d=%d/nodes=%d", c.name, depth, want), func(b *testing.B) { + chessPerftAt(b, c.fen, depth) + }) + } + } +} + +func BenchmarkDragontoothPerft(b *testing.B) { + for _, c := range perftCases { + for i, want := range c.nodes { + depth := i + 1 + if depth > c.maxDepth { + break + } + b.Run(fmt.Sprintf("%s/d=%d/nodes=%d", c.name, depth, want), func(b *testing.B) { + dtPerftAt(b, c.fen, depth) + }) + } + } +} diff --git a/benchcmp/perft/go.mod b/benchcmp/perft/go.mod new file mode 100644 index 00000000..e4ae8e2a --- /dev/null +++ b/benchcmp/perft/go.mod @@ -0,0 +1,10 @@ +module github.com/corentings/chess/benchcmp/perft + +go 1.25.0 + +require ( + github.com/corentings/chess/v3 v3.0.0 + github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93 +) + +replace github.com/corentings/chess/v3 => ../.. diff --git a/benchcmp/perft/go.sum b/benchcmp/perft/go.sum new file mode 100644 index 00000000..aeebce0e --- /dev/null +++ b/benchcmp/perft/go.sum @@ -0,0 +1,2 @@ +github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93 h1:+seiDwiD3oVmo7Lem5B5sI4feILZDJLgOK6gZYd6g0Y= +github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93/go.mod h1:L6ZI7rasNVYqjj/tpfqYRowKPuSQO71UCBBhPxamiDQ= diff --git a/bitboard.go b/bitboard.go index 6e6370f4..dedda981 100644 --- a/bitboard.go +++ b/bitboard.go @@ -93,21 +93,23 @@ func (b bitboard) String() string { // Draw returns visual representation of the bitboard useful for debugging. func (b bitboard) Draw() string { - s := "\n A B C D E F G H\n" + var sb strings.Builder + sb.Grow(154) + sb.WriteString("\n A B C D E F G H\n") for r := 7; r >= 0; r-- { - s += Rank(r).String() + sb.WriteString(Rank(r).String()) for f := range numOfSquaresInRow { sq := NewSquare(File(f), Rank(r)) if b.Occupied(sq) { - s += "1" + sb.WriteByte('1') } else { - s += "0" + sb.WriteByte('0') } - s += " " + sb.WriteByte(' ') } - s += "\n" + sb.WriteByte('\n') } - return s + return sb.String() } // Reverse returns a new bitboard with the bit order reversed, which can be diff --git a/bitboard_test.go b/bitboard_test.go index f0ba4930..1b6b3820 100644 --- a/bitboard_test.go +++ b/bitboard_test.go @@ -96,7 +96,7 @@ func TestBitboardOccupied(t *testing.T) { } func BenchmarkBitboardReverse(b *testing.B) { - for i := 0; i < b.N; i++ { + for range b.N { u := uint64(9223372036854775807) bitboard(u).Reverse() } diff --git a/board.go b/board.go index 115b2341..6759020b 100644 --- a/board.go +++ b/board.go @@ -6,40 +6,39 @@ allowing for efficient move generation and position analysis. Board Layout: - 8 | r n b q k b n r - 7 | p p p p p p p p - 6 | - - - - - - - - - 5 | - - - - - - - - - 4 | - - - - - - - - - 3 | - - - - - - - - - 2 | P P P P P P P P - 1 | R N B Q K B N R - --------------- - A B C D E F G H + 8 | r n b q k b n r + 7 | p p p p p p p p + 6 | - - - - - - - - + 5 | - - - - - - - - + 4 | - - - - - - - - + 3 | - - - - - - - - + 2 | P P P P P P P P + 1 | R N B Q K B N R + --------------- + A B C D E F G H Usage: - // Create a new board with starting position - squares := map[Square]Piece{ - NewSquare(FileE, Rank1): WhiteKing, - NewSquare(FileD, Rank8): BlackQueen, - } - board := NewBoard(squares) + // Create a new board with starting position + squares := map[Square]Piece{ + NewSquare(FileE, Rank1): WhiteKing, + NewSquare(FileD, Rank8): BlackQueen, + } + board := NewBoard(squares) - // Check piece at square - piece := board.Piece(NewSquare(FileE, Rank1)) + // Check piece at square + piece := board.Piece(NewSquare(FileE, Rank1)) - // Get all piece positions. - positions := board.SquareMap() + // Get all piece positions. + positions := board.SquareMap() */ - package chess import ( - "bytes" "encoding/binary" "errors" "fmt" + "strings" ) // Board represents a chess board and its relationship between squares and pieces. @@ -58,11 +57,11 @@ type Board struct { bbBlackBishop bitboard bbBlackKnight bitboard bbBlackPawn bitboard - whiteSqs bitboard // all white pieces - blackSqs bitboard // all black pieces - emptySqs bitboard // all empty squares - whiteKingSq Square // cached white king square - blackKingSq Square // cached black king square + whiteSqs bitboard // all white pieces + blackSqs bitboard // all black pieces + emptySqs bitboard // all empty squares + whiteKingSq Square // cached white king square + blackKingSq Square // cached black king square mailbox [numOfSquaresInBoard]Piece // O(1) piece lookup per square } @@ -111,7 +110,7 @@ func NewBoard(m map[Square]Piece) (*Board, error) { // SquareMap returns a mapping of squares to pieces. // A square is only added to the map if it is occupied. func (b *Board) SquareMap() map[Square]Piece { - m := map[Square]Piece{} + m := make(map[Square]Piece, numOfSquaresInBoard) for sq := range numOfSquaresInBoard { p := b.Piece(Square(sq)) if p != NoPiece { @@ -144,7 +143,7 @@ const ( // For UpDown, pieces are mirrored across the horizontal center line. // For LeftRight, pieces are mirrored across the vertical center line. func (b *Board) Flip(fd FlipDirection) (*Board, error) { - m := map[Square]Piece{} + m := make(map[Square]Piece, numOfSquaresInBoard) for sq := range numOfSquaresInBoard { var mv Square switch fd { @@ -164,7 +163,7 @@ func (b *Board) Flip(fd FlipDirection) (*Board, error) { // Transpose flips the board over the A8 to H1 diagonal. func (b *Board) Transpose() (*Board, error) { - m := map[Square]Piece{} + m := make(map[Square]Piece, numOfSquaresInBoard) for sq := range numOfSquaresInBoard { file := File(7 - Square(sq).Rank()) rank := Rank(7 - Square(sq).File()) @@ -195,7 +194,7 @@ func (b *Board) Draw() string { // Draw2 returns visual representation of the board useful for debugging. // It is similar to Draw() except allows the caller to specify perspective -// and dark mode options +// and dark mode options. func (b *Board) Draw2(perspective Color, darkMode bool) string { if perspective == Black { return b.drawForBlack(darkMode) @@ -204,50 +203,52 @@ func (b *Board) Draw2(perspective Color, darkMode bool) string { return b.drawForWhite(darkMode) } -// drawForWhite returns visual representation of the board from white's perspective +// drawForWhite returns visual representation of the board from white's perspective. func (b *Board) drawForWhite(darkMode bool) string { - s := "\n A B C D E F G H\n" + var sb strings.Builder + sb.Grow(154) + sb.WriteString("\n A B C D E F G H\n") for r := 7; r >= 0; r-- { - s += Rank(r).String() + sb.WriteString(Rank(r).String()) for f := range numOfSquaresInRow { p := b.Piece(NewSquare(File(f), Rank(r))) - if p == NoPiece { - s += "-" - } else { - if darkMode { - s += p.DarkString() - } else { - s += p.String() - } + switch { + case p == NoPiece: + sb.WriteByte('-') + case darkMode: + sb.WriteString(p.DarkString()) + default: + sb.WriteString(p.String()) } - s += " " + sb.WriteByte(' ') } - s += "\n" + sb.WriteByte('\n') } - return s + return sb.String() } -// drawForBlack returns visual representation of the board from black's perspective +// drawForBlack returns visual representation of the board from black's perspective. func (b *Board) drawForBlack(darkMode bool) string { - s := "\n H G F E D C B A\n" - for r := 0; r <= 7; r++ { - s += Rank(r).String() + var sb strings.Builder + sb.Grow(154) + sb.WriteString("\n H G F E D C B A\n") + for r := range 8 { + sb.WriteString(Rank(r).String()) for f := numOfSquaresInRow - 1; f >= 0; f-- { p := b.Piece(NewSquare(File(f), Rank(r))) - if p == NoPiece { - s += "-" - } else { - if darkMode { - s += p.DarkString() - } else { - s += p.String() - } + switch { + case p == NoPiece: + sb.WriteByte('-') + case darkMode: + sb.WriteString(p.DarkString()) + default: + sb.WriteString(p.String()) } - s += " " + sb.WriteByte(' ') } - s += "\n" + sb.WriteByte('\n') } - return s + return sb.String() } // String implements the fmt.Stringer interface and returns @@ -317,7 +318,7 @@ func (b *Board) MarshalText() ([]byte, error) { func (b *Board) UnmarshalText(text []byte) error { cp, err := fenBoard(string(text)) if err != nil { - return err + return fmt.Errorf("chess: unmarshal board FEN: %w", err) } *b = *cp return nil @@ -328,13 +329,15 @@ func (b *Board) UnmarshalText(text []byte) error { // in the following order: WhiteKing, WhiteQueen, WhiteRook, WhiteBishop, WhiteKnight // WhitePawn, BlackKing, BlackQueen, BlackRook, BlackBishop, BlackKnight, BlackPawn. func (b *Board) MarshalBinary() ([]byte, error) { - bbs := []bitboard{ + bbs := [...]bitboard{ b.bbWhiteKing, b.bbWhiteQueen, b.bbWhiteRook, b.bbWhiteBishop, b.bbWhiteKnight, b.bbWhitePawn, b.bbBlackKing, b.bbBlackQueen, b.bbBlackRook, b.bbBlackBishop, b.bbBlackKnight, b.bbBlackPawn, } - buf := new(bytes.Buffer) - err := binary.Write(buf, binary.BigEndian, bbs) - return buf.Bytes(), err + buf := make([]byte, 8*len(bbs)) + for i, bb := range bbs { + binary.BigEndian.PutUint64(buf[i*8:], uint64(bb)) + } + return buf, nil } // UnmarshalBinary implements the encoding.BinaryUnmarshaler interface and parses @@ -364,77 +367,109 @@ func (b *Board) UnmarshalBinary(data []byte) error { return nil } -//nolint:mnd // magic number is used for bitboard size. -func (b *Board) update(m *Move) { +func (b *Board) update(m Move) { p1 := b.Piece(m.s1) + captured := b.Piece(m.s2) s1BB := bbForSquare(m.s1) s2BB := bbForSquare(m.s2) - // move s1 piece to s2 - for _, p := range allPieces { - bb := b.bbForPiece(p) - // remove what was at s2 - if err := b.setBBForPiece(p, bb & ^s2BB); err != nil { - panic(fmt.Sprintf("chess: invariant violation in board update: %v", err)) + if p1 == NoPiece { + return + } + + whiteSqs := b.whiteSqs + blackSqs := b.blackSqs + if p1.Color() == White { + whiteSqs = (whiteSqs & ^s1BB) | s2BB + if captured != NoPiece { + blackSqs &^= s2BB } - // move what was at s1 to s2 - if bb.Occupied(m.s1) { - bb = b.bbForPiece(p) - if err := b.setBBForPiece(p, (bb & ^s1BB)|s2BB); err != nil { - panic(fmt.Sprintf("chess: invariant violation in board update: %v", err)) - } + } else { + blackSqs = (blackSqs & ^s1BB) | s2BB + if captured != NoPiece { + whiteSqs &^= s2BB } } - // check promotion - if m.promo != NoPieceType && p1 != NoPiece { + + if captured != NoPiece { + b.setPieceBB(captured, b.bbForPiece(captured)&^s2BB) + } + + b.setPieceBB(p1, b.bbForPiece(p1)&^s1BB) + + if m.promo != NoPieceType { newPiece := NewPiece(m.promo, p1.Color()) - // remove pawn - bbPawn := b.bbForPiece(p1) - if err := b.setBBForPiece(p1, bbPawn & ^s1BB & ^s2BB); err != nil { - panic(fmt.Sprintf("chess: invariant violation in board update: %v", err)) - } - // add promo piece - bbPromo := b.bbForPiece(newPiece) - if err := b.setBBForPiece(newPiece, bbPromo|s2BB); err != nil { - panic(fmt.Sprintf("chess: invariant violation in board update: %v", err)) - } + b.setPieceBB(newPiece, b.bbForPiece(newPiece)|s2BB) b.mailbox[m.s1] = NoPiece b.mailbox[m.s2] = newPiece } else { + b.setPieceBB(p1, b.bbForPiece(p1)|s2BB) b.mailbox[m.s1] = NoPiece b.mailbox[m.s2] = p1 } - // remove captured en passant piece + if m.HasTag(EnPassant) { if p1.Color() == White { - b.bbBlackPawn = ^(bbForSquare(m.s2) << 8) & b.bbBlackPawn + capturedBB := s2BB << 8 + b.bbBlackPawn = ^capturedBB & b.bbBlackPawn + blackSqs &^= capturedBB b.mailbox[m.s2-8] = NoPiece } else { - b.bbWhitePawn = ^(bbForSquare(m.s2) >> 8) & b.bbWhitePawn + capturedBB := s2BB >> 8 + b.bbWhitePawn = ^capturedBB & b.bbWhitePawn + whiteSqs &^= capturedBB b.mailbox[m.s2+8] = NoPiece } } - // move rook for castle + + b.moveRookForCastle(m, p1, &whiteSqs, &blackSqs) + + b.whiteSqs = whiteSqs + b.blackSqs = blackSqs + b.emptySqs = ^(whiteSqs | blackSqs) + + switch { + case p1 == WhiteKing: + b.whiteKingSq = m.s2 + case p1 == BlackKing: + b.blackKingSq = m.s2 + case captured == WhiteKing: + b.whiteKingSq = NoSquare + case captured == BlackKing: + b.blackKingSq = NoSquare + } +} + +func (b *Board) setPieceBB(p Piece, bb bitboard) { + if err := b.setBBForPiece(p, bb); err != nil { + panic(fmt.Sprintf("chess: invariant violation in board update: %v", err)) + } +} + +//nolint:mnd // magic number is used for bitboard shifts. +func (b *Board) moveRookForCastle(m Move, p1 Piece, whiteSqs, blackSqs *bitboard) { switch { case p1.Color() == White && m.HasTag(KingSideCastle): b.bbWhiteRook = b.bbWhiteRook & ^bbForSquare(H1) | bbForSquare(F1) + *whiteSqs = (*whiteSqs & ^bbForSquare(H1)) | bbForSquare(F1) b.mailbox[H1] = NoPiece b.mailbox[F1] = WhiteRook case p1.Color() == White && m.HasTag(QueenSideCastle): b.bbWhiteRook = (b.bbWhiteRook & ^bbForSquare(A1)) | bbForSquare(D1) + *whiteSqs = (*whiteSqs & ^bbForSquare(A1)) | bbForSquare(D1) b.mailbox[A1] = NoPiece b.mailbox[D1] = WhiteRook case p1.Color() == Black && m.HasTag(KingSideCastle): b.bbBlackRook = b.bbBlackRook & ^bbForSquare(H8) | bbForSquare(F8) + *blackSqs = (*blackSqs & ^bbForSquare(H8)) | bbForSquare(F8) b.mailbox[H8] = NoPiece b.mailbox[F8] = BlackRook case p1.Color() == Black && m.HasTag(QueenSideCastle): b.bbBlackRook = (b.bbBlackRook & ^bbForSquare(A8)) | bbForSquare(D8) + *blackSqs = (*blackSqs & ^bbForSquare(A8)) | bbForSquare(D8) b.mailbox[A8] = NoPiece b.mailbox[D8] = BlackRook } - - b.calcConvienceBBs(m) } func (b *Board) calcConvienceBBs(m *Move) { @@ -518,10 +553,23 @@ func (b *Board) hasSufficientMaterial() bool { if b.bbWhiteKing == 0 || b.bbBlackKing == 0 { return true } - count := map[PieceType]int{} - pieceMap := b.SquareMap() - for _, p := range pieceMap { - count[p.Type()]++ + var count [7]int + whiteCount := 0 + blackCount := 0 + for sq, p := range b.mailbox { + pieceType := p.Type() + if pieceType == NoPieceType { + continue + } + count[pieceType]++ + if pieceType == Bishop { + switch Square(sq).color() { + case White: + whiteCount++ + case Black: + blackCount++ + } + } } // king versus king if count[Bishop] == 0 && count[Knight] == 0 { @@ -537,18 +585,6 @@ func (b *Board) hasSufficientMaterial() bool { } // king and bishop(s) versus king and bishop(s) with the bishops on the same colour. if count[Knight] == 0 { - whiteCount := 0 - blackCount := 0 - for sq, p := range pieceMap { - if p.Type() == Bishop { - switch sq.color() { - case White: - whiteCount++ - case Black: - blackCount++ - } - } - } if whiteCount == 0 || blackCount == 0 { return false } @@ -613,7 +649,7 @@ func (b *Board) setBBForPiece(p Piece, bb bitboard) error { case BlackPawn: b.bbBlackPawn = bb default: - return fmt.Errorf("invalid piece %s", p) + return fmt.Errorf("chess: invalid piece %s", p) } return nil } diff --git a/board_invariants_test.go b/board_invariants_test.go new file mode 100644 index 00000000..3dee0efc --- /dev/null +++ b/board_invariants_test.go @@ -0,0 +1,180 @@ +package chess + +import ( + "fmt" + "testing" +) + +func TestMailboxConsistency(t *testing.T) { + t.Parallel() + + playAndVerify := func(t *testing.T, g *Game, moves []string) { + t.Helper() + for _, moveStr := range moves { + move, err := uciNotation{}.Decode(g.Position(), moveStr) + if err != nil { + t.Fatalf("failed to parse move %s: %v", moveStr, err) + } + if _, err := g.Move(move, nil); err != nil { + t.Fatalf("failed to play move %s: %v", moveStr, err) + } + if err := verifyMailboxConsistency(g.Position().Board()); err != nil { + t.Fatalf("mailbox inconsistent after %s: %v", moveStr, err) + } + } + } + + t.Run("starting_position", func(t *testing.T) { + t.Parallel() + g := NewGame() + if err := verifyMailboxConsistency(g.Position().Board()); err != nil { + t.Fatal(err) + } + }) + + t.Run("after_quiet_moves", func(t *testing.T) { + t.Parallel() + playAndVerify(t, NewGame(), []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6", "d2d3", "d7d5"}) + }) + + t.Run("castling", func(t *testing.T) { + t.Parallel() + playAndVerify(t, NewGame(), []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6", "e1g1", "f8c5", "d2d3", "e8g8"}) + }) + + t.Run("en_passant", func(t *testing.T) { + t.Parallel() + playAndVerify(t, NewGame(), []string{"e2e4", "a7a6", "e4e5", "d7d5", "e5d6"}) + }) + + t.Run("promotion", func(t *testing.T) { + t.Parallel() + opt, err := FEN("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") + if err != nil { + t.Fatal(err) + } + playAndVerify(t, NewGame(opt), []string{"a7a8q"}) + }) + + t.Run("perft_positions", func(t *testing.T) { + fens := []string{ + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", + "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + } + for _, fen := range fens { + t.Run(fen, func(t *testing.T) { + opt, err := FEN(fen) + if err != nil { + t.Fatal(err) + } + g := NewGame(opt) + if err := verifyMailboxConsistency(g.Position().Board()); err != nil { + t.Fatal(err) + } + }) + } + }) + + t.Run("empty_board", func(t *testing.T) { + b, err := NewBoard(map[Square]Piece{}) + if err != nil { + t.Fatal(err) + } + if err := verifyMailboxConsistency(b); err != nil { + t.Fatal(err) + } + }) + + t.Run("binary_round_trip", func(t *testing.T) { + g := NewGame() + board := g.Position().Board() + data, err := board.MarshalBinary() + if err != nil { + t.Fatal(err) + } + cp := &Board{} + if err := cp.UnmarshalBinary(data); err != nil { + t.Fatal(err) + } + if err := verifyMailboxConsistency(cp); err != nil { + t.Fatalf("unmarshaled board mailbox inconsistent: %v", err) + } + if cp.String() != board.String() { + t.Errorf("binary round-trip changed board: got %q, want %q", cp.String(), board.String()) + } + }) + + t.Run("transformations", func(t *testing.T) { + board := NewGame().Position().Board() + + flipped, err := board.Flip(UpDown) + if err != nil { + t.Fatal(err) + } + if err := verifyMailboxConsistency(flipped); err != nil { + t.Fatalf("flipped board mailbox inconsistent: %v", err) + } + + transposed, err := board.Transpose() + if err != nil { + t.Fatal(err) + } + if err := verifyMailboxConsistency(transposed); err != nil { + t.Fatalf("transposed board mailbox inconsistent: %v", err) + } + + rotated, err := board.Rotate() + if err != nil { + t.Fatal(err) + } + if err := verifyMailboxConsistency(rotated); err != nil { + t.Fatalf("rotated board mailbox inconsistent: %v", err) + } + }) +} + +func verifyMailboxConsistency(b *Board) error { + var whiteSqs bitboard + var blackSqs bitboard + var occupied bitboard + for sq := range numOfSquaresInBoard { + square := Square(sq) + mailboxPiece := b.Piece(square) + bitboardPiece := NoPiece + + for _, p := range allPieces { + if b.bbForPiece(p).Occupied(square) { + bitboardPiece = p + break + } + } + + if mailboxPiece != bitboardPiece { + return fmt.Errorf("mailbox/bitboard mismatch at %s: mailbox=%s, bitboard=%s", + square.String(), mailboxPiece.String(), bitboardPiece.String()) + } + if bitboardPiece == NoPiece { + continue + } + sqBB := bbForSquare(square) + occupied |= sqBB + if bitboardPiece.Color() == White { + whiteSqs |= sqBB + } else { + blackSqs |= sqBB + } + } + if b.whiteSqs != whiteSqs { + return fmt.Errorf("white aggregate occupancy mismatch: got %s, want %s", b.whiteSqs, whiteSqs) + } + if b.blackSqs != blackSqs { + return fmt.Errorf("black aggregate occupancy mismatch: got %s, want %s", b.blackSqs, blackSqs) + } + if b.emptySqs != ^occupied { + return fmt.Errorf("empty aggregate occupancy mismatch: got %s, want %s", b.emptySqs, ^occupied) + } + return nil +} diff --git a/board_test.go b/board_test.go index 410c2819..76707893 100644 --- a/board_test.go +++ b/board_test.go @@ -1,203 +1,304 @@ -package chess +package chess_test import ( + "bytes" "fmt" + "strings" "testing" + + "github.com/corentings/chess/v3" ) -func TestBoardTextSerialization(t *testing.T) { - fen := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - b := &Board{} +func boardFromFEN(t *testing.T, fen string) *chess.Board { + t.Helper() + b := &chess.Board{} if err := b.UnmarshalText([]byte(fen)); err != nil { - t.Fatal("recieved unexpected error", err) + t.Fatal(err) } - txt, err := b.MarshalText() - if err != nil { - t.Fatal("recieved unexpected error", err) + return b +} + +var boardFENs = []struct { + name string + fen string +}{ + {"startpos", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"}, + {"empty", "8/8/8/8/8/8/8/8"}, + {"mid", "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R"}, + {"kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R"}, +} + +func TestBoard_TextRoundTrip(t *testing.T) { + for _, tt := range boardFENs { + t.Run(tt.name, func(t *testing.T) { + b := boardFromFEN(t, tt.fen) + txt, err := b.MarshalText() + if err != nil { + t.Fatal(err) + } + if string(txt) != tt.fen { + t.Errorf("MarshalText = %q, want %q", string(txt), tt.fen) + } + }) } - if fen != string(txt) { - t.Fatalf("fen expected board string %s but got %s", fen, string(txt)) +} + +func TestBoard_BinaryRoundTrip(t *testing.T) { + for _, tt := range boardFENs { + t.Run(tt.name, func(t *testing.T) { + b := boardFromFEN(t, tt.fen) + data, err := b.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if len(data) != 96 { + t.Fatalf("MarshalBinary len = %d, want 96 (12 bitboards * 8 bytes)", len(data)) + } + cp := &chess.Board{} + if err := cp.UnmarshalBinary(data); err != nil { + t.Fatal(err) + } + if cp.String() != tt.fen { + t.Errorf("binary round-trip = %q, want %q", cp.String(), tt.fen) + } + redata, err := cp.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, redata) { + t.Errorf("MarshalBinary not byte-order stable: %v != %v", data, redata) + } + }) } } -func TestBoardBinarySerialization(t *testing.T) { - g := NewGame() - board := g.Position().Board() - b, err := board.MarshalBinary() - if err != nil { - t.Fatal("recieved unexpected error", err) +func TestBoard_SquareMapRoundTrip(t *testing.T) { + for _, tt := range boardFENs { + t.Run(tt.name, func(t *testing.T) { + b := boardFromFEN(t, tt.fen) + rebuilt, err := chess.NewBoard(b.SquareMap()) + if err != nil { + t.Fatal(err) + } + if rebuilt.String() != b.String() { + t.Errorf("NewBoard(SquareMap()) = %q, want %q", rebuilt.String(), b.String()) + } + }) } - cpBoard := &Board{} - err = cpBoard.UnmarshalBinary(b) - if err != nil { - t.Fatal("recieved unexpected error", err) +} + +func TestBoard_PieceAtKnownSquares(t *testing.T) { + b := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + tests := []struct { + sq chess.Square + want chess.Piece + }{ + {chess.A1, chess.WhiteRook}, + {chess.E1, chess.WhiteKing}, + {chess.D1, chess.WhiteQueen}, + {chess.A2, chess.WhitePawn}, + {chess.E8, chess.BlackKing}, + {chess.D8, chess.BlackQueen}, + {chess.H8, chess.BlackRook}, + {chess.A7, chess.BlackPawn}, + {chess.E4, chess.NoPiece}, + {chess.D5, chess.NoPiece}, } - s := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - if s != cpBoard.String() { - t.Fatalf("expected board string %s but got %s", s, cpBoard.String()) + for _, tt := range tests { + t.Run(tt.sq.String(), func(t *testing.T) { + if got := b.Piece(tt.sq); got != tt.want { + t.Errorf("Piece(%s) = %v, want %v", tt.sq, got, tt.want) + } + }) } } -func TestBoardRotation(t *testing.T) { - fens := []string{ +func TestBoard_RotateNinetyDegreesClockwise(t *testing.T) { + want := []string{ "RP4pr/NP4pn/BP4pb/QP4pq/KP4pk/BP4pb/NP4pn/RP4pr", "RNBKQBNR/PPPPPPPP/8/8/8/8/pppppppp/rnbkqbnr", "rp4PR/np4PN/bp4PB/kp4PK/qp4PQ/bp4PB/np4PN/rp4PR", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", } - g := NewGame() - board := g.Position().Board() - for i := range fens { - var err error - board, err = board.Rotate() - if err != nil { - t.Fatalf("Rotate() error = %v", err) - } - if fens[i] != board.String() { - t.Fatalf("expected board string %s but got %s", fens[i], board.String()) - } + start := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + board := start + for i, w := range want { + t.Run(rotatedLabel(i+1), func(t *testing.T) { + var err error + board, err = board.Rotate() + if err != nil { + t.Fatalf("Rotate() error = %v", err) + } + if board.String() != w { + t.Errorf("Rotate() = %q, want %q", board.String(), w) + } + }) } -} - -func TestBoardFlip(t *testing.T) { - g := NewGame() - board := g.Position().Board() - var err error - board, err = board.Flip(UpDown) + rotated, err := start.Rotate() if err != nil { - t.Fatalf("Flip(UpDown) error = %v", err) + t.Fatal(err) } - b := "RNBQKBNR/PPPPPPPP/8/8/8/8/pppppppp/rnbqkbnr" - if b != board.String() { - t.Fatalf("expected board string %s but got %s", b, board.String()) - } - board, err = board.Flip(UpDown) + flipped, err := start.Flip(chess.UpDown) if err != nil { - t.Fatalf("Flip(UpDown) error = %v", err) - } - b = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - if b != board.String() { - t.Fatalf("expected board string %s but got %s", b, board.String()) + t.Fatal(err) } - board, err = board.Flip(LeftRight) + transposed, err := flipped.Transpose() if err != nil { - t.Fatalf("Flip(LeftRight) error = %v", err) + t.Fatal(err) } - b = "rnbkqbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBKQBNR" - if b != board.String() { - t.Fatalf("expected board string %s but got %s", b, board.String()) - } - board, err = board.Flip(LeftRight) - if err != nil { - t.Fatalf("Flip(LeftRight) error = %v", err) - } - b = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - if b != board.String() { - t.Fatalf("expected board string %s but got %s", b, board.String()) + if rotated.String() != transposed.String() { + t.Errorf("Rotate() = %q, want Flip(UpDown).Transpose() = %q", rotated.String(), transposed.String()) } } -func TestBoardTranspose(t *testing.T) { - g := NewGame() - board := g.Position().Board() - var err error - board, err = board.Transpose() - if err != nil { - t.Fatalf("Transpose() error = %v", err) - } - b := "rp4PR/np4PN/bp4PB/qp4PQ/kp4PK/bp4PB/np4PN/rp4PR" - if b != board.String() { - t.Fatalf("expected board string %s but got %s", b, board.String()) +func rotatedLabel(n int) string { + switch n { + case 1: + return "rotated_90cw" + case 2: + return "rotated_180" + case 3: + return "rotated_270cw" + default: + return "rotated_360_identity" } } -// TestMailboxConsistency verifies that the mailbox array stays in sync with bitboards -// across various board operations including moves, flips, transposes, and rotations. -func TestMailboxConsistency(t *testing.T) { - g := NewGame() - pos := g.Position() - - // Test 1: Starting position - if err := verifyMailboxConsistency(pos.Board()); err != nil { - t.Fatalf("starting position mailbox inconsistent: %v", err) - } - - // Test 2: After several moves - moves := []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6", "d2d3", "d7d5"} - for _, moveStr := range moves { - move, err := UCINotation{}.Decode(g.Position(), moveStr) +func TestBoard_FlipIsInvolution(t *testing.T) { + t.Run("UpDown_is_involution", func(t *testing.T) { + start := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + once, err := start.Flip(chess.UpDown) if err != nil { - t.Fatalf("failed to parse move %s: %v", moveStr, err) + t.Fatal(err) } - if err := g.Move(move, nil); err != nil { - t.Fatalf("failed to play move %s: %v", moveStr, err) + if want := "RNBQKBNR/PPPPPPPP/8/8/8/8/pppppppp/rnbqkbnr"; once.String() != want { + t.Errorf("Flip(UpDown) = %q, want %q", once.String(), want) } - } - if err := verifyMailboxConsistency(g.Position().Board()); err != nil { - t.Fatalf("after moves mailbox inconsistent: %v", err) - } - - // Test 3: Board transformations - board := g.Position().Board() + twice, err := once.Flip(chess.UpDown) + if err != nil { + t.Fatal(err) + } + if twice.String() != start.String() { + t.Errorf("Flip(UpDown).Flip(UpDown) = %q, want identity %q", twice.String(), start.String()) + } + }) + t.Run("LeftRight_is_involution", func(t *testing.T) { + start := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + once, err := start.Flip(chess.LeftRight) + if err != nil { + t.Fatal(err) + } + if want := "rnbkqbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBKQBNR"; once.String() != want { + t.Errorf("Flip(LeftRight) = %q, want %q", once.String(), want) + } + twice, err := once.Flip(chess.LeftRight) + if err != nil { + t.Fatal(err) + } + if twice.String() != start.String() { + t.Errorf("Flip(LeftRight).Flip(LeftRight) = %q, want identity %q", twice.String(), start.String()) + } + }) +} - flipped, err := board.Flip(UpDown) +func TestBoard_TransposeIsInvolution(t *testing.T) { + start := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + once, err := start.Transpose() if err != nil { - t.Fatalf("Flip error: %v", err) + t.Fatal(err) } - if err := verifyMailboxConsistency(flipped); err != nil { - t.Fatalf("flipped board mailbox inconsistent: %v", err) + if want := "rp4PR/np4PN/bp4PB/qp4PQ/kp4PK/bp4PB/np4PN/rp4PR"; once.String() != want { + t.Errorf("Transpose() = %q, want %q", once.String(), want) } - - transposed, err := board.Transpose() + twice, err := once.Transpose() if err != nil { - t.Fatalf("Transpose error: %v", err) + t.Fatal(err) } - if err := verifyMailboxConsistency(transposed); err != nil { - t.Fatalf("transposed board mailbox inconsistent: %v", err) + if twice.String() != start.String() { + t.Errorf("Transpose().Transpose() = %q, want identity %q", twice.String(), start.String()) } +} - rotated, err := board.Rotate() - if err != nil { - t.Fatalf("Rotate error: %v", err) +func TestBoard_DrawReturnsASCIIArt(t *testing.T) { + b := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + s := b.Draw() + if !strings.Contains(s, " A B C D E F G H") { + t.Errorf("Draw() missing file header, got:\n%s", s) + } + if !strings.Contains(s, "8") || !strings.Contains(s, "1") { + t.Errorf("Draw() missing rank markers, got:\n%s", s) } - if err := verifyMailboxConsistency(rotated); err != nil { - t.Fatalf("rotated board mailbox inconsistent: %v", err) + if !strings.Contains(s, "♚") { + t.Errorf("Draw() missing black king glyph, got:\n%s", s) + } + if !strings.Contains(s, "-") { + t.Errorf("Draw() missing empty-square marker, got:\n%s", s) } } -// verifyMailboxConsistency checks that every square in the mailbox matches the -// piece found by scanning bitboards. -func verifyMailboxConsistency(b *Board) error { - for sq := range numOfSquaresInBoard { - square := Square(sq) - mailboxPiece := b.Piece(square) - bitboardPiece := NoPiece +func TestBoard_Draw2AllPerspectives(t *testing.T) { + b := boardFromFEN(t, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") + for _, tc := range []struct { + name string + persp chess.Color + darkMode bool + }{ + {"white_light", chess.White, false}, + {"white_dark", chess.White, true}, + {"black_light", chess.Black, false}, + {"black_dark", chess.Black, true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := b.Draw2(tc.persp, tc.darkMode) + if !strings.Contains(s, "♔") { + t.Errorf("Draw2(%v,%v) missing white king glyph, got:\n%s", tc.persp, tc.darkMode, s) + } + if !strings.Contains(s, "-") { + t.Errorf("Draw2(%v,%v) missing empty-square marker, got:\n%s", tc.persp, tc.darkMode, s) + } + }) + } +} - // Find which piece (if any) occupies this square in bitboards - for _, p := range allPieces { - if b.bbForPiece(p).Occupied(square) { - bitboardPiece = p - break +func TestBoard_UnmarshalTextErrors(t *testing.T) { + tests := []struct { + name string + fen string + }{ + {"missing_rank", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP"}, + {"bad_char", "rnbqkbnZ/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"}, + {"empty", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &chess.Board{} + if err := b.UnmarshalText([]byte(tt.fen)); err == nil { + t.Errorf("UnmarshalText(%q) = nil, want error", tt.fen) } - } + }) + } +} - if mailboxPiece != bitboardPiece { - return fmt.Errorf("mailbox/bitboard mismatch at %s: mailbox=%s, bitboard=%s", - square.String(), mailboxPiece.String(), bitboardPiece.String()) - } +func TestBoard_UnmarshalBinaryErrors(t *testing.T) { + for _, n := range []int{0, 95, 97} { + t.Run(fmt.Sprintf("len_%d", n), func(t *testing.T) { + b := &chess.Board{} + if err := b.UnmarshalBinary(make([]byte, n)); err == nil { + t.Errorf("UnmarshalBinary(len=%d) = nil, want error", n) + } + }) } - return nil } func BenchmarkPieceMailbox(b *testing.B) { - g := NewGame() + g := chess.NewGame() board := g.Position().Board() b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = board.Piece(A1) - _ = board.Piece(E4) - _ = board.Piece(H8) + for range b.N { + _ = board.Piece(chess.A1) + _ = board.Piece(chess.E4) + _ = board.Piece(chess.H8) } } diff --git a/castling.go b/castling.go new file mode 100644 index 00000000..0bc6a657 --- /dev/null +++ b/castling.go @@ -0,0 +1,66 @@ +package chess + +// castleMoves returns all legal castling moves for the current position. +// +// A castling move is legal if: +// - The king has castling rights in that direction +// - The squares between king and rook are empty +// - The king is not in check +// - The king does not pass through check +func hasCastleMove(pos *Position) bool { + var castles [2]Move + return castleMovesInto(pos, &castles, generateLegalOnly) > 0 +} + +func castleMovesInto(pos *Position, moves *[2]Move, mode moveGenerationMode) int { + count := 0 + + kingSide := pos.castleRights.CanCastle(pos.Turn(), KingSide) + queenSide := pos.castleRights.CanCastle(pos.Turn(), QueenSide) + + // white king side + if pos.turn == White && kingSide && + (^pos.board.emptySqs&(bbForSquare(F1)|bbForSquare(G1))) == 0 && + !squaresAreAttacked(pos, F1, G1) && + !pos.inCheck { + m := Move{s1: E1, s2: G1} + m.tags = moveTagsForMode(m, pos, mode) + moves[count] = m + count++ + } + + // white queen side + if pos.turn == White && queenSide && + (^pos.board.emptySqs&(bbForSquare(B1)|bbForSquare(C1)|bbForSquare(D1))) == 0 && + !squaresAreAttacked(pos, C1, D1) && + !pos.inCheck { + m := Move{s1: E1, s2: C1} + m.tags = moveTagsForMode(m, pos, mode) + moves[count] = m + count++ + } + + // black king side + if pos.turn == Black && kingSide && + (^pos.board.emptySqs&(bbForSquare(F8)|bbForSquare(G8))) == 0 && + !squaresAreAttacked(pos, F8, G8) && + !pos.inCheck { + m := Move{s1: E8, s2: G8} + m.tags = moveTagsForMode(m, pos, mode) + moves[count] = m + count++ + } + + // black queen side + if pos.turn == Black && queenSide && + (^pos.board.emptySqs&(bbForSquare(B8)|bbForSquare(C8)|bbForSquare(D8))) == 0 && + !squaresAreAttacked(pos, C8, D8) && + !pos.inCheck { + m := Move{s1: E8, s2: C8} + m.tags = moveTagsForMode(m, pos, mode) + moves[count] = m + count++ + } + + return count +} diff --git a/coverage.out b/coverage.out deleted file mode 100644 index c97d24ba..00000000 --- a/coverage.out +++ /dev/null @@ -1,2186 +0,0 @@ -mode: set -github.com/corentings/chess/v2/uci/adapter.go:22.68,24.16 2 0 -github.com/corentings/chess/v2/uci/adapter.go:24.16,26.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:27.2,32.36 6 0 -github.com/corentings/chess/v2/uci/adapter.go:32.36,34.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:35.2,41.8 2 0 -github.com/corentings/chess/v2/uci/adapter.go:44.65,45.64 1 0 -github.com/corentings/chess/v2/uci/adapter.go:45.64,47.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:48.2,48.20 1 0 -github.com/corentings/chess/v2/uci/adapter.go:48.20,50.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:51.2,52.23 2 0 -github.com/corentings/chess/v2/uci/adapter.go:52.23,55.23 3 0 -github.com/corentings/chess/v2/uci/adapter.go:55.23,56.9 1 0 -github.com/corentings/chess/v2/uci/adapter.go:59.2,59.40 1 0 -github.com/corentings/chess/v2/uci/adapter.go:59.40,61.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:62.2,62.19 1 0 -github.com/corentings/chess/v2/uci/adapter.go:65.43,66.41 1 0 -github.com/corentings/chess/v2/uci/adapter.go:66.41,68.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:69.2,69.41 1 0 -github.com/corentings/chess/v2/uci/adapter.go:69.41,71.3 1 0 -github.com/corentings/chess/v2/uci/adapter.go:72.2,72.29 1 0 -github.com/corentings/chess/v2/uci/adapter.go:75.39,77.2 1 0 -github.com/corentings/chess/v2/uci/cmd.go:23.31,23.47 1 1 -github.com/corentings/chess/v2/uci/cmd.go:25.40,25.66 1 1 -github.com/corentings/chess/v2/uci/cmd.go:27.35,27.50 1 1 -github.com/corentings/chess/v2/uci/cmd.go:29.55,32.29 3 1 -github.com/corentings/chess/v2/uci/cmd.go:32.29,34.17 2 1 -github.com/corentings/chess/v2/uci/cmd.go:34.17,36.12 2 1 -github.com/corentings/chess/v2/uci/cmd.go:38.3,39.54 2 1 -github.com/corentings/chess/v2/uci/cmd.go:39.54,41.4 1 1 -github.com/corentings/chess/v2/uci/cmd.go:43.2,43.12 1 1 -github.com/corentings/chess/v2/uci/cmd.go:48.35,48.55 1 1 -github.com/corentings/chess/v2/uci/cmd.go:50.44,50.72 1 1 -github.com/corentings/chess/v2/uci/cmd.go:52.39,52.54 1 1 -github.com/corentings/chess/v2/uci/cmd.go:54.55,54.69 1 1 -github.com/corentings/chess/v2/uci/cmd.go:58.38,58.61 1 1 -github.com/corentings/chess/v2/uci/cmd.go:60.44,60.59 1 0 -github.com/corentings/chess/v2/uci/cmd.go:62.42,62.57 1 1 -github.com/corentings/chess/v2/uci/cmd.go:64.58,64.72 1 1 -github.com/corentings/chess/v2/uci/cmd.go:68.37,68.59 1 1 -github.com/corentings/chess/v2/uci/cmd.go:70.43,70.58 1 0 -github.com/corentings/chess/v2/uci/cmd.go:72.41,72.57 1 1 -github.com/corentings/chess/v2/uci/cmd.go:74.57,74.71 1 1 -github.com/corentings/chess/v2/uci/cmd.go:78.32,78.49 1 1 -github.com/corentings/chess/v2/uci/cmd.go:80.38,80.53 1 0 -github.com/corentings/chess/v2/uci/cmd.go:82.36,82.52 1 1 -github.com/corentings/chess/v2/uci/cmd.go:84.52,84.66 1 1 -github.com/corentings/chess/v2/uci/cmd.go:88.32,88.49 1 1 -github.com/corentings/chess/v2/uci/cmd.go:90.38,90.53 1 0 -github.com/corentings/chess/v2/uci/cmd.go:92.36,92.51 1 1 -github.com/corentings/chess/v2/uci/cmd.go:94.52,94.66 1 1 -github.com/corentings/chess/v2/uci/cmd.go:98.32,98.49 1 1 -github.com/corentings/chess/v2/uci/cmd.go:100.41,105.2 2 1 -github.com/corentings/chess/v2/uci/cmd.go:107.36,107.51 1 1 -github.com/corentings/chess/v2/uci/cmd.go:109.56,110.29 1 1 -github.com/corentings/chess/v2/uci/cmd.go:110.29,112.85 2 1 -github.com/corentings/chess/v2/uci/cmd.go:112.85,114.4 1 0 -github.com/corentings/chess/v2/uci/cmd.go:115.3,115.50 1 1 -github.com/corentings/chess/v2/uci/cmd.go:115.50,117.23 2 1 -github.com/corentings/chess/v2/uci/cmd.go:117.23,120.19 3 1 -github.com/corentings/chess/v2/uci/cmd.go:120.19,122.6 1 1 -github.com/corentings/chess/v2/uci/cmd.go:123.5,123.10 1 1 -github.com/corentings/chess/v2/uci/cmd.go:127.2,127.12 1 1 -github.com/corentings/chess/v2/uci/cmd.go:135.41,137.2 1 0 -github.com/corentings/chess/v2/uci/cmd.go:139.43,139.58 1 0 -github.com/corentings/chess/v2/uci/cmd.go:141.41,141.56 1 0 -github.com/corentings/chess/v2/uci/cmd.go:143.57,143.71 1 0 -github.com/corentings/chess/v2/uci/cmd.go:150.40,151.25 1 1 -github.com/corentings/chess/v2/uci/cmd.go:151.25,153.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:154.2,154.25 1 1 -github.com/corentings/chess/v2/uci/cmd.go:154.25,156.3 1 1 -github.com/corentings/chess/v2/uci/cmd.go:157.2,158.30 2 0 -github.com/corentings/chess/v2/uci/cmd.go:158.30,161.3 2 0 -github.com/corentings/chess/v2/uci/cmd.go:162.2,162.91 1 0 -github.com/corentings/chess/v2/uci/cmd.go:165.42,165.57 1 0 -github.com/corentings/chess/v2/uci/cmd.go:167.40,167.55 1 1 -github.com/corentings/chess/v2/uci/cmd.go:169.56,169.70 1 1 -github.com/corentings/chess/v2/uci/cmd.go:186.34,188.16 2 1 -github.com/corentings/chess/v2/uci/cmd.go:188.16,190.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:191.2,191.23 1 1 -github.com/corentings/chess/v2/uci/cmd.go:191.23,193.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:194.2,194.23 1 1 -github.com/corentings/chess/v2/uci/cmd.go:194.23,196.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:197.2,197.28 1 1 -github.com/corentings/chess/v2/uci/cmd.go:197.28,199.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:200.2,200.28 1 1 -github.com/corentings/chess/v2/uci/cmd.go:200.28,202.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:203.2,203.23 1 1 -github.com/corentings/chess/v2/uci/cmd.go:203.23,205.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:206.2,206.19 1 1 -github.com/corentings/chess/v2/uci/cmd.go:206.19,208.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:209.2,209.19 1 1 -github.com/corentings/chess/v2/uci/cmd.go:209.19,211.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:212.2,212.18 1 1 -github.com/corentings/chess/v2/uci/cmd.go:212.18,214.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:215.2,215.22 1 1 -github.com/corentings/chess/v2/uci/cmd.go:215.22,217.3 1 1 -github.com/corentings/chess/v2/uci/cmd.go:218.2,218.18 1 1 -github.com/corentings/chess/v2/uci/cmd.go:218.18,220.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:221.2,221.30 1 1 -github.com/corentings/chess/v2/uci/cmd.go:221.30,223.37 2 0 -github.com/corentings/chess/v2/uci/cmd.go:223.37,226.4 2 0 -github.com/corentings/chess/v2/uci/cmd.go:228.2,228.29 1 1 -github.com/corentings/chess/v2/uci/cmd.go:231.39,233.2 1 1 -github.com/corentings/chess/v2/uci/cmd.go:235.34,235.49 1 1 -github.com/corentings/chess/v2/uci/cmd.go:237.54,241.29 3 1 -github.com/corentings/chess/v2/uci/cmd.go:241.29,242.42 1 1 -github.com/corentings/chess/v2/uci/cmd.go:242.42,244.23 2 1 -github.com/corentings/chess/v2/uci/cmd.go:244.23,246.5 1 0 -github.com/corentings/chess/v2/uci/cmd.go:247.4,248.25 2 1 -github.com/corentings/chess/v2/uci/cmd.go:248.25,250.5 1 1 -github.com/corentings/chess/v2/uci/cmd.go:251.4,252.18 2 1 -github.com/corentings/chess/v2/uci/cmd.go:252.18,254.5 1 0 -github.com/corentings/chess/v2/uci/cmd.go:255.4,256.30 2 1 -github.com/corentings/chess/v2/uci/cmd.go:256.30,258.25 2 0 -github.com/corentings/chess/v2/uci/cmd.go:258.25,260.6 1 0 -github.com/corentings/chess/v2/uci/cmd.go:261.5,261.32 1 0 -github.com/corentings/chess/v2/uci/cmd.go:263.4,263.12 1 1 -github.com/corentings/chess/v2/uci/cmd.go:266.3,268.17 3 1 -github.com/corentings/chess/v2/uci/cmd.go:268.17,269.12 1 0 -github.com/corentings/chess/v2/uci/cmd.go:272.3,272.45 1 1 -github.com/corentings/chess/v2/uci/cmd.go:272.45,274.4 1 1 -github.com/corentings/chess/v2/uci/cmd.go:276.3,276.45 1 1 -github.com/corentings/chess/v2/uci/cmd.go:276.45,278.37 2 1 -github.com/corentings/chess/v2/uci/cmd.go:278.37,279.52 1 1 -github.com/corentings/chess/v2/uci/cmd.go:279.52,281.6 1 1 -github.com/corentings/chess/v2/uci/cmd.go:283.4,283.47 1 1 -github.com/corentings/chess/v2/uci/cmd.go:286.2,288.12 3 1 -github.com/corentings/chess/v2/uci/cmd.go:291.52,293.33 2 1 -github.com/corentings/chess/v2/uci/cmd.go:293.33,295.3 1 1 -github.com/corentings/chess/v2/uci/cmd.go:296.2,298.27 2 1 -github.com/corentings/chess/v2/uci/cmd.go:298.27,300.3 1 0 -github.com/corentings/chess/v2/uci/cmd.go:301.2,301.52 1 1 -github.com/corentings/chess/v2/uci/cmd.go:304.40,306.2 1 1 -github.com/corentings/chess/v2/uci/engine.go:21.23,23.2 1 0 -github.com/corentings/chess/v2/uci/engine.go:25.49,26.25 1 0 -github.com/corentings/chess/v2/uci/engine.go:26.25,28.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:31.65,33.16 2 0 -github.com/corentings/chess/v2/uci/engine.go:33.16,35.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:36.2,36.46 1 0 -github.com/corentings/chess/v2/uci/engine.go:39.71,47.27 2 1 -github.com/corentings/chess/v2/uci/engine.go:47.27,49.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:50.2,50.10 1 1 -github.com/corentings/chess/v2/uci/engine.go:53.41,58.25 4 1 -github.com/corentings/chess/v2/uci/engine.go:58.25,60.3 1 1 -github.com/corentings/chess/v2/uci/engine.go:61.2,61.11 1 1 -github.com/corentings/chess/v2/uci/engine.go:64.46,69.30 4 1 -github.com/corentings/chess/v2/uci/engine.go:69.30,71.3 1 1 -github.com/corentings/chess/v2/uci/engine.go:72.2,72.11 1 1 -github.com/corentings/chess/v2/uci/engine.go:75.48,79.2 3 1 -github.com/corentings/chess/v2/uci/engine.go:81.29,85.2 3 1 -github.com/corentings/chess/v2/uci/engine.go:87.41,88.27 1 1 -github.com/corentings/chess/v2/uci/engine.go:88.27,89.26 1 1 -github.com/corentings/chess/v2/uci/engine.go:89.26,90.48 1 1 -github.com/corentings/chess/v2/uci/engine.go:90.48,92.5 1 0 -github.com/corentings/chess/v2/uci/engine.go:93.9,94.54 1 1 -github.com/corentings/chess/v2/uci/engine.go:94.54,96.5 1 0 -github.com/corentings/chess/v2/uci/engine.go:99.2,99.12 1 1 -github.com/corentings/chess/v2/uci/engine.go:102.32,105.20 3 1 -github.com/corentings/chess/v2/uci/engine.go:105.20,107.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:108.2,108.17 1 1 -github.com/corentings/chess/v2/uci/engine.go:111.54,115.2 3 1 -github.com/corentings/chess/v2/uci/engine.go:117.48,118.13 1 1 -github.com/corentings/chess/v2/uci/engine.go:118.13,120.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:121.2,122.16 2 1 -github.com/corentings/chess/v2/uci/engine.go:122.16,124.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:125.2,125.13 1 1 -github.com/corentings/chess/v2/uci/engine.go:125.13,126.30 1 0 -github.com/corentings/chess/v2/uci/engine.go:126.30,128.4 1 0 -github.com/corentings/chess/v2/uci/engine.go:130.2,130.42 1 1 -github.com/corentings/chess/v2/uci/engine.go:130.42,132.3 1 0 -github.com/corentings/chess/v2/uci/engine.go:133.2,133.41 1 1 -github.com/corentings/chess/v2/uci/engine.go:133.41,135.3 1 1 -github.com/corentings/chess/v2/uci/engine.go:136.2,136.29 1 1 -github.com/corentings/chess/v2/uci/fake.go:9.59,12.9 3 1 -github.com/corentings/chess/v2/uci/fake.go:12.9,14.3 1 1 -github.com/corentings/chess/v2/uci/fake.go:15.2,15.20 1 1 -github.com/corentings/chess/v2/uci/fake.go:15.20,17.3 1 0 -github.com/corentings/chess/v2/uci/fake.go:18.2,19.33 2 1 -github.com/corentings/chess/v2/uci/fake.go:19.33,21.23 2 1 -github.com/corentings/chess/v2/uci/fake.go:21.23,22.9 1 1 -github.com/corentings/chess/v2/uci/fake.go:25.2,25.19 1 1 -github.com/corentings/chess/v2/uci/fake.go:28.37,30.2 1 1 -github.com/corentings/chess/v2/uci/info.go:138.46,140.16 2 0 -github.com/corentings/chess/v2/uci/info.go:140.16,142.3 1 0 -github.com/corentings/chess/v2/uci/info.go:143.2,143.49 1 0 -github.com/corentings/chess/v2/uci/info.go:150.47,152.16 2 0 -github.com/corentings/chess/v2/uci/info.go:152.16,154.3 1 0 -github.com/corentings/chess/v2/uci/info.go:155.2,155.50 1 0 -github.com/corentings/chess/v2/uci/info.go:162.47,164.16 2 0 -github.com/corentings/chess/v2/uci/info.go:164.16,166.3 1 0 -github.com/corentings/chess/v2/uci/info.go:167.2,167.50 1 0 -github.com/corentings/chess/v2/uci/info.go:176.52,178.21 2 1 -github.com/corentings/chess/v2/uci/info.go:178.21,180.3 1 0 -github.com/corentings/chess/v2/uci/info.go:181.2,181.24 1 1 -github.com/corentings/chess/v2/uci/info.go:181.24,183.3 1 0 -github.com/corentings/chess/v2/uci/info.go:184.2,185.34 2 1 -github.com/corentings/chess/v2/uci/info.go:185.34,187.12 2 1 -github.com/corentings/chess/v2/uci/info.go:188.16,189.12 1 1 -github.com/corentings/chess/v2/uci/info.go:190.21,192.12 2 0 -github.com/corentings/chess/v2/uci/info.go:193.21,195.12 2 0 -github.com/corentings/chess/v2/uci/info.go:197.3,197.16 1 1 -github.com/corentings/chess/v2/uci/info.go:197.16,199.12 2 1 -github.com/corentings/chess/v2/uci/info.go:201.3,201.14 1 1 -github.com/corentings/chess/v2/uci/info.go:202.16,204.18 2 1 -github.com/corentings/chess/v2/uci/info.go:204.18,206.5 1 0 -github.com/corentings/chess/v2/uci/info.go:207.4,207.18 1 1 -github.com/corentings/chess/v2/uci/info.go:208.19,210.18 2 0 -github.com/corentings/chess/v2/uci/info.go:210.18,212.5 1 0 -github.com/corentings/chess/v2/uci/info.go:213.4,213.21 1 0 -github.com/corentings/chess/v2/uci/info.go:214.18,216.18 2 1 -github.com/corentings/chess/v2/uci/info.go:216.18,218.5 1 0 -github.com/corentings/chess/v2/uci/info.go:219.4,219.20 1 1 -github.com/corentings/chess/v2/uci/info.go:220.13,222.18 2 1 -github.com/corentings/chess/v2/uci/info.go:222.18,224.5 1 0 -github.com/corentings/chess/v2/uci/info.go:225.4,225.21 1 1 -github.com/corentings/chess/v2/uci/info.go:226.14,229.18 3 0 -github.com/corentings/chess/v2/uci/info.go:229.18,231.5 1 0 -github.com/corentings/chess/v2/uci/info.go:232.4,233.18 2 0 -github.com/corentings/chess/v2/uci/info.go:233.18,235.5 1 0 -github.com/corentings/chess/v2/uci/info.go:236.4,237.18 2 0 -github.com/corentings/chess/v2/uci/info.go:237.18,239.5 1 0 -github.com/corentings/chess/v2/uci/info.go:240.4,240.10 1 0 -github.com/corentings/chess/v2/uci/info.go:241.16,243.18 2 1 -github.com/corentings/chess/v2/uci/info.go:243.18,245.5 1 0 -github.com/corentings/chess/v2/uci/info.go:246.4,246.18 1 1 -github.com/corentings/chess/v2/uci/info.go:247.15,249.18 2 0 -github.com/corentings/chess/v2/uci/info.go:249.18,251.5 1 0 -github.com/corentings/chess/v2/uci/info.go:252.4,252.23 1 0 -github.com/corentings/chess/v2/uci/info.go:253.25,255.18 2 0 -github.com/corentings/chess/v2/uci/info.go:255.18,257.5 1 0 -github.com/corentings/chess/v2/uci/info.go:258.4,258.30 1 0 -github.com/corentings/chess/v2/uci/info.go:259.19,261.18 2 0 -github.com/corentings/chess/v2/uci/info.go:261.18,263.5 1 0 -github.com/corentings/chess/v2/uci/info.go:264.4,264.24 1 0 -github.com/corentings/chess/v2/uci/info.go:265.19,267.18 2 0 -github.com/corentings/chess/v2/uci/info.go:267.18,269.5 1 0 -github.com/corentings/chess/v2/uci/info.go:270.4,270.21 1 0 -github.com/corentings/chess/v2/uci/info.go:271.17,273.18 2 1 -github.com/corentings/chess/v2/uci/info.go:273.18,275.5 1 0 -github.com/corentings/chess/v2/uci/info.go:276.4,276.19 1 1 -github.com/corentings/chess/v2/uci/info.go:277.15,279.18 2 1 -github.com/corentings/chess/v2/uci/info.go:279.18,281.5 1 0 -github.com/corentings/chess/v2/uci/info.go:282.4,282.51 1 1 -github.com/corentings/chess/v2/uci/info.go:283.14,285.18 2 1 -github.com/corentings/chess/v2/uci/info.go:285.18,287.5 1 0 -github.com/corentings/chess/v2/uci/info.go:288.4,288.16 1 1 -github.com/corentings/chess/v2/uci/info.go:289.18,291.18 2 0 -github.com/corentings/chess/v2/uci/info.go:291.18,293.5 1 0 -github.com/corentings/chess/v2/uci/info.go:294.4,294.20 1 0 -github.com/corentings/chess/v2/uci/info.go:295.13,297.18 2 1 -github.com/corentings/chess/v2/uci/info.go:297.18,299.5 1 0 -github.com/corentings/chess/v2/uci/info.go:300.4,300.32 1 1 -github.com/corentings/chess/v2/uci/info.go:302.3,302.18 1 1 -github.com/corentings/chess/v2/uci/info.go:302.18,304.4 1 1 -github.com/corentings/chess/v2/uci/info.go:306.2,306.12 1 1 -github.com/corentings/chess/v2/uci/option.go:120.51,123.21 3 1 -github.com/corentings/chess/v2/uci/option.go:123.21,125.3 1 0 -github.com/corentings/chess/v2/uci/option.go:126.2,126.26 1 1 -github.com/corentings/chess/v2/uci/option.go:126.26,128.3 1 1 -github.com/corentings/chess/v2/uci/option.go:129.2,130.34 2 1 -github.com/corentings/chess/v2/uci/option.go:130.34,132.16 2 1 -github.com/corentings/chess/v2/uci/option.go:132.16,134.12 2 1 -github.com/corentings/chess/v2/uci/option.go:136.3,136.14 1 1 -github.com/corentings/chess/v2/uci/option.go:137.15,138.14 1 1 -github.com/corentings/chess/v2/uci/option.go:139.15,141.18 2 1 -github.com/corentings/chess/v2/uci/option.go:141.18,143.5 1 0 -github.com/corentings/chess/v2/uci/option.go:144.4,144.15 1 1 -github.com/corentings/chess/v2/uci/option.go:145.18,146.17 1 1 -github.com/corentings/chess/v2/uci/option.go:147.14,148.13 1 1 -github.com/corentings/chess/v2/uci/option.go:149.14,150.13 1 1 -github.com/corentings/chess/v2/uci/option.go:151.14,152.30 1 0 -github.com/corentings/chess/v2/uci/option.go:154.3,154.11 1 1 -github.com/corentings/chess/v2/uci/option.go:156.2,156.44 1 1 -github.com/corentings/chess/v2/uci/option.go:156.44,158.3 1 0 -github.com/corentings/chess/v2/uci/option.go:159.2,159.12 1 1 -github.com/corentings/chess/v2/uci/option.go:194.57,195.100 1 1 -github.com/corentings/chess/v2/uci/option.go:195.100,196.22 1 1 -github.com/corentings/chess/v2/uci/option.go:196.22,198.4 1 1 -github.com/corentings/chess/v2/uci/option.go:200.2,200.66 1 0 -github.com/corentings/chess/v2/image/image.go:18.69,21.2 2 1 -github.com/corentings/chess/v2/image/image.go:26.59,27.26 1 0 -github.com/corentings/chess/v2/image/image.go:27.26,30.3 2 0 -github.com/corentings/chess/v2/image/image.go:37.69,38.26 1 1 -github.com/corentings/chess/v2/image/image.go:38.26,39.26 1 1 -github.com/corentings/chess/v2/image/image.go:39.26,41.4 1 1 -github.com/corentings/chess/v2/image/image.go:48.48,49.26 1 1 -github.com/corentings/chess/v2/image/image.go:49.26,51.3 1 1 -github.com/corentings/chess/v2/image/image.go:66.65,74.29 2 1 -github.com/corentings/chess/v2/image/image.go:74.29,76.3 1 1 -github.com/corentings/chess/v2/image/image.go:77.2,77.10 1 1 -github.com/corentings/chess/v2/image/image.go:97.51,106.34 8 1 -github.com/corentings/chess/v2/image/image.go:106.34,109.3 2 1 -github.com/corentings/chess/v2/image/image.go:110.2,110.29 1 1 -github.com/corentings/chess/v2/image/image.go:110.29,111.30 1 1 -github.com/corentings/chess/v2/image/image.go:111.30,118.10 7 1 -github.com/corentings/chess/v2/image/image.go:118.10,120.5 1 1 -github.com/corentings/chess/v2/image/image.go:122.4,123.26 2 1 -github.com/corentings/chess/v2/image/image.go:123.26,125.65 2 1 -github.com/corentings/chess/v2/image/image.go:125.65,127.6 1 0 -github.com/corentings/chess/v2/image/image.go:130.4,131.14 2 1 -github.com/corentings/chess/v2/image/image.go:131.14,134.5 2 1 -github.com/corentings/chess/v2/image/image.go:136.4,136.21 1 1 -github.com/corentings/chess/v2/image/image.go:136.21,139.5 2 1 -github.com/corentings/chess/v2/image/image.go:142.2,143.12 2 1 -github.com/corentings/chess/v2/image/image.go:146.63,148.18 2 1 -github.com/corentings/chess/v2/image/image.go:148.18,150.3 1 1 -github.com/corentings/chess/v2/image/image.go:151.2,151.16 1 1 -github.com/corentings/chess/v2/image/image.go:154.61,156.18 2 1 -github.com/corentings/chess/v2/image/image.go:156.18,158.3 1 1 -github.com/corentings/chess/v2/image/image.go:159.2,159.15 1 1 -github.com/corentings/chess/v2/image/image.go:162.39,165.2 2 1 -github.com/corentings/chess/v2/image/image.go:167.47,173.2 5 1 -github.com/corentings/chess/v2/opening/eco.go:24.28,26.123 2 1 -github.com/corentings/chess/v2/opening/eco.go:26.123,27.13 1 0 -github.com/corentings/chess/v2/opening/eco.go:29.2,40.16 6 1 -github.com/corentings/chess/v2/opening/eco.go:40.16,42.3 1 0 -github.com/corentings/chess/v2/opening/eco.go:43.2,43.30 1 1 -github.com/corentings/chess/v2/opening/eco.go:43.30,44.13 1 1 -github.com/corentings/chess/v2/opening/eco.go:44.13,45.12 1 1 -github.com/corentings/chess/v2/opening/eco.go:47.3,48.18 2 1 -github.com/corentings/chess/v2/opening/eco.go:50.2,50.10 1 1 -github.com/corentings/chess/v2/opening/eco.go:54.54,55.63 1 1 -github.com/corentings/chess/v2/opening/eco.go:55.63,56.23 1 1 -github.com/corentings/chess/v2/opening/eco.go:56.23,58.4 1 1 -github.com/corentings/chess/v2/opening/eco.go:60.2,60.12 1 0 -github.com/corentings/chess/v2/opening/eco.go:64.60,67.34 3 1 -github.com/corentings/chess/v2/opening/eco.go:67.34,68.23 1 1 -github.com/corentings/chess/v2/opening/eco.go:68.23,70.4 1 1 -github.com/corentings/chess/v2/opening/eco.go:72.2,72.17 1 1 -github.com/corentings/chess/v2/opening/eco.go:75.66,76.21 1 1 -github.com/corentings/chess/v2/opening/eco.go:76.21,78.3 1 1 -github.com/corentings/chess/v2/opening/eco.go:79.2,80.9 2 1 -github.com/corentings/chess/v2/opening/eco.go:80.9,82.3 1 0 -github.com/corentings/chess/v2/opening/eco.go:83.2,83.35 1 1 -github.com/corentings/chess/v2/opening/eco.go:86.44,89.41 3 1 -github.com/corentings/chess/v2/opening/eco.go:89.41,92.17 3 1 -github.com/corentings/chess/v2/opening/eco.go:92.17,94.4 1 0 -github.com/corentings/chess/v2/opening/eco.go:95.3,96.43 2 1 -github.com/corentings/chess/v2/opening/eco.go:98.2,100.12 3 1 -github.com/corentings/chess/v2/opening/eco.go:103.92,108.32 5 1 -github.com/corentings/chess/v2/opening/eco.go:108.32,109.20 1 1 -github.com/corentings/chess/v2/opening/eco.go:109.20,111.9 2 1 -github.com/corentings/chess/v2/opening/eco.go:114.2,114.18 1 1 -github.com/corentings/chess/v2/opening/eco.go:114.18,122.3 2 1 -github.com/corentings/chess/v2/opening/eco.go:123.2,123.23 1 1 -github.com/corentings/chess/v2/opening/eco.go:123.23,126.3 2 1 -github.com/corentings/chess/v2/opening/eco.go:127.2,127.41 1 1 -github.com/corentings/chess/v2/opening/eco.go:138.52,140.34 2 1 -github.com/corentings/chess/v2/opening/eco.go:140.34,142.3 1 1 -github.com/corentings/chess/v2/opening/eco.go:145.48,147.12 2 1 -github.com/corentings/chess/v2/opening/eco.go:147.12,150.3 2 1 -github.com/corentings/chess/v2/opening/eco.go:151.2,152.20 2 1 -github.com/corentings/chess/v2/opening/eco.go:152.20,154.3 1 1 -github.com/corentings/chess/v2/opening/eco.go:155.2,155.14 1 1 -github.com/corentings/chess/v2/opening/eco.go:158.34,162.2 3 1 -github.com/corentings/chess/v2/opening/eco.go:165.41,168.25 3 1 -github.com/corentings/chess/v2/opening/eco.go:168.25,170.14 2 1 -github.com/corentings/chess/v2/opening/eco.go:170.14,172.4 1 1 -github.com/corentings/chess/v2/opening/eco.go:172.9,174.4 1 0 -github.com/corentings/chess/v2/opening/eco.go:176.2,176.11 1 1 -github.com/corentings/chess/v2/opening/opening.go:19.33,21.2 1 0 -github.com/corentings/chess/v2/opening/opening.go:24.34,26.2 1 1 -github.com/corentings/chess/v2/opening/opening.go:29.32,31.2 1 0 -github.com/corentings/chess/v2/opening/opening.go:34.38,35.19 1 0 -github.com/corentings/chess/v2/opening/opening.go:35.19,38.3 2 0 -github.com/corentings/chess/v2/opening/opening.go:39.2,39.15 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:32.60,34.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:34.16,36.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:38.2,42.16 4 0 -github.com/corentings/chess/v2/image/internal/bindata.go:42.16,44.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:45.2,45.18 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:45.18,47.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:49.2,49.25 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:64.41,66.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:68.40,70.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:72.46,74.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:76.47,78.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:80.40,82.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:84.45,86.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:90.44,95.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:97.39,99.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:99.16,101.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:103.2,105.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:110.41,115.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:117.36,119.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:119.16,121.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:123.2,125.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:130.41,135.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:137.36,139.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:139.16,141.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:143.2,145.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:150.41,155.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:157.36,159.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:159.16,161.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:163.2,165.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:170.41,175.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:177.36,179.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:179.16,181.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:183.2,185.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:190.41,195.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:197.36,199.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:199.16,201.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:203.2,205.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:210.41,215.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:217.36,219.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:219.16,221.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:223.2,225.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:230.41,235.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:237.36,239.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:239.16,241.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:243.2,245.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:250.41,255.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:257.36,259.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:259.16,261.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:263.2,265.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:270.41,275.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:277.36,279.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:279.16,281.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:283.2,285.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:290.41,295.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:297.36,299.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:299.16,301.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:303.2,305.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:310.41,315.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:317.36,319.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:319.16,321.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:323.2,325.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:330.41,335.2 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:337.36,339.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:339.16,341.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:343.2,345.15 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:351.41,353.43 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:353.43,355.17 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:355.17,357.4 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:358.3,358.22 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:360.2,360.52 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:365.36,367.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:367.16,368.54 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:371.2,371.10 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:377.50,379.43 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:379.43,381.17 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:381.17,383.4 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:384.3,384.21 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:386.2,386.56 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:390.28,392.29 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:392.29,394.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:395.2,395.14 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:430.46,432.20 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:432.20,435.30 3 0 -github.com/corentings/chess/v2/image/internal/bindata.go:435.30,437.19 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:437.19,439.5 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:442.2,442.22 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:442.22,444.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:445.2,446.39 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:446.39,448.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:449.2,449.16 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:476.43,478.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:478.16,480.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:481.2,482.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:482.16,484.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:485.2,486.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:486.16,488.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:489.2,490.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:490.16,492.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:493.2,494.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:494.16,496.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:497.2,497.12 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:501.44,504.16 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:504.16,506.3 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:508.2,508.33 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:508.33,510.17 2 0 -github.com/corentings/chess/v2/image/internal/bindata.go:510.17,512.4 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:514.2,514.12 1 0 -github.com/corentings/chess/v2/image/internal/bindata.go:517.41,520.2 2 0 -github.com/corentings/chess/v2/bitboard.go:62.46,64.38 2 1 -github.com/corentings/chess/v2/bitboard.go:64.38,66.20 2 1 -github.com/corentings/chess/v2/bitboard.go:66.20,68.4 1 1 -github.com/corentings/chess/v2/bitboard.go:70.2,70.21 1 1 -github.com/corentings/chess/v2/bitboard.go:78.45,80.38 2 1 -github.com/corentings/chess/v2/bitboard.go:80.38,81.36 1 1 -github.com/corentings/chess/v2/bitboard.go:81.36,83.4 1 1 -github.com/corentings/chess/v2/bitboard.go:85.2,85.10 1 1 -github.com/corentings/chess/v2/bitboard.go:89.35,92.2 2 0 -github.com/corentings/chess/v2/bitboard.go:95.33,97.26 2 0 -github.com/corentings/chess/v2/bitboard.go:97.26,99.36 2 0 -github.com/corentings/chess/v2/bitboard.go:99.36,101.22 2 0 -github.com/corentings/chess/v2/bitboard.go:101.22,103.5 1 0 -github.com/corentings/chess/v2/bitboard.go:103.10,105.5 1 0 -github.com/corentings/chess/v2/bitboard.go:106.4,106.12 1 0 -github.com/corentings/chess/v2/bitboard.go:108.3,108.12 1 0 -github.com/corentings/chess/v2/bitboard.go:110.2,110.10 1 0 -github.com/corentings/chess/v2/bitboard.go:121.38,123.2 1 1 -github.com/corentings/chess/v2/bitboard.go:134.44,136.2 1 1 -github.com/corentings/chess/v2/board.go:78.42,80.31 2 1 -github.com/corentings/chess/v2/board.go:80.31,82.39 2 1 -github.com/corentings/chess/v2/board.go:82.39,84.55 2 1 -github.com/corentings/chess/v2/board.go:84.55,86.5 1 1 -github.com/corentings/chess/v2/board.go:88.3,88.36 1 1 -github.com/corentings/chess/v2/board.go:90.2,91.10 2 1 -github.com/corentings/chess/v2/board.go:96.46,98.38 2 1 -github.com/corentings/chess/v2/board.go:98.38,100.19 2 1 -github.com/corentings/chess/v2/board.go:100.19,102.4 1 1 -github.com/corentings/chess/v2/board.go:104.2,104.10 1 1 -github.com/corentings/chess/v2/board.go:108.33,110.2 1 1 -github.com/corentings/chess/v2/board.go:125.47,127.38 2 1 -github.com/corentings/chess/v2/board.go:127.38,129.13 2 1 -github.com/corentings/chess/v2/board.go:130.15,133.30 3 1 -github.com/corentings/chess/v2/board.go:134.18,137.30 3 1 -github.com/corentings/chess/v2/board.go:139.3,139.30 1 1 -github.com/corentings/chess/v2/board.go:141.2,141.20 1 1 -github.com/corentings/chess/v2/board.go:145.36,147.38 2 1 -github.com/corentings/chess/v2/board.go:147.38,152.3 4 1 -github.com/corentings/chess/v2/board.go:153.2,153.20 1 1 -github.com/corentings/chess/v2/board.go:171.31,173.2 1 0 -github.com/corentings/chess/v2/board.go:178.64,179.26 1 0 -github.com/corentings/chess/v2/board.go:179.26,181.3 1 0 -github.com/corentings/chess/v2/board.go:183.2,183.33 1 0 -github.com/corentings/chess/v2/board.go:187.52,189.26 2 0 -github.com/corentings/chess/v2/board.go:189.26,191.36 2 0 -github.com/corentings/chess/v2/board.go:191.36,193.20 2 0 -github.com/corentings/chess/v2/board.go:193.20,195.5 1 0 -github.com/corentings/chess/v2/board.go:195.10,196.17 1 0 -github.com/corentings/chess/v2/board.go:196.17,198.6 1 0 -github.com/corentings/chess/v2/board.go:198.11,200.6 1 0 -github.com/corentings/chess/v2/board.go:202.4,202.12 1 0 -github.com/corentings/chess/v2/board.go:204.3,204.12 1 0 -github.com/corentings/chess/v2/board.go:206.2,206.10 1 0 -github.com/corentings/chess/v2/board.go:210.52,212.26 2 0 -github.com/corentings/chess/v2/board.go:212.26,214.47 2 0 -github.com/corentings/chess/v2/board.go:214.47,216.20 2 0 -github.com/corentings/chess/v2/board.go:216.20,218.5 1 0 -github.com/corentings/chess/v2/board.go:218.10,219.17 1 0 -github.com/corentings/chess/v2/board.go:219.17,221.6 1 0 -github.com/corentings/chess/v2/board.go:221.11,223.6 1 0 -github.com/corentings/chess/v2/board.go:225.4,225.12 1 0 -github.com/corentings/chess/v2/board.go:227.3,227.12 1 0 -github.com/corentings/chess/v2/board.go:229.2,229.10 1 0 -github.com/corentings/chess/v2/board.go:234.33,245.37 5 1 -github.com/corentings/chess/v2/board.go:245.37,247.23 1 1 -github.com/corentings/chess/v2/board.go:247.23,249.4 1 1 -github.com/corentings/chess/v2/board.go:252.3,252.29 1 1 -github.com/corentings/chess/v2/board.go:252.29,256.20 3 1 -github.com/corentings/chess/v2/board.go:256.20,258.13 2 1 -github.com/corentings/chess/v2/board.go:262.4,262.22 1 1 -github.com/corentings/chess/v2/board.go:262.22,265.5 2 1 -github.com/corentings/chess/v2/board.go:268.4,268.37 1 1 -github.com/corentings/chess/v2/board.go:272.3,272.21 1 1 -github.com/corentings/chess/v2/board.go:272.21,275.4 2 1 -github.com/corentings/chess/v2/board.go:279.2,279.20 1 1 -github.com/corentings/chess/v2/board.go:284.40,285.30 1 1 -github.com/corentings/chess/v2/board.go:285.30,287.22 2 1 -github.com/corentings/chess/v2/board.go:287.22,289.4 1 1 -github.com/corentings/chess/v2/board.go:291.2,291.16 1 1 -github.com/corentings/chess/v2/board.go:296.47,298.2 1 1 -github.com/corentings/chess/v2/board.go:302.50,304.16 2 1 -github.com/corentings/chess/v2/board.go:304.16,306.3 1 0 -github.com/corentings/chess/v2/board.go:307.2,308.12 2 1 -github.com/corentings/chess/v2/board.go:315.49,323.2 4 1 -github.com/corentings/chess/v2/board.go:329.52,332.31 2 1 -github.com/corentings/chess/v2/board.go:332.31,334.3 1 0 -github.com/corentings/chess/v2/board.go:335.2,348.12 14 1 -github.com/corentings/chess/v2/board.go:352.33,358.30 4 1 -github.com/corentings/chess/v2/board.go:358.30,363.24 3 1 -github.com/corentings/chess/v2/board.go:363.24,366.4 2 1 -github.com/corentings/chess/v2/board.go:369.2,369.45 1 1 -github.com/corentings/chess/v2/board.go:369.45,377.3 5 1 -github.com/corentings/chess/v2/board.go:379.2,379.25 1 1 -github.com/corentings/chess/v2/board.go:379.25,380.26 1 1 -github.com/corentings/chess/v2/board.go:380.26,382.4 1 1 -github.com/corentings/chess/v2/board.go:382.9,384.4 1 1 -github.com/corentings/chess/v2/board.go:387.2,387.9 1 1 -github.com/corentings/chess/v2/board.go:388.55,389.69 1 1 -github.com/corentings/chess/v2/board.go:390.56,391.71 1 1 -github.com/corentings/chess/v2/board.go:392.55,393.69 1 1 -github.com/corentings/chess/v2/board.go:394.56,395.71 1 1 -github.com/corentings/chess/v2/board.go:398.2,398.23 1 1 -github.com/corentings/chess/v2/board.go:401.43,408.9 7 1 -github.com/corentings/chess/v2/board.go:409.16,413.39 3 1 -github.com/corentings/chess/v2/board.go:413.39,415.35 2 1 -github.com/corentings/chess/v2/board.go:415.35,417.5 1 1 -github.com/corentings/chess/v2/board.go:417.10,417.42 1 1 -github.com/corentings/chess/v2/board.go:417.42,419.5 1 1 -github.com/corentings/chess/v2/board.go:421.29,422.23 1 1 -github.com/corentings/chess/v2/board.go:423.29,424.23 1 1 -github.com/corentings/chess/v2/board.go:428.31,448.2 1 1 -github.com/corentings/chess/v2/board.go:450.44,452.2 1 1 -github.com/corentings/chess/v2/board.go:454.46,457.55 1 1 -github.com/corentings/chess/v2/board.go:457.55,459.3 1 1 -github.com/corentings/chess/v2/board.go:461.2,461.46 1 1 -github.com/corentings/chess/v2/board.go:461.46,463.3 1 0 -github.com/corentings/chess/v2/board.go:464.2,466.29 3 1 -github.com/corentings/chess/v2/board.go:466.29,468.3 1 1 -github.com/corentings/chess/v2/board.go:470.2,470.46 1 1 -github.com/corentings/chess/v2/board.go:470.46,472.3 1 1 -github.com/corentings/chess/v2/board.go:474.2,474.46 1 1 -github.com/corentings/chess/v2/board.go:474.46,476.3 1 1 -github.com/corentings/chess/v2/board.go:478.2,478.46 1 1 -github.com/corentings/chess/v2/board.go:478.46,480.3 1 1 -github.com/corentings/chess/v2/board.go:482.2,482.24 1 1 -github.com/corentings/chess/v2/board.go:482.24,485.31 3 1 -github.com/corentings/chess/v2/board.go:485.31,486.26 1 1 -github.com/corentings/chess/v2/board.go:486.26,487.23 1 1 -github.com/corentings/chess/v2/board.go:488.16,489.18 1 1 -github.com/corentings/chess/v2/board.go:490.16,491.18 1 1 -github.com/corentings/chess/v2/board.go:495.3,495.41 1 1 -github.com/corentings/chess/v2/board.go:495.41,497.4 1 1 -github.com/corentings/chess/v2/board.go:499.2,499.13 1 1 -github.com/corentings/chess/v2/board.go:502.46,503.11 1 1 -github.com/corentings/chess/v2/board.go:504.17,505.23 1 1 -github.com/corentings/chess/v2/board.go:506.18,507.24 1 1 -github.com/corentings/chess/v2/board.go:508.17,509.23 1 1 -github.com/corentings/chess/v2/board.go:510.19,511.25 1 1 -github.com/corentings/chess/v2/board.go:512.19,513.25 1 1 -github.com/corentings/chess/v2/board.go:514.17,515.23 1 1 -github.com/corentings/chess/v2/board.go:516.17,517.23 1 1 -github.com/corentings/chess/v2/board.go:518.18,519.24 1 1 -github.com/corentings/chess/v2/board.go:520.17,521.23 1 1 -github.com/corentings/chess/v2/board.go:522.19,523.25 1 1 -github.com/corentings/chess/v2/board.go:524.19,525.25 1 1 -github.com/corentings/chess/v2/board.go:526.17,527.23 1 1 -github.com/corentings/chess/v2/board.go:529.2,529.20 1 0 -github.com/corentings/chess/v2/board.go:532.53,533.11 1 1 -github.com/corentings/chess/v2/board.go:534.17,535.21 1 1 -github.com/corentings/chess/v2/board.go:536.18,537.22 1 1 -github.com/corentings/chess/v2/board.go:538.17,539.21 1 1 -github.com/corentings/chess/v2/board.go:540.19,541.23 1 1 -github.com/corentings/chess/v2/board.go:542.19,543.23 1 1 -github.com/corentings/chess/v2/board.go:544.17,545.21 1 1 -github.com/corentings/chess/v2/board.go:546.17,547.21 1 1 -github.com/corentings/chess/v2/board.go:548.18,549.22 1 1 -github.com/corentings/chess/v2/board.go:550.17,551.21 1 1 -github.com/corentings/chess/v2/board.go:552.19,553.23 1 1 -github.com/corentings/chess/v2/board.go:554.19,555.23 1 1 -github.com/corentings/chess/v2/board.go:556.17,557.21 1 1 -github.com/corentings/chess/v2/board.go:558.10,559.36 1 0 -github.com/corentings/chess/v2/engine.go:38.59,43.2 2 1 -github.com/corentings/chess/v2/engine.go:47.49,49.2 1 1 -github.com/corentings/chess/v2/engine.go:60.44,62.27 2 1 -github.com/corentings/chess/v2/engine.go:62.27,64.3 1 0 -github.com/corentings/chess/v2/engine.go:64.8,66.3 1 1 -github.com/corentings/chess/v2/engine.go:67.2,67.30 1 1 -github.com/corentings/chess/v2/engine.go:67.30,69.3 1 1 -github.com/corentings/chess/v2/engine.go:69.8,69.36 1 1 -github.com/corentings/chess/v2/engine.go:69.36,71.3 1 1 -github.com/corentings/chess/v2/engine.go:72.2,72.17 1 1 -github.com/corentings/chess/v2/engine.go:87.26,89.3 1 1 -github.com/corentings/chess/v2/engine.go:98.71,107.25 6 1 -github.com/corentings/chess/v2/engine.go:107.25,109.3 1 1 -github.com/corentings/chess/v2/engine.go:111.2,111.30 1 1 -github.com/corentings/chess/v2/engine.go:111.30,112.30 1 1 -github.com/corentings/chess/v2/engine.go:112.30,113.12 1 1 -github.com/corentings/chess/v2/engine.go:115.3,116.16 2 1 -github.com/corentings/chess/v2/engine.go:116.16,117.12 1 1 -github.com/corentings/chess/v2/engine.go:119.3,119.39 1 1 -github.com/corentings/chess/v2/engine.go:119.39,120.41 1 1 -github.com/corentings/chess/v2/engine.go:120.41,121.13 1 1 -github.com/corentings/chess/v2/engine.go:123.4,124.17 2 1 -github.com/corentings/chess/v2/engine.go:124.17,125.13 1 1 -github.com/corentings/chess/v2/engine.go:127.4,127.40 1 1 -github.com/corentings/chess/v2/engine.go:127.40,128.42 1 1 -github.com/corentings/chess/v2/engine.go:128.42,129.14 1 1 -github.com/corentings/chess/v2/engine.go:133.5,136.105 3 1 -github.com/corentings/chess/v2/engine.go:136.105,137.41 1 1 -github.com/corentings/chess/v2/engine.go:137.41,140.42 3 1 -github.com/corentings/chess/v2/engine.go:140.42,144.17 3 1 -github.com/corentings/chess/v2/engine.go:144.17,149.9 3 1 -github.com/corentings/chess/v2/engine.go:152.11,155.42 3 1 -github.com/corentings/chess/v2/engine.go:155.42,158.16 3 1 -github.com/corentings/chess/v2/engine.go:158.16,162.8 3 1 -github.com/corentings/chess/v2/engine.go:170.2,172.15 3 1 -github.com/corentings/chess/v2/engine.go:183.46,186.32 3 1 -github.com/corentings/chess/v2/engine.go:186.32,188.3 1 1 -github.com/corentings/chess/v2/engine.go:188.8,188.60 1 1 -github.com/corentings/chess/v2/engine.go:188.60,190.3 1 1 -github.com/corentings/chess/v2/engine.go:192.2,192.70 1 1 -github.com/corentings/chess/v2/engine.go:192.70,193.15 1 1 -github.com/corentings/chess/v2/engine.go:194.15,195.27 1 1 -github.com/corentings/chess/v2/engine.go:196.15,197.26 1 1 -github.com/corentings/chess/v2/engine.go:201.2,206.19 5 1 -github.com/corentings/chess/v2/engine.go:206.19,208.3 1 1 -github.com/corentings/chess/v2/engine.go:210.2,211.19 2 1 -github.com/corentings/chess/v2/engine.go:211.19,213.3 1 1 -github.com/corentings/chess/v2/engine.go:214.2,214.13 1 1 -github.com/corentings/chess/v2/engine.go:218.36,220.25 2 1 -github.com/corentings/chess/v2/engine.go:220.25,222.3 1 1 -github.com/corentings/chess/v2/engine.go:224.2,224.24 1 1 -github.com/corentings/chess/v2/engine.go:224.24,226.3 1 1 -github.com/corentings/chess/v2/engine.go:227.2,227.40 1 1 -github.com/corentings/chess/v2/engine.go:240.60,243.25 3 1 -github.com/corentings/chess/v2/engine.go:243.25,246.26 2 1 -github.com/corentings/chess/v2/engine.go:246.26,248.4 1 1 -github.com/corentings/chess/v2/engine.go:249.3,249.82 1 1 -github.com/corentings/chess/v2/engine.go:249.82,250.12 1 1 -github.com/corentings/chess/v2/engine.go:253.3,255.14 3 1 -github.com/corentings/chess/v2/engine.go:255.14,257.4 1 1 -github.com/corentings/chess/v2/engine.go:259.3,261.14 3 1 -github.com/corentings/chess/v2/engine.go:261.14,263.4 1 1 -github.com/corentings/chess/v2/engine.go:265.3,267.14 3 1 -github.com/corentings/chess/v2/engine.go:267.14,269.4 1 1 -github.com/corentings/chess/v2/engine.go:271.3,273.14 3 1 -github.com/corentings/chess/v2/engine.go:273.14,275.4 1 1 -github.com/corentings/chess/v2/engine.go:277.3,277.26 1 1 -github.com/corentings/chess/v2/engine.go:277.26,281.15 4 1 -github.com/corentings/chess/v2/engine.go:281.15,283.5 1 1 -github.com/corentings/chess/v2/engine.go:284.9,288.15 4 1 -github.com/corentings/chess/v2/engine.go:288.15,290.5 1 1 -github.com/corentings/chess/v2/engine.go:293.3,295.14 3 1 -github.com/corentings/chess/v2/engine.go:295.14,297.4 1 1 -github.com/corentings/chess/v2/engine.go:299.2,299.14 1 1 -github.com/corentings/chess/v2/engine.go:313.74,314.12 1 1 -github.com/corentings/chess/v2/engine.go:315.12,316.25 1 1 -github.com/corentings/chess/v2/engine.go:317.13,318.80 1 1 -github.com/corentings/chess/v2/engine.go:319.12,320.43 1 1 -github.com/corentings/chess/v2/engine.go:321.14,322.44 1 1 -github.com/corentings/chess/v2/engine.go:323.14,324.27 1 1 -github.com/corentings/chess/v2/engine.go:325.12,326.28 1 1 -github.com/corentings/chess/v2/engine.go:328.2,328.20 1 0 -github.com/corentings/chess/v2/engine.go:338.40,349.16 5 1 -github.com/corentings/chess/v2/engine.go:349.16,354.3 4 1 -github.com/corentings/chess/v2/engine.go:357.2,360.16 1 1 -github.com/corentings/chess/v2/engine.go:360.16,365.3 4 1 -github.com/corentings/chess/v2/engine.go:368.2,371.16 1 1 -github.com/corentings/chess/v2/engine.go:371.16,376.3 4 1 -github.com/corentings/chess/v2/engine.go:379.2,382.16 1 1 -github.com/corentings/chess/v2/engine.go:382.16,387.3 4 1 -github.com/corentings/chess/v2/engine.go:389.2,389.22 1 1 -github.com/corentings/chess/v2/engine.go:401.51,404.37 3 1 -github.com/corentings/chess/v2/engine.go:404.37,406.3 1 1 -github.com/corentings/chess/v2/engine.go:407.2,407.25 1 1 -github.com/corentings/chess/v2/engine.go:407.25,413.3 5 1 -github.com/corentings/chess/v2/engine.go:414.2,418.43 5 1 -github.com/corentings/chess/v2/engine.go:423.55,428.2 4 1 -github.com/corentings/chess/v2/engine.go:431.54,436.2 4 1 -github.com/corentings/chess/v2/engine.go:441.58,444.2 2 1 -github.com/corentings/chess/v2/engine.go:467.38,469.2 1 1 -github.com/corentings/chess/v2/engine.go:498.13,500.38 2 1 -github.com/corentings/chess/v2/engine.go:500.38,502.3 1 1 -github.com/corentings/chess/v2/errors.go:14.35,16.2 1 1 -github.com/corentings/chess/v2/errors.go:18.42,21.9 3 1 -github.com/corentings/chess/v2/errors.go:21.9,23.3 1 0 -github.com/corentings/chess/v2/errors.go:25.2,25.23 1 1 -github.com/corentings/chess/v2/errors.go:32.47,32.96 1 0 -github.com/corentings/chess/v2/errors.go:33.47,33.94 1 1 -github.com/corentings/chess/v2/errors.go:34.47,34.102 1 0 -github.com/corentings/chess/v2/errors.go:35.47,35.89 1 0 -github.com/corentings/chess/v2/errors.go:36.47,36.90 1 1 -github.com/corentings/chess/v2/errors.go:37.47,37.88 1 0 -github.com/corentings/chess/v2/errors.go:49.38,52.2 1 0 -github.com/corentings/chess/v2/fen.go:14.47,19.31 4 1 -github.com/corentings/chess/v2/fen.go:19.31,21.3 1 0 -github.com/corentings/chess/v2/fen.go:22.2,23.16 2 1 -github.com/corentings/chess/v2/fen.go:23.16,25.3 1 1 -github.com/corentings/chess/v2/fen.go:26.2,27.9 2 1 -github.com/corentings/chess/v2/fen.go:27.9,29.3 1 1 -github.com/corentings/chess/v2/fen.go:30.2,31.16 2 1 -github.com/corentings/chess/v2/fen.go:31.16,33.3 1 1 -github.com/corentings/chess/v2/fen.go:34.2,35.16 2 1 -github.com/corentings/chess/v2/fen.go:35.16,37.3 1 1 -github.com/corentings/chess/v2/fen.go:38.2,39.37 2 1 -github.com/corentings/chess/v2/fen.go:39.37,41.3 1 1 -github.com/corentings/chess/v2/fen.go:42.2,43.33 2 1 -github.com/corentings/chess/v2/fen.go:43.33,45.3 1 1 -github.com/corentings/chess/v2/fen.go:46.2,53.8 1 1 -github.com/corentings/chess/v2/fen.go:67.27,69.4 1 1 -github.com/corentings/chess/v2/fen.go:76.27,78.4 1 1 -github.com/corentings/chess/v2/fen.go:83.47,84.19 1 1 -github.com/corentings/chess/v2/fen.go:84.19,86.3 1 1 -github.com/corentings/chess/v2/fen.go:90.48,106.15 7 1 -github.com/corentings/chess/v2/fen.go:106.15,109.3 2 1 -github.com/corentings/chess/v2/fen.go:112.2,114.31 3 1 -github.com/corentings/chess/v2/fen.go:114.31,115.25 1 1 -github.com/corentings/chess/v2/fen.go:115.25,116.31 1 1 -github.com/corentings/chess/v2/fen.go:116.31,118.5 1 0 -github.com/corentings/chess/v2/fen.go:119.4,121.17 3 1 -github.com/corentings/chess/v2/fen.go:126.2,126.27 1 1 -github.com/corentings/chess/v2/fen.go:126.27,127.30 1 1 -github.com/corentings/chess/v2/fen.go:127.30,129.4 1 0 -github.com/corentings/chess/v2/fen.go:130.3,131.14 2 1 -github.com/corentings/chess/v2/fen.go:134.2,134.29 1 1 -github.com/corentings/chess/v2/fen.go:134.29,136.3 1 0 -github.com/corentings/chess/v2/fen.go:138.2,138.28 1 1 -github.com/corentings/chess/v2/fen.go:138.28,145.61 3 1 -github.com/corentings/chess/v2/fen.go:145.61,147.4 1 1 -github.com/corentings/chess/v2/fen.go:150.3,150.36 1 1 -github.com/corentings/chess/v2/fen.go:150.36,152.4 1 1 -github.com/corentings/chess/v2/fen.go:157.2,157.25 1 1 -github.com/corentings/chess/v2/fen.go:161.58,165.30 3 1 -github.com/corentings/chess/v2/fen.go:165.30,169.27 2 1 -github.com/corentings/chess/v2/fen.go:169.27,171.12 2 1 -github.com/corentings/chess/v2/fen.go:175.3,176.23 2 1 -github.com/corentings/chess/v2/fen.go:176.23,178.4 1 0 -github.com/corentings/chess/v2/fen.go:180.3,181.10 2 1 -github.com/corentings/chess/v2/fen.go:184.2,184.25 1 1 -github.com/corentings/chess/v2/fen.go:184.25,186.3 1 1 -github.com/corentings/chess/v2/fen.go:188.2,188.12 1 1 -github.com/corentings/chess/v2/fen.go:191.63,193.54 1 1 -github.com/corentings/chess/v2/fen.go:193.54,194.38 1 1 -github.com/corentings/chess/v2/fen.go:194.38,196.4 1 1 -github.com/corentings/chess/v2/fen.go:198.2,198.30 1 1 -github.com/corentings/chess/v2/fen.go:198.30,200.12 2 1 -github.com/corentings/chess/v2/fen.go:201.32,201.32 0 1 -github.com/corentings/chess/v2/fen.go:202.11,203.76 1 1 -github.com/corentings/chess/v2/fen.go:206.2,206.37 1 1 -github.com/corentings/chess/v2/fen.go:209.54,210.22 1 1 -github.com/corentings/chess/v2/fen.go:210.22,212.3 1 1 -github.com/corentings/chess/v2/fen.go:213.2,214.67 2 1 -github.com/corentings/chess/v2/fen.go:214.67,216.3 1 1 -github.com/corentings/chess/v2/fen.go:217.2,217.16 1 1 -github.com/corentings/chess/v2/game.go:49.34,51.2 1 1 -github.com/corentings/chess/v2/game.go:106.44,109.24 2 1 -github.com/corentings/chess/v2/game.go:109.24,111.3 1 1 -github.com/corentings/chess/v2/game.go:113.2,114.16 2 1 -github.com/corentings/chess/v2/game.go:114.16,116.3 1 0 -github.com/corentings/chess/v2/game.go:118.2,119.16 2 1 -github.com/corentings/chess/v2/game.go:119.16,121.3 1 0 -github.com/corentings/chess/v2/game.go:123.2,125.16 3 1 -github.com/corentings/chess/v2/game.go:125.16,127.3 1 0 -github.com/corentings/chess/v2/game.go:130.2,130.23 1 1 -github.com/corentings/chess/v2/game.go:130.23,132.3 1 1 -github.com/corentings/chess/v2/game.go:140.43,142.16 2 1 -github.com/corentings/chess/v2/game.go:142.16,144.3 1 0 -github.com/corentings/chess/v2/game.go:145.2,145.16 1 1 -github.com/corentings/chess/v2/game.go:145.16,147.3 1 0 -github.com/corentings/chess/v2/game.go:148.2,148.23 1 1 -github.com/corentings/chess/v2/game.go:148.23,153.3 4 1 -github.com/corentings/chess/v2/game.go:166.44,180.28 4 1 -github.com/corentings/chess/v2/game.go:180.28,181.15 1 1 -github.com/corentings/chess/v2/game.go:181.15,183.4 1 1 -github.com/corentings/chess/v2/game.go:185.2,185.13 1 1 -github.com/corentings/chess/v2/game.go:190.58,193.2 2 1 -github.com/corentings/chess/v2/game.go:197.37,201.52 2 1 -github.com/corentings/chess/v2/game.go:201.52,203.3 1 0 -github.com/corentings/chess/v2/game.go:206.2,206.35 1 1 -github.com/corentings/chess/v2/game.go:206.35,209.3 2 1 -github.com/corentings/chess/v2/game.go:212.2,212.40 1 1 -github.com/corentings/chess/v2/game.go:215.34,216.24 1 1 -github.com/corentings/chess/v2/game.go:216.24,218.3 1 1 -github.com/corentings/chess/v2/game.go:219.2,219.67 1 1 -github.com/corentings/chess/v2/game.go:225.30,226.57 1 1 -github.com/corentings/chess/v2/game.go:226.57,230.3 3 1 -github.com/corentings/chess/v2/game.go:231.2,231.14 1 1 -github.com/corentings/chess/v2/game.go:237.33,239.61 1 1 -github.com/corentings/chess/v2/game.go:239.61,243.3 3 1 -github.com/corentings/chess/v2/game.go:244.2,244.14 1 1 -github.com/corentings/chess/v2/game.go:248.33,250.2 1 1 -github.com/corentings/chess/v2/game.go:253.31,255.2 1 1 -github.com/corentings/chess/v2/game.go:258.36,260.2 1 1 -github.com/corentings/chess/v2/game.go:264.37,266.2 1 1 -github.com/corentings/chess/v2/game.go:269.32,270.23 1 1 -github.com/corentings/chess/v2/game.go:270.23,272.3 1 0 -github.com/corentings/chess/v2/game.go:274.2,278.21 3 1 -github.com/corentings/chess/v2/game.go:278.21,280.33 2 1 -github.com/corentings/chess/v2/game.go:280.33,281.9 1 1 -github.com/corentings/chess/v2/game.go:284.3,284.32 1 1 -github.com/corentings/chess/v2/game.go:287.2,287.18 1 1 -github.com/corentings/chess/v2/game.go:302.45,303.56 1 1 -github.com/corentings/chess/v2/game.go:303.56,305.3 1 1 -github.com/corentings/chess/v2/game.go:307.2,310.50 3 1 -github.com/corentings/chess/v2/game.go:310.50,312.18 2 1 -github.com/corentings/chess/v2/game.go:312.18,313.9 1 0 -github.com/corentings/chess/v2/game.go:315.3,316.28 2 1 -github.com/corentings/chess/v2/game.go:316.28,318.4 1 1 -github.com/corentings/chess/v2/game.go:320.3,326.17 2 1 -github.com/corentings/chess/v2/game.go:329.2,329.16 1 1 -github.com/corentings/chess/v2/game.go:333.36,335.2 1 1 -github.com/corentings/chess/v2/game.go:338.47,339.44 1 1 -github.com/corentings/chess/v2/game.go:339.44,341.3 1 1 -github.com/corentings/chess/v2/game.go:343.2,343.26 1 1 -github.com/corentings/chess/v2/game.go:348.38,349.23 1 1 -github.com/corentings/chess/v2/game.go:349.23,351.3 1 1 -github.com/corentings/chess/v2/game.go:352.2,352.47 1 1 -github.com/corentings/chess/v2/game.go:356.37,358.2 1 1 -github.com/corentings/chess/v2/game.go:363.44,364.26 1 1 -github.com/corentings/chess/v2/game.go:364.26,366.3 1 0 -github.com/corentings/chess/v2/game.go:368.2,368.31 1 1 -github.com/corentings/chess/v2/game.go:372.34,374.2 1 1 -github.com/corentings/chess/v2/game.go:377.32,379.2 1 1 -github.com/corentings/chess/v2/game.go:382.29,384.2 1 0 -github.com/corentings/chess/v2/game.go:388.38,390.30 2 1 -github.com/corentings/chess/v2/game.go:390.30,392.28 2 1 -github.com/corentings/chess/v2/game.go:392.28,394.4 1 1 -github.com/corentings/chess/v2/game.go:395.3,395.18 1 1 -github.com/corentings/chess/v2/game.go:397.2,397.20 1 1 -github.com/corentings/chess/v2/game.go:402.32,408.37 4 1 -github.com/corentings/chess/v2/game.go:408.37,414.3 2 1 -github.com/corentings/chess/v2/game.go:416.2,419.38 2 1 -github.com/corentings/chess/v2/game.go:419.38,421.3 1 1 -github.com/corentings/chess/v2/game.go:424.2,424.25 1 1 -github.com/corentings/chess/v2/game.go:424.25,426.3 1 1 -github.com/corentings/chess/v2/game.go:430.2,431.23 2 1 -github.com/corentings/chess/v2/game.go:431.23,432.35 1 1 -github.com/corentings/chess/v2/game.go:432.35,436.4 1 1 -github.com/corentings/chess/v2/game.go:436.9,436.41 1 1 -github.com/corentings/chess/v2/game.go:436.41,438.4 1 1 -github.com/corentings/chess/v2/game.go:442.2,442.23 1 1 -github.com/corentings/chess/v2/game.go:442.23,444.3 1 1 -github.com/corentings/chess/v2/game.go:445.2,446.20 2 1 -github.com/corentings/chess/v2/game.go:456.40,458.20 1 1 -github.com/corentings/chess/v2/game.go:458.20,460.3 1 0 -github.com/corentings/chess/v2/game.go:463.2,471.4 1 1 -github.com/corentings/chess/v2/game.go:471.4,472.19 1 1 -github.com/corentings/chess/v2/game.go:472.19,474.4 1 1 -github.com/corentings/chess/v2/game.go:475.3,475.19 1 1 -github.com/corentings/chess/v2/game.go:475.19,477.4 1 1 -github.com/corentings/chess/v2/game.go:481.2,481.19 1 1 -github.com/corentings/chess/v2/game.go:481.19,483.3 1 1 -github.com/corentings/chess/v2/game.go:483.8,483.26 1 1 -github.com/corentings/chess/v2/game.go:483.26,485.3 1 1 -github.com/corentings/chess/v2/game.go:486.2,486.10 1 0 -github.com/corentings/chess/v2/game.go:509.8,513.17 2 1 -github.com/corentings/chess/v2/game.go:513.17,515.3 1 0 -github.com/corentings/chess/v2/game.go:518.2,518.37 1 1 -github.com/corentings/chess/v2/game.go:518.37,520.3 1 1 -github.com/corentings/chess/v2/game.go:522.2,525.18 2 1 -github.com/corentings/chess/v2/game.go:525.18,527.3 1 1 -github.com/corentings/chess/v2/game.go:527.8,528.30 1 1 -github.com/corentings/chess/v2/game.go:528.30,530.4 1 0 -github.com/corentings/chess/v2/game.go:531.3,531.33 1 1 -github.com/corentings/chess/v2/game.go:534.2,543.61 4 1 -github.com/corentings/chess/v2/game.go:543.61,545.3 1 1 -github.com/corentings/chess/v2/game.go:548.2,550.35 2 1 -github.com/corentings/chess/v2/game.go:550.35,553.14 3 1 -github.com/corentings/chess/v2/game.go:553.14,557.4 2 1 -github.com/corentings/chess/v2/game.go:557.9,561.4 2 1 -github.com/corentings/chess/v2/game.go:562.3,563.10 1 1 -github.com/corentings/chess/v2/game.go:566.2,566.22 1 1 -github.com/corentings/chess/v2/game.go:571.3,572.21 1 1 -github.com/corentings/chess/v2/game.go:572.21,574.3 1 1 -github.com/corentings/chess/v2/game.go:575.2,575.13 1 1 -github.com/corentings/chess/v2/game.go:575.13,577.3 1 1 -github.com/corentings/chess/v2/game.go:577.8,577.54 1 1 -github.com/corentings/chess/v2/game.go:577.54,579.3 1 1 -github.com/corentings/chess/v2/game.go:582.95,583.42 1 1 -github.com/corentings/chess/v2/game.go:583.42,586.3 2 1 -github.com/corentings/chess/v2/game.go:586.8,588.3 1 1 -github.com/corentings/chess/v2/game.go:591.61,593.28 2 1 -github.com/corentings/chess/v2/game.go:593.28,595.3 1 1 -github.com/corentings/chess/v2/game.go:596.2,597.13 2 1 -github.com/corentings/chess/v2/game.go:600.56,601.17 1 1 -github.com/corentings/chess/v2/game.go:601.17,603.3 1 1 -github.com/corentings/chess/v2/game.go:605.2,606.33 2 1 -github.com/corentings/chess/v2/game.go:606.33,609.3 2 1 -github.com/corentings/chess/v2/game.go:612.69,613.31 1 1 -github.com/corentings/chess/v2/game.go:613.31,614.28 1 1 -github.com/corentings/chess/v2/game.go:614.28,615.12 1 1 -github.com/corentings/chess/v2/game.go:618.3,619.36 2 1 -github.com/corentings/chess/v2/game.go:619.36,620.21 1 1 -github.com/corentings/chess/v2/game.go:621.21,622.30 1 1 -github.com/corentings/chess/v2/game.go:623.24,624.34 1 1 -github.com/corentings/chess/v2/game.go:624.34,626.6 1 1 -github.com/corentings/chess/v2/game.go:627.5,631.24 5 1 -github.com/corentings/chess/v2/game.go:634.3,634.22 1 1 -github.com/corentings/chess/v2/game.go:638.54,641.2 2 1 -github.com/corentings/chess/v2/game.go:643.87,646.28 2 1 -github.com/corentings/chess/v2/game.go:646.28,647.43 1 1 -github.com/corentings/chess/v2/game.go:647.43,648.26 1 1 -github.com/corentings/chess/v2/game.go:648.26,650.5 1 1 -github.com/corentings/chess/v2/game.go:651.4,656.23 5 1 -github.com/corentings/chess/v2/game.go:660.2,660.27 1 1 -github.com/corentings/chess/v2/game.go:665.46,667.2 1 0 -github.com/corentings/chess/v2/game.go:671.49,675.16 3 0 -github.com/corentings/chess/v2/game.go:675.16,677.3 1 0 -github.com/corentings/chess/v2/game.go:678.2,680.12 2 0 -github.com/corentings/chess/v2/game.go:686.42,690.16 3 1 -github.com/corentings/chess/v2/game.go:691.27,692.68 1 1 -github.com/corentings/chess/v2/game.go:692.68,694.4 1 1 -github.com/corentings/chess/v2/game.go:695.21,696.58 1 1 -github.com/corentings/chess/v2/game.go:696.58,698.4 1 1 -github.com/corentings/chess/v2/game.go:699.17,699.17 0 0 -github.com/corentings/chess/v2/game.go:700.10,701.50 1 0 -github.com/corentings/chess/v2/game.go:703.2,705.12 3 1 -github.com/corentings/chess/v2/game.go:710.36,711.48 1 1 -github.com/corentings/chess/v2/game.go:711.48,713.3 1 1 -github.com/corentings/chess/v2/game.go:714.2,714.20 1 1 -github.com/corentings/chess/v2/game.go:714.20,716.3 1 1 -github.com/corentings/chess/v2/game.go:716.8,718.3 1 1 -github.com/corentings/chess/v2/game.go:719.2,719.24 1 1 -github.com/corentings/chess/v2/game.go:723.41,728.68 4 1 -github.com/corentings/chess/v2/game.go:728.68,730.3 1 1 -github.com/corentings/chess/v2/game.go:731.2,731.58 1 1 -github.com/corentings/chess/v2/game.go:731.58,733.3 1 1 -github.com/corentings/chess/v2/game.go:734.2,734.14 1 1 -github.com/corentings/chess/v2/game.go:739.45,740.23 1 1 -github.com/corentings/chess/v2/game.go:740.23,742.3 1 1 -github.com/corentings/chess/v2/game.go:743.2,743.44 1 1 -github.com/corentings/chess/v2/game.go:743.44,746.3 2 1 -github.com/corentings/chess/v2/game.go:747.2,748.14 2 1 -github.com/corentings/chess/v2/game.go:753.44,755.2 1 1 -github.com/corentings/chess/v2/game.go:759.45,760.44 1 1 -github.com/corentings/chess/v2/game.go:760.44,763.3 2 1 -github.com/corentings/chess/v2/game.go:765.2,765.14 1 1 -github.com/corentings/chess/v2/game.go:769.41,771.16 2 1 -github.com/corentings/chess/v2/game.go:772.17,774.19 2 1 -github.com/corentings/chess/v2/game.go:775.17,778.28 3 1 -github.com/corentings/chess/v2/game.go:778.28,780.4 1 1 -github.com/corentings/chess/v2/game.go:782.2,782.28 1 1 -github.com/corentings/chess/v2/game.go:782.28,784.3 1 1 -github.com/corentings/chess/v2/game.go:787.2,787.66 1 1 -github.com/corentings/chess/v2/game.go:787.66,790.3 2 1 -github.com/corentings/chess/v2/game.go:793.2,793.93 1 1 -github.com/corentings/chess/v2/game.go:793.93,796.3 2 1 -github.com/corentings/chess/v2/game.go:799.2,799.79 1 1 -github.com/corentings/chess/v2/game.go:799.79,802.3 2 1 -github.com/corentings/chess/v2/game.go:806.33,808.34 2 1 -github.com/corentings/chess/v2/game.go:808.34,810.3 1 1 -github.com/corentings/chess/v2/game.go:811.2,819.72 9 1 -github.com/corentings/chess/v2/game.go:823.30,831.26 5 1 -github.com/corentings/chess/v2/game.go:831.26,833.3 1 0 -github.com/corentings/chess/v2/game.go:833.8,835.29 2 1 -github.com/corentings/chess/v2/game.go:835.29,837.4 1 0 -github.com/corentings/chess/v2/game.go:839.2,841.12 2 1 -github.com/corentings/chess/v2/game.go:844.58,845.54 1 1 -github.com/corentings/chess/v2/game.go:845.54,847.3 1 0 -github.com/corentings/chess/v2/game.go:848.2,848.24 1 1 -github.com/corentings/chess/v2/game.go:848.24,850.3 1 1 -github.com/corentings/chess/v2/game.go:851.2,851.42 1 1 -github.com/corentings/chess/v2/game.go:851.42,852.31 1 1 -github.com/corentings/chess/v2/game.go:852.31,854.4 1 0 -github.com/corentings/chess/v2/game.go:855.3,855.78 1 1 -github.com/corentings/chess/v2/game.go:855.78,857.4 1 1 -github.com/corentings/chess/v2/game.go:859.2,859.12 1 0 -github.com/corentings/chess/v2/game.go:864.40,868.21 3 1 -github.com/corentings/chess/v2/game.go:868.21,869.30 1 1 -github.com/corentings/chess/v2/game.go:869.30,871.4 1 1 -github.com/corentings/chess/v2/game.go:872.3,872.33 1 1 -github.com/corentings/chess/v2/game.go:872.33,873.9 1 1 -github.com/corentings/chess/v2/game.go:875.3,875.32 1 1 -github.com/corentings/chess/v2/game.go:878.2,878.18 1 1 -github.com/corentings/chess/v2/game.go:881.39,883.36 2 1 -github.com/corentings/chess/v2/game.go:883.36,884.17 1 1 -github.com/corentings/chess/v2/game.go:884.17,885.12 1 0 -github.com/corentings/chess/v2/game.go:887.3,887.30 1 1 -github.com/corentings/chess/v2/game.go:887.30,889.4 1 1 -github.com/corentings/chess/v2/game.go:891.2,891.14 1 1 -github.com/corentings/chess/v2/game.go:909.79,911.2 1 1 -github.com/corentings/chess/v2/game.go:926.100,928.16 2 1 -github.com/corentings/chess/v2/game.go:928.16,930.3 1 1 -github.com/corentings/chess/v2/game.go:932.2,932.30 1 1 -github.com/corentings/chess/v2/game.go:947.106,949.16 2 1 -github.com/corentings/chess/v2/game.go:949.16,951.3 1 1 -github.com/corentings/chess/v2/game.go:953.2,953.36 1 1 -github.com/corentings/chess/v2/game.go:969.65,970.20 1 1 -github.com/corentings/chess/v2/game.go:970.20,972.3 1 1 -github.com/corentings/chess/v2/game.go:975.2,975.45 1 1 -github.com/corentings/chess/v2/game.go:975.45,977.3 1 1 -github.com/corentings/chess/v2/game.go:979.2,979.39 1 1 -github.com/corentings/chess/v2/game.go:996.71,997.20 1 1 -github.com/corentings/chess/v2/game.go:997.20,999.3 1 1 -github.com/corentings/chess/v2/game.go:1001.2,1001.39 1 1 -github.com/corentings/chess/v2/game.go:1006.74,1007.17 1 1 -github.com/corentings/chess/v2/game.go:1007.17,1009.3 1 1 -github.com/corentings/chess/v2/game.go:1011.2,1019.12 6 1 -github.com/corentings/chess/v2/game.go:1024.47,1025.17 1 1 -github.com/corentings/chess/v2/game.go:1025.17,1027.3 1 1 -github.com/corentings/chess/v2/game.go:1029.2,1029.18 1 1 -github.com/corentings/chess/v2/game.go:1029.18,1031.3 1 0 -github.com/corentings/chess/v2/game.go:1034.2,1035.39 2 1 -github.com/corentings/chess/v2/game.go:1035.39,1036.90 1 1 -github.com/corentings/chess/v2/game.go:1036.90,1038.4 1 1 -github.com/corentings/chess/v2/game.go:1041.2,1041.83 1 1 -github.com/corentings/chess/v2/game.go:1044.51,1045.26 1 1 -github.com/corentings/chess/v2/game.go:1045.26,1047.3 1 0 -github.com/corentings/chess/v2/game.go:1048.2,1048.47 1 1 -github.com/corentings/chess/v2/game.go:1048.47,1049.78 1 1 -github.com/corentings/chess/v2/game.go:1049.78,1051.4 1 1 -github.com/corentings/chess/v2/game.go:1053.2,1053.12 1 1 -github.com/corentings/chess/v2/game.go:1056.79,1059.25 2 1 -github.com/corentings/chess/v2/game.go:1059.25,1060.65 1 1 -github.com/corentings/chess/v2/game.go:1060.65,1062.4 1 0 -github.com/corentings/chess/v2/game.go:1063.8,1065.3 1 1 -github.com/corentings/chess/v2/game.go:1068.47,1070.33 2 0 -github.com/corentings/chess/v2/game.go:1070.33,1071.20 1 0 -github.com/corentings/chess/v2/game.go:1071.20,1074.9 3 0 -github.com/corentings/chess/v2/game.go:1079.59,1080.19 1 1 -github.com/corentings/chess/v2/game.go:1080.19,1082.3 1 1 -github.com/corentings/chess/v2/game.go:1082.8,1084.3 1 1 -github.com/corentings/chess/v2/game.go:1087.43,1088.49 1 1 -github.com/corentings/chess/v2/game.go:1088.49,1091.3 2 1 -github.com/corentings/chess/v2/game.go:1097.32,1100.40 2 1 -github.com/corentings/chess/v2/game.go:1100.40,1102.3 1 1 -github.com/corentings/chess/v2/game.go:1105.2,1106.29 2 1 -github.com/corentings/chess/v2/game.go:1106.29,1109.3 2 1 -github.com/corentings/chess/v2/game.go:1111.2,1111.14 1 1 -github.com/corentings/chess/v2/game.go:1117.41,1118.17 1 1 -github.com/corentings/chess/v2/game.go:1118.17,1120.3 1 0 -github.com/corentings/chess/v2/game.go:1122.2,1122.29 1 1 -github.com/corentings/chess/v2/game.go:1122.29,1124.3 1 1 -github.com/corentings/chess/v2/game.go:1126.2,1127.34 2 1 -github.com/corentings/chess/v2/game.go:1127.34,1129.32 2 1 -github.com/corentings/chess/v2/game.go:1129.32,1132.4 2 1 -github.com/corentings/chess/v2/game.go:1134.2,1134.14 1 1 -github.com/corentings/chess/v2/game.go:1137.57,1141.25 3 1 -github.com/corentings/chess/v2/game.go:1141.25,1147.3 4 1 -github.com/corentings/chess/v2/game.go:1149.2,1155.13 6 1 -github.com/corentings/chess/v2/game.go:1161.34,1164.2 2 1 -github.com/corentings/chess/v2/game.go:1169.49,1170.23 1 1 -github.com/corentings/chess/v2/game.go:1170.23,1172.3 1 1 -github.com/corentings/chess/v2/game.go:1178.50,1179.23 1 1 -github.com/corentings/chess/v2/game.go:1179.23,1181.3 1 1 -github.com/corentings/chess/v2/game.go:1187.51,1188.23 1 1 -github.com/corentings/chess/v2/game.go:1188.23,1190.3 1 1 -github.com/corentings/chess/v2/hashes.go:8.63,10.40 2 1 -github.com/corentings/chess/v2/hashes.go:10.40,12.17 2 1 -github.com/corentings/chess/v2/hashes.go:12.17,13.70 1 0 -github.com/corentings/chess/v2/hashes.go:15.3,15.24 1 1 -github.com/corentings/chess/v2/hashes.go:17.2,17.15 1 1 -github.com/corentings/chess/v2/hashes.go:21.45,22.52 1 1 -github.com/corentings/chess/v2/hashes.go:22.52,24.3 1 0 -github.com/corentings/chess/v2/hashes.go:25.2,25.38 1 1 -github.com/corentings/chess/v2/hashes.go:29.35,31.2 1 0 -github.com/corentings/chess/v2/lexer.go:66.36,101.35 2 1 -github.com/corentings/chess/v2/lexer.go:101.35,103.3 1 1 -github.com/corentings/chess/v2/lexer.go:105.2,105.17 1 1 -github.com/corentings/chess/v2/lexer.go:134.36,138.2 3 1 -github.com/corentings/chess/v2/lexer.go:140.33,141.36 1 1 -github.com/corentings/chess/v2/lexer.go:141.36,143.3 1 0 -github.com/corentings/chess/v2/lexer.go:144.2,144.32 1 1 -github.com/corentings/chess/v2/lexer.go:147.34,148.66 1 1 -github.com/corentings/chess/v2/lexer.go:148.66,150.3 1 1 -github.com/corentings/chess/v2/lexer.go:153.36,155.20 2 1 -github.com/corentings/chess/v2/lexer.go:155.20,157.3 1 1 -github.com/corentings/chess/v2/lexer.go:158.2,158.69 1 1 -github.com/corentings/chess/v2/lexer.go:161.41,164.27 2 1 -github.com/corentings/chess/v2/lexer.go:164.27,166.3 1 1 -github.com/corentings/chess/v2/lexer.go:167.2,168.70 2 1 -github.com/corentings/chess/v2/lexer.go:171.42,175.15 2 1 -github.com/corentings/chess/v2/lexer.go:175.15,177.3 1 1 -github.com/corentings/chess/v2/lexer.go:179.2,180.17 2 1 -github.com/corentings/chess/v2/lexer.go:180.17,184.32 3 1 -github.com/corentings/chess/v2/lexer.go:184.32,186.4 1 1 -github.com/corentings/chess/v2/lexer.go:187.3,187.16 1 1 -github.com/corentings/chess/v2/lexer.go:187.16,189.4 1 1 -github.com/corentings/chess/v2/lexer.go:190.3,192.49 3 1 -github.com/corentings/chess/v2/lexer.go:197.2,197.61 1 1 -github.com/corentings/chess/v2/lexer.go:197.61,199.3 1 1 -github.com/corentings/chess/v2/lexer.go:201.2,201.32 1 1 -github.com/corentings/chess/v2/lexer.go:201.32,203.3 1 0 -github.com/corentings/chess/v2/lexer.go:205.2,206.90 2 1 -github.com/corentings/chess/v2/lexer.go:209.33,213.32 1 1 -github.com/corentings/chess/v2/lexer.go:213.32,218.33 3 1 -github.com/corentings/chess/v2/lexer.go:218.33,221.4 2 1 -github.com/corentings/chess/v2/lexer.go:223.3,223.40 1 1 -github.com/corentings/chess/v2/lexer.go:225.2,229.20 3 1 -github.com/corentings/chess/v2/lexer.go:229.20,231.3 1 1 -github.com/corentings/chess/v2/lexer.go:234.2,237.3 1 1 -github.com/corentings/chess/v2/lexer.go:240.36,242.39 2 1 -github.com/corentings/chess/v2/lexer.go:242.39,244.3 1 1 -github.com/corentings/chess/v2/lexer.go:245.2,246.22 2 1 -github.com/corentings/chess/v2/lexer.go:246.22,248.3 1 1 -github.com/corentings/chess/v2/lexer.go:249.2,249.47 1 1 -github.com/corentings/chess/v2/lexer.go:252.34,254.19 2 1 -github.com/corentings/chess/v2/lexer.go:254.19,257.3 2 0 -github.com/corentings/chess/v2/lexer.go:258.2,259.39 2 1 -github.com/corentings/chess/v2/lexer.go:262.37,266.31 2 1 -github.com/corentings/chess/v2/lexer.go:266.31,267.41 1 1 -github.com/corentings/chess/v2/lexer.go:267.41,268.30 1 1 -github.com/corentings/chess/v2/lexer.go:268.30,271.5 1 1 -github.com/corentings/chess/v2/lexer.go:273.4,277.17 4 1 -github.com/corentings/chess/v2/lexer.go:277.17,282.5 1 0 -github.com/corentings/chess/v2/lexer.go:283.4,283.49 1 1 -github.com/corentings/chess/v2/lexer.go:285.3,285.15 1 1 -github.com/corentings/chess/v2/lexer.go:289.2,289.15 1 1 -github.com/corentings/chess/v2/lexer.go:289.15,295.3 2 0 -github.com/corentings/chess/v2/lexer.go:298.2,298.28 1 1 -github.com/corentings/chess/v2/lexer.go:298.28,300.3 1 1 -github.com/corentings/chess/v2/lexer.go:302.2,302.44 1 0 -github.com/corentings/chess/v2/lexer.go:306.39,309.20 2 1 -github.com/corentings/chess/v2/lexer.go:309.20,312.3 2 0 -github.com/corentings/chess/v2/lexer.go:313.2,316.41 2 1 -github.com/corentings/chess/v2/lexer.go:319.34,326.15 4 1 -github.com/corentings/chess/v2/lexer.go:326.15,328.3 1 0 -github.com/corentings/chess/v2/lexer.go:331.2,331.18 1 1 -github.com/corentings/chess/v2/lexer.go:331.18,336.18 3 1 -github.com/corentings/chess/v2/lexer.go:336.18,338.4 1 1 -github.com/corentings/chess/v2/lexer.go:341.2,341.36 1 1 -github.com/corentings/chess/v2/lexer.go:341.36,345.16 2 1 -github.com/corentings/chess/v2/lexer.go:345.16,346.9 1 1 -github.com/corentings/chess/v2/lexer.go:351.2,356.63 2 1 -github.com/corentings/chess/v2/lexer.go:356.63,362.3 4 1 -github.com/corentings/chess/v2/lexer.go:366.2,369.73 1 1 -github.com/corentings/chess/v2/lexer.go:369.73,371.3 1 1 -github.com/corentings/chess/v2/lexer.go:374.2,374.36 1 1 -github.com/corentings/chess/v2/lexer.go:374.36,379.3 3 1 -github.com/corentings/chess/v2/lexer.go:382.2,382.109 1 1 -github.com/corentings/chess/v2/lexer.go:382.109,385.3 2 1 -github.com/corentings/chess/v2/lexer.go:387.2,387.65 1 1 -github.com/corentings/chess/v2/lexer.go:390.44,392.20 2 1 -github.com/corentings/chess/v2/lexer.go:392.20,395.3 2 0 -github.com/corentings/chess/v2/lexer.go:396.2,397.50 2 1 -github.com/corentings/chess/v2/lexer.go:400.28,401.36 1 1 -github.com/corentings/chess/v2/lexer.go:401.36,403.3 1 1 -github.com/corentings/chess/v2/lexer.go:403.8,405.3 1 1 -github.com/corentings/chess/v2/lexer.go:406.2,407.18 2 1 -github.com/corentings/chess/v2/lexer.go:410.38,413.31 3 1 -github.com/corentings/chess/v2/lexer.go:413.31,414.19 1 1 -github.com/corentings/chess/v2/lexer.go:414.19,416.35 2 1 -github.com/corentings/chess/v2/lexer.go:416.35,420.13 4 1 -github.com/corentings/chess/v2/lexer.go:423.3,424.15 2 1 -github.com/corentings/chess/v2/lexer.go:426.2,427.53 2 1 -github.com/corentings/chess/v2/lexer.go:430.36,432.38 2 1 -github.com/corentings/chess/v2/lexer.go:432.38,434.3 1 1 -github.com/corentings/chess/v2/lexer.go:435.2,435.65 1 1 -github.com/corentings/chess/v2/lexer.go:438.46,442.17 2 1 -github.com/corentings/chess/v2/lexer.go:442.17,444.3 1 0 -github.com/corentings/chess/v2/lexer.go:447.2,447.34 1 1 -github.com/corentings/chess/v2/lexer.go:447.34,449.3 1 0 -github.com/corentings/chess/v2/lexer.go:452.2,452.25 1 1 -github.com/corentings/chess/v2/lexer.go:452.25,454.3 1 0 -github.com/corentings/chess/v2/lexer.go:455.2,458.17 3 1 -github.com/corentings/chess/v2/lexer.go:458.17,464.3 4 0 -github.com/corentings/chess/v2/lexer.go:465.2,468.40 2 1 -github.com/corentings/chess/v2/lexer.go:468.40,472.3 3 1 -github.com/corentings/chess/v2/lexer.go:474.2,474.56 1 1 -github.com/corentings/chess/v2/lexer.go:496.35,499.17 2 1 -github.com/corentings/chess/v2/lexer.go:499.17,500.15 1 1 -github.com/corentings/chess/v2/lexer.go:501.12,504.46 3 1 -github.com/corentings/chess/v2/lexer.go:505.12,507.31 2 1 -github.com/corentings/chess/v2/lexer.go:508.11,510.24 1 1 -github.com/corentings/chess/v2/lexer.go:510.24,512.5 1 1 -github.com/corentings/chess/v2/lexer.go:513.4,513.30 1 1 -github.com/corentings/chess/v2/lexer.go:517.2,517.17 1 1 -github.com/corentings/chess/v2/lexer.go:517.17,518.18 1 1 -github.com/corentings/chess/v2/lexer.go:518.18,522.4 3 1 -github.com/corentings/chess/v2/lexer.go:523.3,523.25 1 1 -github.com/corentings/chess/v2/lexer.go:526.2,526.31 1 1 -github.com/corentings/chess/v2/lexer.go:526.31,528.3 1 1 -github.com/corentings/chess/v2/lexer.go:530.2,530.14 1 1 -github.com/corentings/chess/v2/lexer.go:531.11,533.49 2 1 -github.com/corentings/chess/v2/lexer.go:535.11,537.47 2 1 -github.com/corentings/chess/v2/lexer.go:538.11,541.43 3 1 -github.com/corentings/chess/v2/lexer.go:542.11,545.41 3 1 -github.com/corentings/chess/v2/lexer.go:546.11,547.26 1 1 -github.com/corentings/chess/v2/lexer.go:548.11,551.47 3 1 -github.com/corentings/chess/v2/lexer.go:552.11,554.45 2 0 -github.com/corentings/chess/v2/lexer.go:555.11,556.97 1 1 -github.com/corentings/chess/v2/lexer.go:556.97,561.4 4 1 -github.com/corentings/chess/v2/lexer.go:562.3,563.38 2 1 -github.com/corentings/chess/v2/lexer.go:564.11,566.42 2 1 -github.com/corentings/chess/v2/lexer.go:567.11,568.14 1 1 -github.com/corentings/chess/v2/lexer.go:569.11,570.24 1 1 -github.com/corentings/chess/v2/lexer.go:571.21,572.21 1 1 -github.com/corentings/chess/v2/lexer.go:573.11,575.56 1 1 -github.com/corentings/chess/v2/lexer.go:575.56,577.4 1 1 -github.com/corentings/chess/v2/lexer.go:579.3,579.27 1 0 -github.com/corentings/chess/v2/lexer.go:580.11,582.44 2 1 -github.com/corentings/chess/v2/lexer.go:583.11,585.40 2 1 -github.com/corentings/chess/v2/lexer.go:586.11,588.44 2 1 -github.com/corentings/chess/v2/lexer.go:589.56,590.14 1 1 -github.com/corentings/chess/v2/lexer.go:590.14,592.4 1 0 -github.com/corentings/chess/v2/lexer.go:595.3,595.69 1 1 -github.com/corentings/chess/v2/lexer.go:595.69,598.4 1 1 -github.com/corentings/chess/v2/lexer.go:601.3,602.34 2 1 -github.com/corentings/chess/v2/lexer.go:602.34,604.4 1 1 -github.com/corentings/chess/v2/lexer.go:605.3,605.15 1 1 -github.com/corentings/chess/v2/lexer.go:606.12,607.71 1 1 -github.com/corentings/chess/v2/lexer.go:608.12,612.25 4 1 -github.com/corentings/chess/v2/lexer.go:613.11,618.25 4 1 -github.com/corentings/chess/v2/lexer.go:620.9,621.37 1 1 -github.com/corentings/chess/v2/lexer.go:622.10,623.21 1 1 -github.com/corentings/chess/v2/lexer.go:623.21,624.35 1 1 -github.com/corentings/chess/v2/lexer.go:624.35,626.55 1 1 -github.com/corentings/chess/v2/lexer.go:626.55,628.6 1 1 -github.com/corentings/chess/v2/lexer.go:629.5,629.29 1 1 -github.com/corentings/chess/v2/lexer.go:631.4,631.23 1 1 -github.com/corentings/chess/v2/lexer.go:635.2,637.12 3 1 -github.com/corentings/chess/v2/move.go:66.32,68.2 1 1 -github.com/corentings/chess/v2/move.go:71.28,73.2 1 1 -github.com/corentings/chess/v2/move.go:76.28,78.2 1 1 -github.com/corentings/chess/v2/move.go:81.34,83.2 1 1 -github.com/corentings/chess/v2/move.go:86.41,88.2 1 1 -github.com/corentings/chess/v2/move.go:92.36,94.2 1 1 -github.com/corentings/chess/v2/move.go:96.54,97.49 1 1 -github.com/corentings/chess/v2/move.go:97.49,99.46 2 1 -github.com/corentings/chess/v2/move.go:99.46,101.54 2 1 -github.com/corentings/chess/v2/move.go:101.54,103.5 1 1 -github.com/corentings/chess/v2/move.go:107.2,107.22 1 1 -github.com/corentings/chess/v2/move.go:107.22,110.3 2 1 -github.com/corentings/chess/v2/move.go:111.2,112.18 2 1 -github.com/corentings/chess/v2/move.go:115.46,116.22 1 1 -github.com/corentings/chess/v2/move.go:116.22,118.3 1 1 -github.com/corentings/chess/v2/move.go:119.2,121.27 2 1 -github.com/corentings/chess/v2/move.go:121.27,124.3 2 1 -github.com/corentings/chess/v2/move.go:126.2,127.70 2 1 -github.com/corentings/chess/v2/move.go:127.70,128.84 1 1 -github.com/corentings/chess/v2/move.go:128.84,130.54 2 1 -github.com/corentings/chess/v2/move.go:130.54,133.5 2 1 -github.com/corentings/chess/v2/move.go:137.2,137.31 1 1 -github.com/corentings/chess/v2/move.go:137.31,139.3 1 0 -github.com/corentings/chess/v2/move.go:140.2,145.4 2 1 -github.com/corentings/chess/v2/move.go:148.43,153.40 5 1 -github.com/corentings/chess/v2/move.go:153.40,155.3 1 1 -github.com/corentings/chess/v2/move.go:158.43,159.27 1 1 -github.com/corentings/chess/v2/move.go:159.27,163.3 3 1 -github.com/corentings/chess/v2/move.go:165.2,166.31 2 1 -github.com/corentings/chess/v2/move.go:166.31,170.3 3 1 -github.com/corentings/chess/v2/move.go:172.2,173.66 2 1 -github.com/corentings/chess/v2/move.go:173.66,175.31 2 1 -github.com/corentings/chess/v2/move.go:175.31,179.4 3 1 -github.com/corentings/chess/v2/move.go:181.2,182.37 2 1 -github.com/corentings/chess/v2/move.go:185.34,186.30 1 1 -github.com/corentings/chess/v2/move.go:186.30,188.3 1 1 -github.com/corentings/chess/v2/move.go:189.2,189.19 1 1 -github.com/corentings/chess/v2/move.go:193.47,196.40 3 1 -github.com/corentings/chess/v2/move.go:196.40,198.3 1 1 -github.com/corentings/chess/v2/move.go:199.2,199.15 1 1 -github.com/corentings/chess/v2/move.go:202.29,204.2 1 1 -github.com/corentings/chess/v2/move.go:206.35,208.2 1 1 -github.com/corentings/chess/v2/move.go:210.31,212.2 1 1 -github.com/corentings/chess/v2/move.go:214.37,216.2 1 1 -github.com/corentings/chess/v2/move.go:218.35,220.2 1 1 -github.com/corentings/chess/v2/move.go:222.29,224.14 2 1 -github.com/corentings/chess/v2/move.go:224.14,226.3 1 1 -github.com/corentings/chess/v2/move.go:228.2,228.12 1 1 -github.com/corentings/chess/v2/move.go:232.37,234.2 1 1 -github.com/corentings/chess/v2/move.go:237.26,238.14 1 1 -github.com/corentings/chess/v2/move.go:238.14,240.3 1 1 -github.com/corentings/chess/v2/move.go:241.2,241.23 1 1 -github.com/corentings/chess/v2/move.go:241.23,243.3 1 1 -github.com/corentings/chess/v2/move.go:244.2,246.30 2 1 -github.com/corentings/chess/v2/move.go:246.30,249.3 1 1 -github.com/corentings/chess/v2/move.go:251.2,251.27 1 1 -github.com/corentings/chess/v2/move.go:260.30,276.30 15 1 -github.com/corentings/chess/v2/move.go:276.30,278.3 1 1 -github.com/corentings/chess/v2/move.go:280.2,280.12 1 1 -github.com/corentings/chess/v2/move.go:283.52,284.27 1 1 -github.com/corentings/chess/v2/move.go:284.27,286.3 1 1 -github.com/corentings/chess/v2/move.go:287.2,289.37 3 1 -github.com/corentings/chess/v2/move.go:292.38,294.2 1 1 -github.com/corentings/chess/v2/move.go:296.50,299.40 3 1 -github.com/corentings/chess/v2/move.go:299.40,300.36 1 1 -github.com/corentings/chess/v2/move.go:300.36,301.35 1 1 -github.com/corentings/chess/v2/move.go:301.35,303.5 1 1 -github.com/corentings/chess/v2/move.go:306.2,306.25 1 1 -github.com/corentings/chess/v2/move.go:306.25,308.3 1 1 -github.com/corentings/chess/v2/move.go:311.48,312.75 1 1 -github.com/corentings/chess/v2/move.go:312.75,314.3 1 1 -github.com/corentings/chess/v2/move.go:316.2,317.22 2 1 -github.com/corentings/chess/v2/move.go:317.22,319.3 1 1 -github.com/corentings/chess/v2/move.go:320.2,320.51 1 1 -github.com/corentings/chess/v2/move.go:320.51,322.3 1 1 -github.com/corentings/chess/v2/move.go:323.2,323.41 1 1 -github.com/corentings/chess/v2/move.go:326.44,328.2 1 1 -github.com/corentings/chess/v2/move.go:330.82,332.22 2 1 -github.com/corentings/chess/v2/move.go:332.22,334.3 1 1 -github.com/corentings/chess/v2/move.go:335.2,335.50 1 1 -github.com/corentings/chess/v2/move.go:335.50,337.3 1 1 -github.com/corentings/chess/v2/move.go:338.2,338.27 1 1 -github.com/corentings/chess/v2/move.go:338.27,341.3 2 1 -github.com/corentings/chess/v2/move.go:342.2,342.41 1 1 -github.com/corentings/chess/v2/move.go:345.55,347.31 2 1 -github.com/corentings/chess/v2/move.go:347.31,349.36 2 1 -github.com/corentings/chess/v2/move.go:349.36,350.32 1 1 -github.com/corentings/chess/v2/move.go:350.32,352.5 1 1 -github.com/corentings/chess/v2/move.go:354.3,354.26 1 1 -github.com/corentings/chess/v2/move.go:354.26,356.4 1 1 -github.com/corentings/chess/v2/move.go:358.2,358.36 1 1 -github.com/corentings/chess/v2/move.go:361.59,363.28 2 1 -github.com/corentings/chess/v2/move.go:363.28,365.3 1 1 -github.com/corentings/chess/v2/move.go:366.2,366.15 1 1 -github.com/corentings/chess/v2/move.go:369.51,370.27 1 1 -github.com/corentings/chess/v2/move.go:370.27,372.3 1 1 -github.com/corentings/chess/v2/move.go:374.2,374.36 1 1 -github.com/corentings/chess/v2/move.go:374.36,379.3 4 1 -github.com/corentings/chess/v2/notation.go:38.27,40.4 1 1 -github.com/corentings/chess/v2/notation.go:46.27,49.4 2 1 -github.com/corentings/chess/v2/notation.go:106.36,108.2 1 0 -github.com/corentings/chess/v2/notation.go:111.56,123.30 8 1 -github.com/corentings/chess/v2/notation.go:123.30,125.3 1 1 -github.com/corentings/chess/v2/notation.go:127.2,127.20 1 1 -github.com/corentings/chess/v2/notation.go:131.67,135.20 3 1 -github.com/corentings/chess/v2/notation.go:135.20,137.3 1 1 -github.com/corentings/chess/v2/notation.go:138.2,138.34 1 1 -github.com/corentings/chess/v2/notation.go:138.34,139.39 1 1 -github.com/corentings/chess/v2/notation.go:139.39,142.4 1 1 -github.com/corentings/chess/v2/notation.go:143.3,143.39 1 1 -github.com/corentings/chess/v2/notation.go:143.39,146.4 1 1 -github.com/corentings/chess/v2/notation.go:150.2,153.46 3 1 -github.com/corentings/chess/v2/notation.go:153.46,155.3 1 0 -github.com/corentings/chess/v2/notation.go:157.2,160.19 2 1 -github.com/corentings/chess/v2/notation.go:160.19,165.27 3 1 -github.com/corentings/chess/v2/notation.go:165.27,167.4 1 1 -github.com/corentings/chess/v2/notation.go:168.3,168.18 1 1 -github.com/corentings/chess/v2/notation.go:171.2,171.16 1 1 -github.com/corentings/chess/v2/notation.go:171.16,173.3 1 1 -github.com/corentings/chess/v2/notation.go:175.2,179.16 3 1 -github.com/corentings/chess/v2/notation.go:189.42,191.2 1 0 -github.com/corentings/chess/v2/notation.go:194.64,197.30 2 1 -github.com/corentings/chess/v2/notation.go:197.30,199.3 1 1 -github.com/corentings/chess/v2/notation.go:200.2,200.31 1 1 -github.com/corentings/chess/v2/notation.go:200.31,202.3 1 1 -github.com/corentings/chess/v2/notation.go:205.2,210.53 5 1 -github.com/corentings/chess/v2/notation.go:210.53,212.3 1 1 -github.com/corentings/chess/v2/notation.go:214.2,214.42 1 1 -github.com/corentings/chess/v2/notation.go:214.42,216.3 1 1 -github.com/corentings/chess/v2/notation.go:218.2,218.46 1 1 -github.com/corentings/chess/v2/notation.go:218.46,219.40 1 1 -github.com/corentings/chess/v2/notation.go:219.40,221.4 1 1 -github.com/corentings/chess/v2/notation.go:222.3,222.29 1 1 -github.com/corentings/chess/v2/notation.go:225.2,227.28 2 1 -github.com/corentings/chess/v2/notation.go:227.28,230.3 2 1 -github.com/corentings/chess/v2/notation.go:232.2,233.20 2 1 -github.com/corentings/chess/v2/notation.go:237.63,239.26 2 1 -github.com/corentings/chess/v2/notation.go:239.26,241.3 1 1 -github.com/corentings/chess/v2/notation.go:244.2,253.8 1 1 -github.com/corentings/chess/v2/notation.go:257.41,273.2 12 1 -github.com/corentings/chess/v2/notation.go:276.53,286.20 6 1 -github.com/corentings/chess/v2/notation.go:286.20,318.3 26 1 -github.com/corentings/chess/v2/notation.go:318.8,319.23 1 1 -github.com/corentings/chess/v2/notation.go:319.23,328.4 7 1 -github.com/corentings/chess/v2/notation.go:329.3,329.49 1 1 -github.com/corentings/chess/v2/notation.go:329.49,337.4 6 1 -github.com/corentings/chess/v2/notation.go:340.2,340.17 1 1 -github.com/corentings/chess/v2/notation.go:344.73,347.16 2 1 -github.com/corentings/chess/v2/notation.go:347.16,349.3 1 1 -github.com/corentings/chess/v2/notation.go:352.2,355.37 2 1 -github.com/corentings/chess/v2/notation.go:355.37,361.36 3 1 -github.com/corentings/chess/v2/notation.go:361.36,362.12 1 0 -github.com/corentings/chess/v2/notation.go:366.3,366.44 1 1 -github.com/corentings/chess/v2/notation.go:366.44,368.4 1 1 -github.com/corentings/chess/v2/notation.go:371.3,371.52 1 1 -github.com/corentings/chess/v2/notation.go:371.52,372.36 1 1 -github.com/corentings/chess/v2/notation.go:372.36,374.5 1 1 -github.com/corentings/chess/v2/notation.go:378.2,378.58 1 1 -github.com/corentings/chess/v2/notation.go:389.46,391.2 1 0 -github.com/corentings/chess/v2/notation.go:394.68,396.30 2 1 -github.com/corentings/chess/v2/notation.go:396.30,398.3 1 1 -github.com/corentings/chess/v2/notation.go:398.8,398.38 1 1 -github.com/corentings/chess/v2/notation.go:398.38,400.3 1 0 -github.com/corentings/chess/v2/notation.go:401.2,405.46 5 1 -github.com/corentings/chess/v2/notation.go:405.46,407.38 2 1 -github.com/corentings/chess/v2/notation.go:407.38,409.4 1 0 -github.com/corentings/chess/v2/notation.go:411.2,412.72 2 1 -github.com/corentings/chess/v2/notation.go:416.77,418.2 1 1 -github.com/corentings/chess/v2/notation.go:420.53,421.25 1 1 -github.com/corentings/chess/v2/notation.go:421.25,423.3 1 1 -github.com/corentings/chess/v2/notation.go:424.2,425.35 2 1 -github.com/corentings/chess/v2/notation.go:425.35,427.3 1 1 -github.com/corentings/chess/v2/notation.go:428.2,428.12 1 1 -github.com/corentings/chess/v2/notation.go:434.54,435.25 1 0 -github.com/corentings/chess/v2/notation.go:435.25,437.3 1 0 -github.com/corentings/chess/v2/notation.go:438.2,438.44 1 0 -github.com/corentings/chess/v2/notation.go:438.44,440.3 1 0 -github.com/corentings/chess/v2/notation.go:441.2,441.25 1 0 -github.com/corentings/chess/v2/notation.go:444.44,446.22 2 1 -github.com/corentings/chess/v2/notation.go:446.22,448.3 1 1 -github.com/corentings/chess/v2/notation.go:450.2,457.38 5 1 -github.com/corentings/chess/v2/notation.go:457.38,458.68 1 1 -github.com/corentings/chess/v2/notation.go:458.68,461.35 2 1 -github.com/corentings/chess/v2/notation.go:461.35,463.5 1 0 -github.com/corentings/chess/v2/notation.go:465.4,465.35 1 1 -github.com/corentings/chess/v2/notation.go:465.35,467.5 1 0 -github.com/corentings/chess/v2/notation.go:471.2,471.32 1 1 -github.com/corentings/chess/v2/notation.go:471.32,473.3 1 1 -github.com/corentings/chess/v2/notation.go:475.2,475.13 1 1 -github.com/corentings/chess/v2/notation.go:475.13,477.3 1 0 -github.com/corentings/chess/v2/notation.go:479.2,479.20 1 1 -github.com/corentings/chess/v2/notation.go:482.39,484.13 2 1 -github.com/corentings/chess/v2/notation.go:484.13,486.3 1 1 -github.com/corentings/chess/v2/notation.go:487.2,487.10 1 1 -github.com/corentings/chess/v2/notation.go:490.44,491.11 1 1 -github.com/corentings/chess/v2/notation.go:492.12,493.13 1 0 -github.com/corentings/chess/v2/notation.go:494.13,495.13 1 1 -github.com/corentings/chess/v2/notation.go:496.12,497.13 1 0 -github.com/corentings/chess/v2/notation.go:498.14,499.13 1 0 -github.com/corentings/chess/v2/notation.go:500.14,501.13 1 0 -github.com/corentings/chess/v2/notation.go:503.2,503.11 1 1 -github.com/corentings/chess/v2/notation.go:506.44,507.11 1 0 -github.com/corentings/chess/v2/notation.go:508.11,509.15 1 0 -github.com/corentings/chess/v2/notation.go:510.11,511.14 1 0 -github.com/corentings/chess/v2/notation.go:512.11,513.16 1 0 -github.com/corentings/chess/v2/notation.go:514.11,515.16 1 0 -github.com/corentings/chess/v2/notation.go:517.2,517.20 1 0 -github.com/corentings/chess/v2/pgn.go:38.40,52.2 2 1 -github.com/corentings/chess/v2/pgn.go:55.39,56.33 1 1 -github.com/corentings/chess/v2/pgn.go:56.33,58.3 1 1 -github.com/corentings/chess/v2/pgn.go:59.2,59.29 1 1 -github.com/corentings/chess/v2/pgn.go:63.28,65.2 1 1 -github.com/corentings/chess/v2/pgn.go:80.41,82.40 1 1 -github.com/corentings/chess/v2/pgn.go:82.40,84.3 1 0 -github.com/corentings/chess/v2/pgn.go:87.2,87.45 1 1 -github.com/corentings/chess/v2/pgn.go:87.45,89.17 2 1 -github.com/corentings/chess/v2/pgn.go:89.17,91.4 1 0 -github.com/corentings/chess/v2/pgn.go:92.3,93.19 2 1 -github.com/corentings/chess/v2/pgn.go:97.2,97.42 1 1 -github.com/corentings/chess/v2/pgn.go:97.42,99.3 1 0 -github.com/corentings/chess/v2/pgn.go:101.2,101.38 1 1 -github.com/corentings/chess/v2/pgn.go:101.38,103.3 1 1 -github.com/corentings/chess/v2/pgn.go:104.2,106.20 2 1 -github.com/corentings/chess/v2/pgn.go:109.38,110.40 1 1 -github.com/corentings/chess/v2/pgn.go:110.40,111.42 1 1 -github.com/corentings/chess/v2/pgn.go:111.42,113.4 1 0 -github.com/corentings/chess/v2/pgn.go:115.2,115.12 1 1 -github.com/corentings/chess/v2/pgn.go:118.39,120.39 1 1 -github.com/corentings/chess/v2/pgn.go:120.39,127.3 1 0 -github.com/corentings/chess/v2/pgn.go:128.2,131.37 2 1 -github.com/corentings/chess/v2/pgn.go:131.37,138.3 1 0 -github.com/corentings/chess/v2/pgn.go:139.2,143.39 3 1 -github.com/corentings/chess/v2/pgn.go:143.39,150.3 1 0 -github.com/corentings/chess/v2/pgn.go:151.2,155.37 3 1 -github.com/corentings/chess/v2/pgn.go:155.37,162.3 1 0 -github.com/corentings/chess/v2/pgn.go:163.2,167.12 3 1 -github.com/corentings/chess/v2/pgn.go:170.40,173.33 3 1 -github.com/corentings/chess/v2/pgn.go:173.33,176.21 2 1 -github.com/corentings/chess/v2/pgn.go:177.19,179.42 2 1 -github.com/corentings/chess/v2/pgn.go:179.42,182.5 2 1 -github.com/corentings/chess/v2/pgn.go:183.4,184.36 2 1 -github.com/corentings/chess/v2/pgn.go:184.36,186.5 1 1 -github.com/corentings/chess/v2/pgn.go:188.17,190.9 2 1 -github.com/corentings/chess/v2/pgn.go:192.61,194.18 2 1 -github.com/corentings/chess/v2/pgn.go:194.18,196.5 1 0 -github.com/corentings/chess/v2/pgn.go:197.4,197.22 1 1 -github.com/corentings/chess/v2/pgn.go:197.22,199.5 1 1 -github.com/corentings/chess/v2/pgn.go:200.4,205.8 3 1 -github.com/corentings/chess/v2/pgn.go:205.8,207.21 2 1 -github.com/corentings/chess/v2/pgn.go:208.14,210.17 2 1 -github.com/corentings/chess/v2/pgn.go:211.23,213.20 2 1 -github.com/corentings/chess/v2/pgn.go:213.20,215.7 1 0 -github.com/corentings/chess/v2/pgn.go:216.6,216.30 1 1 -github.com/corentings/chess/v2/pgn.go:216.30,218.7 1 1 -github.com/corentings/chess/v2/pgn.go:219.13,220.23 1 1 -github.com/corentings/chess/v2/pgn.go:224.21,226.18 2 1 -github.com/corentings/chess/v2/pgn.go:226.18,228.5 1 0 -github.com/corentings/chess/v2/pgn.go:229.4,229.28 1 1 -github.com/corentings/chess/v2/pgn.go:229.28,231.5 1 1 -github.com/corentings/chess/v2/pgn.go:233.23,234.60 1 1 -github.com/corentings/chess/v2/pgn.go:234.60,236.5 1 0 -github.com/corentings/chess/v2/pgn.go:238.15,240.14 2 1 -github.com/corentings/chess/v2/pgn.go:242.11,243.15 1 1 -github.com/corentings/chess/v2/pgn.go:246.2,246.12 1 1 -github.com/corentings/chess/v2/pgn.go:250.45,254.45 2 1 -github.com/corentings/chess/v2/pgn.go:254.45,256.45 2 1 -github.com/corentings/chess/v2/pgn.go:256.45,257.32 1 1 -github.com/corentings/chess/v2/pgn.go:257.32,261.24 4 1 -github.com/corentings/chess/v2/pgn.go:261.24,263.6 1 0 -github.com/corentings/chess/v2/pgn.go:264.5,265.21 2 1 -github.com/corentings/chess/v2/pgn.go:268.3,273.4 1 0 -github.com/corentings/chess/v2/pgn.go:276.2,276.46 1 1 -github.com/corentings/chess/v2/pgn.go:276.46,278.45 2 1 -github.com/corentings/chess/v2/pgn.go:278.45,279.33 1 1 -github.com/corentings/chess/v2/pgn.go:279.33,283.24 4 1 -github.com/corentings/chess/v2/pgn.go:283.24,285.6 1 1 -github.com/corentings/chess/v2/pgn.go:286.5,287.21 2 1 -github.com/corentings/chess/v2/pgn.go:290.3,295.4 1 0 -github.com/corentings/chess/v2/pgn.go:299.2,309.31 2 1 -github.com/corentings/chess/v2/pgn.go:310.13,315.36 3 1 -github.com/corentings/chess/v2/pgn.go:315.36,318.4 2 1 -github.com/corentings/chess/v2/pgn.go:318.9,318.43 1 1 -github.com/corentings/chess/v2/pgn.go:318.43,321.4 2 1 -github.com/corentings/chess/v2/pgn.go:321.9,321.58 1 1 -github.com/corentings/chess/v2/pgn.go:321.58,324.30 2 1 -github.com/corentings/chess/v2/pgn.go:324.30,327.5 2 1 -github.com/corentings/chess/v2/pgn.go:328.4,328.15 1 1 -github.com/corentings/chess/v2/pgn.go:331.12,333.14 2 1 -github.com/corentings/chess/v2/pgn.go:338.2,338.38 1 1 -github.com/corentings/chess/v2/pgn.go:338.38,341.3 2 1 -github.com/corentings/chess/v2/pgn.go:344.2,344.37 1 1 -github.com/corentings/chess/v2/pgn.go:344.37,351.3 1 0 -github.com/corentings/chess/v2/pgn.go:352.2,356.40 3 1 -github.com/corentings/chess/v2/pgn.go:356.40,358.46 2 1 -github.com/corentings/chess/v2/pgn.go:358.46,365.4 1 0 -github.com/corentings/chess/v2/pgn.go:366.3,367.14 2 1 -github.com/corentings/chess/v2/pgn.go:371.2,372.30 2 1 -github.com/corentings/chess/v2/pgn.go:372.30,379.3 1 0 -github.com/corentings/chess/v2/pgn.go:382.2,385.31 4 1 -github.com/corentings/chess/v2/pgn.go:385.31,387.29 1 1 -github.com/corentings/chess/v2/pgn.go:387.29,392.131 3 1 -github.com/corentings/chess/v2/pgn.go:392.131,399.13 2 1 -github.com/corentings/chess/v2/pgn.go:403.4,403.82 1 1 -github.com/corentings/chess/v2/pgn.go:403.82,410.13 2 1 -github.com/corentings/chess/v2/pgn.go:412.4,412.91 1 1 -github.com/corentings/chess/v2/pgn.go:412.91,419.13 2 1 -github.com/corentings/chess/v2/pgn.go:423.4,423.72 1 1 -github.com/corentings/chess/v2/pgn.go:423.72,430.13 2 0 -github.com/corentings/chess/v2/pgn.go:434.4,434.74 1 1 -github.com/corentings/chess/v2/pgn.go:434.74,441.13 2 1 -github.com/corentings/chess/v2/pgn.go:444.4,445.9 2 1 -github.com/corentings/chess/v2/pgn.go:449.2,449.25 1 1 -github.com/corentings/chess/v2/pgn.go:449.25,450.17 1 0 -github.com/corentings/chess/v2/pgn.go:450.17,455.4 1 0 -github.com/corentings/chess/v2/pgn.go:456.3,459.4 1 0 -github.com/corentings/chess/v2/pgn.go:463.2,470.36 6 1 -github.com/corentings/chess/v2/pgn.go:470.36,473.3 2 1 -github.com/corentings/chess/v2/pgn.go:476.2,476.34 1 1 -github.com/corentings/chess/v2/pgn.go:476.34,479.3 2 1 -github.com/corentings/chess/v2/pgn.go:482.2,482.53 1 1 -github.com/corentings/chess/v2/pgn.go:482.53,483.63 1 1 -github.com/corentings/chess/v2/pgn.go:483.63,485.4 1 1 -github.com/corentings/chess/v2/pgn.go:488.2,488.18 1 1 -github.com/corentings/chess/v2/pgn.go:491.55,496.72 3 1 -github.com/corentings/chess/v2/pgn.go:496.72,497.32 1 1 -github.com/corentings/chess/v2/pgn.go:498.21,500.18 2 1 -github.com/corentings/chess/v2/pgn.go:500.18,502.5 1 1 -github.com/corentings/chess/v2/pgn.go:503.4,503.46 1 1 -github.com/corentings/chess/v2/pgn.go:505.16,506.99 1 1 -github.com/corentings/chess/v2/pgn.go:507.11,513.5 1 1 -github.com/corentings/chess/v2/pgn.go:515.3,515.14 1 1 -github.com/corentings/chess/v2/pgn.go:518.2,518.33 1 1 -github.com/corentings/chess/v2/pgn.go:518.33,523.3 1 1 -github.com/corentings/chess/v2/pgn.go:525.2,526.19 2 1 -github.com/corentings/chess/v2/pgn.go:529.54,536.72 4 1 -github.com/corentings/chess/v2/pgn.go:536.72,537.32 1 1 -github.com/corentings/chess/v2/pgn.go:539.20,541.32 1 1 -github.com/corentings/chess/v2/pgn.go:542.21,544.32 1 1 -github.com/corentings/chess/v2/pgn.go:544.32,546.5 1 1 -github.com/corentings/chess/v2/pgn.go:547.11,553.5 1 1 -github.com/corentings/chess/v2/pgn.go:555.3,555.14 1 1 -github.com/corentings/chess/v2/pgn.go:558.2,558.33 1 1 -github.com/corentings/chess/v2/pgn.go:558.33,563.3 1 1 -github.com/corentings/chess/v2/pgn.go:566.2,566.71 1 1 -github.com/corentings/chess/v2/pgn.go:569.79,580.63 5 1 -github.com/corentings/chess/v2/pgn.go:580.63,582.78 2 1 -github.com/corentings/chess/v2/pgn.go:582.78,584.67 2 1 -github.com/corentings/chess/v2/pgn.go:584.67,586.5 1 1 -github.com/corentings/chess/v2/pgn.go:587.9,589.4 1 1 -github.com/corentings/chess/v2/pgn.go:590.8,592.3 1 1 -github.com/corentings/chess/v2/pgn.go:594.2,600.74 5 1 -github.com/corentings/chess/v2/pgn.go:600.74,601.32 1 1 -github.com/corentings/chess/v2/pgn.go:602.19,604.18 2 1 -github.com/corentings/chess/v2/pgn.go:604.18,607.5 2 1 -github.com/corentings/chess/v2/pgn.go:608.4,609.36 2 1 -github.com/corentings/chess/v2/pgn.go:609.36,612.5 2 1 -github.com/corentings/chess/v2/pgn.go:614.17,617.9 3 1 -github.com/corentings/chess/v2/pgn.go:619.23,620.60 1 1 -github.com/corentings/chess/v2/pgn.go:620.60,622.5 1 0 -github.com/corentings/chess/v2/pgn.go:624.21,626.18 2 1 -github.com/corentings/chess/v2/pgn.go:626.18,628.5 1 1 -github.com/corentings/chess/v2/pgn.go:629.4,629.28 1 0 -github.com/corentings/chess/v2/pgn.go:629.28,631.5 1 0 -github.com/corentings/chess/v2/pgn.go:633.12,635.15 2 0 -github.com/corentings/chess/v2/pgn.go:637.61,638.51 1 1 -github.com/corentings/chess/v2/pgn.go:638.51,643.5 1 1 -github.com/corentings/chess/v2/pgn.go:645.4,646.18 2 1 -github.com/corentings/chess/v2/pgn.go:646.18,648.5 1 0 -github.com/corentings/chess/v2/pgn.go:650.4,655.56 5 1 -github.com/corentings/chess/v2/pgn.go:655.56,657.5 1 1 -github.com/corentings/chess/v2/pgn.go:659.4,666.8 5 1 -github.com/corentings/chess/v2/pgn.go:666.8,668.21 2 1 -github.com/corentings/chess/v2/pgn.go:669.14,671.17 2 0 -github.com/corentings/chess/v2/pgn.go:672.23,674.20 2 1 -github.com/corentings/chess/v2/pgn.go:674.20,676.7 1 0 -github.com/corentings/chess/v2/pgn.go:677.6,677.30 1 1 -github.com/corentings/chess/v2/pgn.go:677.30,679.7 1 1 -github.com/corentings/chess/v2/pgn.go:680.13,681.39 1 1 -github.com/corentings/chess/v2/pgn.go:685.11,686.15 1 1 -github.com/corentings/chess/v2/pgn.go:690.2,690.33 1 1 -github.com/corentings/chess/v2/pgn.go:690.33,695.3 1 1 -github.com/corentings/chess/v2/pgn.go:697.2,703.12 5 1 -github.com/corentings/chess/v2/pgn.go:706.32,708.16 2 1 -github.com/corentings/chess/v2/pgn.go:709.13,710.28 1 1 -github.com/corentings/chess/v2/pgn.go:711.13,712.28 1 1 -github.com/corentings/chess/v2/pgn.go:713.17,714.24 1 1 -github.com/corentings/chess/v2/pgn.go:715.10,716.29 1 1 -github.com/corentings/chess/v2/pgn.go:718.2,718.13 1 1 -github.com/corentings/chess/v2/pgn.go:721.38,723.38 1 1 -github.com/corentings/chess/v2/pgn.go:723.38,726.3 2 1 -github.com/corentings/chess/v2/pgn.go:726.8,730.3 2 1 -github.com/corentings/chess/v2/pgn.go:733.2,733.54 1 1 -github.com/corentings/chess/v2/pgn.go:733.54,736.3 2 1 -github.com/corentings/chess/v2/pgn.go:739.2,741.22 2 1 -github.com/corentings/chess/v2/pgn.go:745.41,746.11 1 1 -github.com/corentings/chess/v2/pgn.go:747.11,748.14 1 0 -github.com/corentings/chess/v2/pgn.go:749.11,750.16 1 1 -github.com/corentings/chess/v2/pgn.go:751.11,752.16 1 1 -github.com/corentings/chess/v2/pgn.go:753.11,754.14 1 1 -github.com/corentings/chess/v2/pgn.go:755.11,756.15 1 1 -github.com/corentings/chess/v2/pgn.go:757.11,758.14 1 0 -github.com/corentings/chess/v2/pgn.go:759.10,760.21 1 0 -github.com/corentings/chess/v2/pgn.go:765.35,767.25 2 1 -github.com/corentings/chess/v2/pgn.go:767.25,769.3 1 0 -github.com/corentings/chess/v2/pgn.go:771.2,775.50 3 1 -github.com/corentings/chess/v2/pgn.go:775.50,777.3 1 0 -github.com/corentings/chess/v2/pgn.go:779.2,779.30 1 1 -github.com/corentings/chess/v2/piece.go:17.38,18.28 1 1 -github.com/corentings/chess/v2/piece.go:19.11,20.15 1 1 -github.com/corentings/chess/v2/piece.go:21.11,22.15 1 1 -github.com/corentings/chess/v2/piece.go:24.2,24.16 1 0 -github.com/corentings/chess/v2/piece.go:28.30,29.11 1 1 -github.com/corentings/chess/v2/piece.go:30.13,31.15 1 1 -github.com/corentings/chess/v2/piece.go:32.13,33.15 1 1 -github.com/corentings/chess/v2/piece.go:35.2,35.16 1 0 -github.com/corentings/chess/v2/piece.go:40.32,41.11 1 1 -github.com/corentings/chess/v2/piece.go:42.13,43.13 1 1 -github.com/corentings/chess/v2/piece.go:44.13,45.13 1 1 -github.com/corentings/chess/v2/piece.go:47.2,47.12 1 0 -github.com/corentings/chess/v2/piece.go:51.30,52.11 1 0 -github.com/corentings/chess/v2/piece.go:53.13,54.17 1 0 -github.com/corentings/chess/v2/piece.go:55.13,56.17 1 0 -github.com/corentings/chess/v2/piece.go:58.2,58.19 1 0 -github.com/corentings/chess/v2/piece.go:82.32,84.2 1 0 -github.com/corentings/chess/v2/piece.go:86.42,87.11 1 1 -github.com/corentings/chess/v2/piece.go:88.11,89.14 1 1 -github.com/corentings/chess/v2/piece.go:90.11,91.15 1 1 -github.com/corentings/chess/v2/piece.go:92.11,93.14 1 1 -github.com/corentings/chess/v2/piece.go:94.11,95.16 1 1 -github.com/corentings/chess/v2/piece.go:96.11,97.16 1 1 -github.com/corentings/chess/v2/piece.go:98.11,99.14 1 0 -github.com/corentings/chess/v2/piece.go:101.2,101.20 1 0 -github.com/corentings/chess/v2/piece.go:104.46,105.17 1 1 -github.com/corentings/chess/v2/piece.go:105.17,107.3 1 0 -github.com/corentings/chess/v2/piece.go:108.2,108.49 1 1 -github.com/corentings/chess/v2/piece.go:111.36,112.11 1 1 -github.com/corentings/chess/v2/piece.go:113.12,114.13 1 1 -github.com/corentings/chess/v2/piece.go:115.13,116.13 1 1 -github.com/corentings/chess/v2/piece.go:117.12,118.13 1 1 -github.com/corentings/chess/v2/piece.go:119.14,120.13 1 1 -github.com/corentings/chess/v2/piece.go:121.14,122.13 1 1 -github.com/corentings/chess/v2/piece.go:123.12,124.13 1 1 -github.com/corentings/chess/v2/piece.go:126.2,126.11 1 1 -github.com/corentings/chess/v2/piece.go:129.35,130.11 1 1 -github.com/corentings/chess/v2/piece.go:131.12,132.21 1 1 -github.com/corentings/chess/v2/piece.go:133.13,134.21 1 1 -github.com/corentings/chess/v2/piece.go:135.12,136.21 1 1 -github.com/corentings/chess/v2/piece.go:137.14,138.21 1 1 -github.com/corentings/chess/v2/piece.go:139.14,140.21 1 1 -github.com/corentings/chess/v2/piece.go:141.12,142.21 1 1 -github.com/corentings/chess/v2/piece.go:143.19,144.18 1 1 -github.com/corentings/chess/v2/piece.go:146.2,146.17 1 0 -github.com/corentings/chess/v2/piece.go:149.51,150.11 1 1 -github.com/corentings/chess/v2/piece.go:151.14,152.11 1 0 -github.com/corentings/chess/v2/piece.go:153.14,154.11 1 0 -github.com/corentings/chess/v2/piece.go:155.12,156.11 1 0 -github.com/corentings/chess/v2/piece.go:157.13,158.11 1 1 -github.com/corentings/chess/v2/piece.go:159.10,160.11 1 1 -github.com/corentings/chess/v2/piece.go:206.43,207.30 1 1 -github.com/corentings/chess/v2/piece.go:207.30,208.38 1 1 -github.com/corentings/chess/v2/piece.go:208.38,210.4 1 1 -github.com/corentings/chess/v2/piece.go:212.2,212.16 1 0 -github.com/corentings/chess/v2/piece.go:216.33,217.11 1 1 -github.com/corentings/chess/v2/piece.go:218.28,219.14 1 1 -github.com/corentings/chess/v2/piece.go:220.30,221.15 1 1 -github.com/corentings/chess/v2/piece.go:222.28,223.14 1 1 -github.com/corentings/chess/v2/piece.go:224.32,225.16 1 1 -github.com/corentings/chess/v2/piece.go:226.32,227.16 1 1 -github.com/corentings/chess/v2/piece.go:228.28,229.14 1 1 -github.com/corentings/chess/v2/piece.go:231.2,231.20 1 1 -github.com/corentings/chess/v2/piece.go:235.30,236.11 1 1 -github.com/corentings/chess/v2/piece.go:237.77,238.15 1 1 -github.com/corentings/chess/v2/piece.go:239.77,240.15 1 1 -github.com/corentings/chess/v2/piece.go:242.2,242.16 1 1 -github.com/corentings/chess/v2/piece.go:246.32,247.18 1 0 -github.com/corentings/chess/v2/piece.go:247.18,249.3 1 0 -github.com/corentings/chess/v2/piece.go:250.2,250.30 1 0 -github.com/corentings/chess/v2/piece.go:255.36,257.2 1 0 -github.com/corentings/chess/v2/piece.go:269.34,271.36 2 1 -github.com/corentings/chess/v2/piece.go:271.36,273.3 1 0 -github.com/corentings/chess/v2/piece.go:275.2,275.24 1 1 -github.com/corentings/chess/v2/piece.go:275.24,277.3 1 1 -github.com/corentings/chess/v2/piece.go:278.2,278.36 1 1 -github.com/corentings/chess/v2/polyglot.go:64.36,72.2 7 1 -github.com/corentings/chess/v2/polyglot.go:74.40,82.2 7 1 -github.com/corentings/chess/v2/polyglot.go:84.87,85.21 1 1 -github.com/corentings/chess/v2/polyglot.go:85.21,86.17 1 1 -github.com/corentings/chess/v2/polyglot.go:87.12,88.31 1 1 -github.com/corentings/chess/v2/polyglot.go:89.12,90.31 1 1 -github.com/corentings/chess/v2/polyglot.go:93.2,93.37 1 0 -github.com/corentings/chess/v2/polyglot.go:96.38,103.21 6 1 -github.com/corentings/chess/v2/polyglot.go:103.21,105.3 1 1 -github.com/corentings/chess/v2/polyglot.go:107.2,108.43 2 1 -github.com/corentings/chess/v2/polyglot.go:108.43,111.3 2 1 -github.com/corentings/chess/v2/polyglot.go:111.8,113.3 1 1 -github.com/corentings/chess/v2/polyglot.go:115.2,116.16 2 1 -github.com/corentings/chess/v2/polyglot.go:116.16,118.3 1 0 -github.com/corentings/chess/v2/polyglot.go:120.2,120.21 1 1 -github.com/corentings/chess/v2/polyglot.go:120.21,121.61 1 1 -github.com/corentings/chess/v2/polyglot.go:121.61,123.4 1 1 -github.com/corentings/chess/v2/polyglot.go:123.9,125.4 1 1 -github.com/corentings/chess/v2/polyglot.go:128.2,128.16 1 1 -github.com/corentings/chess/v2/polyglot.go:150.71,153.16 2 1 -github.com/corentings/chess/v2/polyglot.go:153.16,155.3 1 0 -github.com/corentings/chess/v2/polyglot.go:157.2,161.8 1 1 -github.com/corentings/chess/v2/polyglot.go:165.62,166.39 1 1 -github.com/corentings/chess/v2/polyglot.go:166.39,168.3 1 0 -github.com/corentings/chess/v2/polyglot.go:170.2,173.16 3 1 -github.com/corentings/chess/v2/polyglot.go:173.16,175.3 1 0 -github.com/corentings/chess/v2/polyglot.go:176.2,176.15 1 1 -github.com/corentings/chess/v2/polyglot.go:180.50,182.2 1 1 -github.com/corentings/chess/v2/polyglot.go:196.55,201.2 1 1 -github.com/corentings/chess/v2/polyglot.go:204.61,205.35 1 1 -github.com/corentings/chess/v2/polyglot.go:205.35,207.3 1 1 -github.com/corentings/chess/v2/polyglot.go:209.2,212.16 3 1 -github.com/corentings/chess/v2/polyglot.go:212.16,214.3 1 0 -github.com/corentings/chess/v2/polyglot.go:215.2,215.15 1 1 -github.com/corentings/chess/v2/polyglot.go:219.49,221.2 1 1 -github.com/corentings/chess/v2/polyglot.go:224.63,226.16 2 1 -github.com/corentings/chess/v2/polyglot.go:226.16,228.3 1 0 -github.com/corentings/chess/v2/polyglot.go:230.2,230.18 1 1 -github.com/corentings/chess/v2/polyglot.go:230.18,232.3 1 1 -github.com/corentings/chess/v2/polyglot.go:234.2,238.6 4 1 -github.com/corentings/chess/v2/polyglot.go:238.6,240.24 2 1 -github.com/corentings/chess/v2/polyglot.go:240.24,241.9 1 1 -github.com/corentings/chess/v2/polyglot.go:243.3,243.21 1 1 -github.com/corentings/chess/v2/polyglot.go:243.21,245.4 1 0 -github.com/corentings/chess/v2/polyglot.go:247.3,253.35 2 1 -github.com/corentings/chess/v2/polyglot.go:256.2,256.42 1 1 -github.com/corentings/chess/v2/polyglot.go:256.42,258.3 1 1 -github.com/corentings/chess/v2/polyglot.go:260.2,260.45 1 1 -github.com/corentings/chess/v2/polyglot.go:278.62,280.16 2 0 -github.com/corentings/chess/v2/polyglot.go:280.16,282.3 1 0 -github.com/corentings/chess/v2/polyglot.go:283.2,283.31 1 0 -github.com/corentings/chess/v2/polyglot.go:296.56,299.2 2 1 -github.com/corentings/chess/v2/polyglot.go:315.74,316.57 1 1 -github.com/corentings/chess/v2/polyglot.go:316.57,318.3 1 1 -github.com/corentings/chess/v2/polyglot.go:320.2,320.71 1 1 -github.com/corentings/chess/v2/polyglot.go:320.71,322.3 1 1 -github.com/corentings/chess/v2/polyglot.go:324.2,325.82 2 1 -github.com/corentings/chess/v2/polyglot.go:325.82,327.3 1 1 -github.com/corentings/chess/v2/polyglot.go:329.2,329.40 1 1 -github.com/corentings/chess/v2/polyglot.go:329.40,331.3 1 1 -github.com/corentings/chess/v2/polyglot.go:333.2,333.14 1 1 -github.com/corentings/chess/v2/polyglot.go:358.43,367.2 1 1 -github.com/corentings/chess/v2/polyglot.go:370.66,373.2 1 1 -github.com/corentings/chess/v2/polyglot.go:387.77,389.21 2 1 -github.com/corentings/chess/v2/polyglot.go:389.21,391.3 1 1 -github.com/corentings/chess/v2/polyglot.go:393.2,394.29 2 1 -github.com/corentings/chess/v2/polyglot.go:394.29,396.3 1 1 -github.com/corentings/chess/v2/polyglot.go:398.2,400.29 3 1 -github.com/corentings/chess/v2/polyglot.go:400.29,402.24 2 1 -github.com/corentings/chess/v2/polyglot.go:402.24,404.4 1 1 -github.com/corentings/chess/v2/polyglot.go:407.2,407.18 1 0 -github.com/corentings/chess/v2/polyglot.go:413.24,416.16 3 1 -github.com/corentings/chess/v2/polyglot.go:416.16,417.66 1 0 -github.com/corentings/chess/v2/polyglot.go:419.2,419.35 1 1 -github.com/corentings/chess/v2/polyglot.go:424.74,426.28 2 1 -github.com/corentings/chess/v2/polyglot.go:426.28,427.28 1 1 -github.com/corentings/chess/v2/polyglot.go:427.28,435.4 2 1 -github.com/corentings/chess/v2/polyglot.go:437.2,437.42 1 1 -github.com/corentings/chess/v2/polyglot.go:437.42,439.3 1 1 -github.com/corentings/chess/v2/polyglot.go:440.2,440.40 1 1 -github.com/corentings/chess/v2/polyglot.go:444.82,453.47 3 1 -github.com/corentings/chess/v2/polyglot.go:453.47,455.3 1 1 -github.com/corentings/chess/v2/polyglot.go:459.94,462.37 3 1 -github.com/corentings/chess/v2/polyglot.go:462.37,463.56 1 1 -github.com/corentings/chess/v2/polyglot.go:463.56,466.4 2 1 -github.com/corentings/chess/v2/polyglot.go:468.2,468.14 1 1 -github.com/corentings/chess/v2/polyglot.go:468.14,470.3 1 1 -github.com/corentings/chess/v2/polyglot.go:471.2,471.12 1 1 -github.com/corentings/chess/v2/polyglot.go:475.60,477.37 2 1 -github.com/corentings/chess/v2/polyglot.go:477.37,478.32 1 1 -github.com/corentings/chess/v2/polyglot.go:478.32,480.4 1 1 -github.com/corentings/chess/v2/polyglot.go:482.2,482.27 1 1 -github.com/corentings/chess/v2/polyglot.go:485.78,487.20 2 1 -github.com/corentings/chess/v2/polyglot.go:487.20,489.3 1 1 -github.com/corentings/chess/v2/polyglot.go:490.2,491.32 2 1 -github.com/corentings/chess/v2/polyglot.go:491.32,495.3 3 1 -github.com/corentings/chess/v2/polyglot.go:496.2,496.19 1 1 -github.com/corentings/chess/v2/polyglot.go:499.67,501.37 2 1 -github.com/corentings/chess/v2/polyglot.go:501.37,509.3 4 1 -github.com/corentings/chess/v2/polyglot.go:510.2,510.15 1 1 -github.com/corentings/chess/v2/position.go:51.59,53.23 2 1 -github.com/corentings/chess/v2/position.go:53.23,55.3 1 1 -github.com/corentings/chess/v2/position.go:56.2,56.16 1 1 -github.com/corentings/chess/v2/position.go:56.16,58.3 1 1 -github.com/corentings/chess/v2/position.go:59.2,59.43 1 1 -github.com/corentings/chess/v2/position.go:64.40,66.2 1 1 -github.com/corentings/chess/v2/position.go:88.35,91.2 2 1 -github.com/corentings/chess/v2/position.go:101.48,103.23 2 1 -github.com/corentings/chess/v2/position.go:103.23,105.3 1 1 -github.com/corentings/chess/v2/position.go:107.2,107.14 1 1 -github.com/corentings/chess/v2/position.go:107.14,117.3 1 1 -github.com/corentings/chess/v2/position.go:119.2,122.43 4 1 -github.com/corentings/chess/v2/position.go:122.43,124.3 1 1 -github.com/corentings/chess/v2/position.go:124.8,126.3 1 1 -github.com/corentings/chess/v2/position.go:127.2,137.3 3 1 -github.com/corentings/chess/v2/position.go:143.42,144.27 1 1 -github.com/corentings/chess/v2/position.go:144.27,146.3 1 1 -github.com/corentings/chess/v2/position.go:147.2,148.47 2 1 -github.com/corentings/chess/v2/position.go:153.43,155.2 1 1 -github.com/corentings/chess/v2/position.go:159.38,161.2 1 1 -github.com/corentings/chess/v2/position.go:164.37,166.2 1 1 -github.com/corentings/chess/v2/position.go:169.35,171.2 1 1 -github.com/corentings/chess/v2/position.go:174.45,177.2 2 0 -github.com/corentings/chess/v2/position.go:180.42,182.2 1 0 -github.com/corentings/chess/v2/position.go:185.47,187.2 1 0 -github.com/corentings/chess/v2/position.go:190.50,192.2 1 0 -github.com/corentings/chess/v2/position.go:195.32,196.16 1 1 -github.com/corentings/chess/v2/position.go:196.16,198.3 1 0 -github.com/corentings/chess/v2/position.go:199.2,199.24 1 1 -github.com/corentings/chess/v2/position.go:199.24,201.3 1 1 -github.com/corentings/chess/v2/position.go:203.2,203.23 1 1 -github.com/corentings/chess/v2/position.go:203.23,205.3 1 1 -github.com/corentings/chess/v2/position.go:205.8,207.3 1 1 -github.com/corentings/chess/v2/position.go:212.38,217.37 5 1 -github.com/corentings/chess/v2/position.go:217.37,219.3 1 1 -github.com/corentings/chess/v2/position.go:220.2,220.88 1 1 -github.com/corentings/chess/v2/position.go:225.42,230.37 5 1 -github.com/corentings/chess/v2/position.go:230.37,233.24 2 1 -github.com/corentings/chess/v2/position.go:233.24,235.4 1 1 -github.com/corentings/chess/v2/position.go:235.9,237.4 1 1 -github.com/corentings/chess/v2/position.go:239.3,242.40 3 1 -github.com/corentings/chess/v2/position.go:242.40,243.30 1 1 -github.com/corentings/chess/v2/position.go:243.30,244.13 1 1 -github.com/corentings/chess/v2/position.go:247.4,249.32 3 1 -github.com/corentings/chess/v2/position.go:249.32,250.13 1 1 -github.com/corentings/chess/v2/position.go:252.4,252.36 1 1 -github.com/corentings/chess/v2/position.go:252.36,253.13 1 0 -github.com/corentings/chess/v2/position.go:255.4,255.41 1 1 -github.com/corentings/chess/v2/position.go:255.41,257.10 2 1 -github.com/corentings/chess/v2/position.go:261.2,261.88 1 1 -github.com/corentings/chess/v2/position.go:265.38,268.2 2 1 -github.com/corentings/chess/v2/position.go:272.52,274.2 1 0 -github.com/corentings/chess/v2/position.go:278.55,280.16 2 0 -github.com/corentings/chess/v2/position.go:280.16,282.3 1 0 -github.com/corentings/chess/v2/position.go:283.2,290.12 8 0 -github.com/corentings/chess/v2/position.go:303.54,305.16 2 1 -github.com/corentings/chess/v2/position.go:305.16,307.3 1 0 -github.com/corentings/chess/v2/position.go:308.2,309.85 2 1 -github.com/corentings/chess/v2/position.go:309.85,311.3 1 0 -github.com/corentings/chess/v2/position.go:312.2,312.82 1 1 -github.com/corentings/chess/v2/position.go:312.82,314.3 1 0 -github.com/corentings/chess/v2/position.go:315.2,315.80 1 1 -github.com/corentings/chess/v2/position.go:315.80,317.3 1 0 -github.com/corentings/chess/v2/position.go:318.2,319.49 2 1 -github.com/corentings/chess/v2/position.go:319.49,321.3 1 1 -github.com/corentings/chess/v2/position.go:322.2,322.50 1 1 -github.com/corentings/chess/v2/position.go:322.50,324.3 1 1 -github.com/corentings/chess/v2/position.go:325.2,325.49 1 1 -github.com/corentings/chess/v2/position.go:325.49,327.3 1 1 -github.com/corentings/chess/v2/position.go:328.2,328.50 1 1 -github.com/corentings/chess/v2/position.go:328.50,330.3 1 1 -github.com/corentings/chess/v2/position.go:331.2,331.23 1 1 -github.com/corentings/chess/v2/position.go:331.23,333.3 1 1 -github.com/corentings/chess/v2/position.go:334.2,334.37 1 1 -github.com/corentings/chess/v2/position.go:334.37,336.3 1 1 -github.com/corentings/chess/v2/position.go:337.2,337.62 1 1 -github.com/corentings/chess/v2/position.go:337.62,339.3 1 0 -github.com/corentings/chess/v2/position.go:340.2,340.25 1 1 -github.com/corentings/chess/v2/position.go:344.57,346.23 2 1 -github.com/corentings/chess/v2/position.go:346.23,348.3 1 0 -github.com/corentings/chess/v2/position.go:349.2,350.57 2 1 -github.com/corentings/chess/v2/position.go:350.57,352.3 1 0 -github.com/corentings/chess/v2/position.go:353.2,356.70 4 1 -github.com/corentings/chess/v2/position.go:356.70,358.3 1 0 -github.com/corentings/chess/v2/position.go:359.2,361.71 3 1 -github.com/corentings/chess/v2/position.go:361.71,363.3 1 0 -github.com/corentings/chess/v2/position.go:364.2,365.81 2 1 -github.com/corentings/chess/v2/position.go:365.81,367.3 1 0 -github.com/corentings/chess/v2/position.go:368.2,369.63 2 1 -github.com/corentings/chess/v2/position.go:369.63,371.3 1 0 -github.com/corentings/chess/v2/position.go:372.2,374.32 3 1 -github.com/corentings/chess/v2/position.go:374.32,376.3 1 1 -github.com/corentings/chess/v2/position.go:377.2,377.33 1 1 -github.com/corentings/chess/v2/position.go:377.33,379.3 1 1 -github.com/corentings/chess/v2/position.go:380.2,380.32 1 1 -github.com/corentings/chess/v2/position.go:380.32,382.3 1 1 -github.com/corentings/chess/v2/position.go:383.2,383.33 1 1 -github.com/corentings/chess/v2/position.go:383.33,385.3 1 1 -github.com/corentings/chess/v2/position.go:386.2,386.28 1 1 -github.com/corentings/chess/v2/position.go:386.28,388.3 1 1 -github.com/corentings/chess/v2/position.go:389.2,389.21 1 1 -github.com/corentings/chess/v2/position.go:389.21,391.3 1 1 -github.com/corentings/chess/v2/position.go:392.2,392.29 1 1 -github.com/corentings/chess/v2/position.go:392.29,394.3 1 1 -github.com/corentings/chess/v2/position.go:395.2,396.12 2 1 -github.com/corentings/chess/v2/position.go:399.39,409.2 1 1 -github.com/corentings/chess/v2/position.go:411.63,414.48 3 1 -github.com/corentings/chess/v2/position.go:414.48,416.3 1 1 -github.com/corentings/chess/v2/position.go:417.2,417.48 1 1 -github.com/corentings/chess/v2/position.go:417.48,419.3 1 1 -github.com/corentings/chess/v2/position.go:420.2,420.48 1 1 -github.com/corentings/chess/v2/position.go:420.48,422.3 1 1 -github.com/corentings/chess/v2/position.go:423.2,423.48 1 1 -github.com/corentings/chess/v2/position.go:423.48,425.3 1 1 -github.com/corentings/chess/v2/position.go:426.2,426.14 1 1 -github.com/corentings/chess/v2/position.go:426.14,428.3 1 1 -github.com/corentings/chess/v2/position.go:429.2,429.25 1 1 -github.com/corentings/chess/v2/position.go:432.60,435.22 3 1 -github.com/corentings/chess/v2/position.go:435.22,437.3 1 1 -github.com/corentings/chess/v2/position.go:438.2,440.36 1 1 -github.com/corentings/chess/v2/position.go:440.36,442.3 1 1 -github.com/corentings/chess/v2/position.go:442.8,444.36 1 1 -github.com/corentings/chess/v2/position.go:444.36,446.3 1 1 -github.com/corentings/chess/v2/position.go:447.2,447.17 1 1 -github.com/corentings/chess/v2/position.go:455.56,460.2 1 1 -github.com/corentings/chess/v2/position.go:466.55,467.37 1 1 -github.com/corentings/chess/v2/position.go:467.37,469.3 1 1 -github.com/corentings/chess/v2/position.go:477.2,482.21 5 1 -github.com/corentings/chess/v2/position.go:482.21,485.3 2 1 -github.com/corentings/chess/v2/position.go:485.8,488.3 2 1 -github.com/corentings/chess/v2/position.go:491.2,491.20 1 1 -github.com/corentings/chess/v2/position.go:491.20,493.43 2 1 -github.com/corentings/chess/v2/position.go:493.43,495.4 1 1 -github.com/corentings/chess/v2/position.go:497.2,497.20 1 1 -github.com/corentings/chess/v2/position.go:497.20,499.43 2 1 -github.com/corentings/chess/v2/position.go:499.43,501.4 1 1 -github.com/corentings/chess/v2/position.go:504.2,504.17 1 1 -github.com/corentings/chess/v2/scanner.go:48.55,49.17 1 1 -github.com/corentings/chess/v2/scanner.go:49.17,51.3 1 0 -github.com/corentings/chess/v2/scanner.go:53.2,56.6 3 1 -github.com/corentings/chess/v2/scanner.go:56.6,58.24 2 1 -github.com/corentings/chess/v2/scanner.go:58.24,59.9 1 1 -github.com/corentings/chess/v2/scanner.go:61.3,61.33 1 1 -github.com/corentings/chess/v2/scanner.go:64.2,64.20 1 1 -github.com/corentings/chess/v2/scanner.go:83.43,84.26 1 1 -github.com/corentings/chess/v2/scanner.go:84.26,86.3 1 1 -github.com/corentings/chess/v2/scanner.go:100.62,109.27 4 1 -github.com/corentings/chess/v2/scanner.go:109.27,111.3 1 1 -github.com/corentings/chess/v2/scanner.go:113.2,113.12 1 1 -github.com/corentings/chess/v2/scanner.go:126.52,128.23 1 1 -github.com/corentings/chess/v2/scanner.go:128.23,132.3 3 1 -github.com/corentings/chess/v2/scanner.go:135.2,135.22 1 1 -github.com/corentings/chess/v2/scanner.go:135.22,137.3 1 1 -github.com/corentings/chess/v2/scanner.go:140.2,140.40 1 1 -github.com/corentings/chess/v2/scanner.go:140.40,142.3 1 0 -github.com/corentings/chess/v2/scanner.go:143.2,143.20 1 1 -github.com/corentings/chess/v2/scanner.go:155.34,157.53 1 1 -github.com/corentings/chess/v2/scanner.go:157.53,159.3 1 1 -github.com/corentings/chess/v2/scanner.go:162.2,162.22 1 1 -github.com/corentings/chess/v2/scanner.go:162.22,166.3 2 1 -github.com/corentings/chess/v2/scanner.go:169.2,170.14 2 1 -github.com/corentings/chess/v2/scanner.go:183.46,184.32 1 1 -github.com/corentings/chess/v2/scanner.go:184.32,188.3 3 1 -github.com/corentings/chess/v2/scanner.go:190.2,191.16 2 1 -github.com/corentings/chess/v2/scanner.go:191.16,193.3 1 0 -github.com/corentings/chess/v2/scanner.go:194.2,195.16 2 1 -github.com/corentings/chess/v2/scanner.go:195.16,197.3 1 0 -github.com/corentings/chess/v2/scanner.go:198.2,200.16 3 1 -github.com/corentings/chess/v2/scanner.go:200.16,202.3 1 0 -github.com/corentings/chess/v2/scanner.go:203.2,203.30 1 1 -github.com/corentings/chess/v2/scanner.go:203.30,205.3 1 1 -github.com/corentings/chess/v2/scanner.go:207.2,209.28 3 1 -github.com/corentings/chess/v2/scanner.go:213.66,216.24 2 1 -github.com/corentings/chess/v2/scanner.go:216.24,218.3 1 1 -github.com/corentings/chess/v2/scanner.go:221.2,222.17 2 1 -github.com/corentings/chess/v2/scanner.go:222.17,224.3 1 1 -github.com/corentings/chess/v2/scanner.go:227.2,227.47 1 1 -github.com/corentings/chess/v2/scanner.go:231.45,233.35 2 1 -github.com/corentings/chess/v2/scanner.go:233.35,234.33 1 1 -github.com/corentings/chess/v2/scanner.go:234.33,235.9 1 1 -github.com/corentings/chess/v2/scanner.go:238.2,238.14 1 1 -github.com/corentings/chess/v2/scanner.go:242.62,243.11 1 1 -github.com/corentings/chess/v2/scanner.go:243.11,245.3 1 1 -github.com/corentings/chess/v2/scanner.go:246.2,246.20 1 0 -github.com/corentings/chess/v2/scanner.go:250.60,252.45 1 1 -github.com/corentings/chess/v2/scanner.go:252.45,254.16 2 1 -github.com/corentings/chess/v2/scanner.go:254.16,256.4 1 1 -github.com/corentings/chess/v2/scanner.go:257.3,257.15 1 0 -github.com/corentings/chess/v2/scanner.go:259.2,259.14 1 1 -github.com/corentings/chess/v2/scanner.go:263.67,265.45 1 1 -github.com/corentings/chess/v2/scanner.go:265.45,268.44 2 1 -github.com/corentings/chess/v2/scanner.go:268.44,269.13 1 1 -github.com/corentings/chess/v2/scanner.go:269.13,271.5 1 1 -github.com/corentings/chess/v2/scanner.go:272.4,272.13 1 1 -github.com/corentings/chess/v2/scanner.go:274.3,274.15 1 0 -github.com/corentings/chess/v2/scanner.go:277.2,277.14 1 1 -github.com/corentings/chess/v2/scanner.go:281.82,287.36 4 1 -github.com/corentings/chess/v2/scanner.go:287.36,293.66 3 1 -github.com/corentings/chess/v2/scanner.go:293.66,295.22 2 1 -github.com/corentings/chess/v2/scanner.go:295.22,298.5 1 1 -github.com/corentings/chess/v2/scanner.go:302.3,302.48 1 1 -github.com/corentings/chess/v2/scanner.go:302.48,304.4 1 1 -github.com/corentings/chess/v2/scanner.go:308.2,308.45 1 1 -github.com/corentings/chess/v2/scanner.go:308.45,310.3 1 1 -github.com/corentings/chess/v2/scanner.go:312.2,312.26 1 1 -github.com/corentings/chess/v2/scanner.go:312.26,314.3 1 1 -github.com/corentings/chess/v2/scanner.go:317.2,317.54 1 1 -github.com/corentings/chess/v2/scanner.go:321.72,322.29 1 1 -github.com/corentings/chess/v2/scanner.go:322.29,324.3 1 1 -github.com/corentings/chess/v2/scanner.go:324.8,324.36 1 1 -github.com/corentings/chess/v2/scanner.go:324.36,326.3 1 1 -github.com/corentings/chess/v2/scanner.go:327.2,327.19 1 1 -github.com/corentings/chess/v2/scanner.go:331.55,332.15 1 1 -github.com/corentings/chess/v2/scanner.go:332.15,334.3 1 1 -github.com/corentings/chess/v2/scanner.go:334.8,334.35 1 1 -github.com/corentings/chess/v2/scanner.go:334.35,336.3 1 1 -github.com/corentings/chess/v2/scanner.go:337.2,337.18 1 1 -github.com/corentings/chess/v2/scanner.go:341.41,343.20 2 1 -github.com/corentings/chess/v2/scanner.go:343.20,345.3 1 1 -github.com/corentings/chess/v2/scanner.go:346.2,346.11 1 1 -github.com/corentings/chess/v2/scanner.go:350.53,354.30 3 1 -github.com/corentings/chess/v2/scanner.go:354.30,355.10 1 1 -github.com/corentings/chess/v2/scanner.go:356.49,357.18 1 1 -github.com/corentings/chess/v2/scanner.go:358.49,359.18 1 1 -github.com/corentings/chess/v2/scanner.go:360.88,361.18 1 1 -github.com/corentings/chess/v2/scanner.go:362.23,363.18 1 1 -github.com/corentings/chess/v2/scanner.go:364.11,365.9 1 1 -github.com/corentings/chess/v2/scanner.go:368.2,368.18 1 1 -github.com/corentings/chess/v2/square.go:12.30,14.2 1 1 -github.com/corentings/chess/v2/square.go:17.30,19.2 1 1 -github.com/corentings/chess/v2/square.go:21.34,23.2 1 1 -github.com/corentings/chess/v2/square.go:25.33,27.2 1 1 -github.com/corentings/chess/v2/square.go:30.39,32.2 1 1 -github.com/corentings/chess/v2/square.go:34.32,35.32 1 1 -github.com/corentings/chess/v2/square.go:35.32,37.3 1 1 -github.com/corentings/chess/v2/square.go:38.2,38.14 1 1 -github.com/corentings/chess/v2/square.go:128.31,130.2 1 1 -github.com/corentings/chess/v2/square.go:132.27,134.2 1 1 -github.com/corentings/chess/v2/square.go:150.31,152.2 1 1 -github.com/corentings/chess/v2/square.go:154.27,156.2 1 1 -github.com/corentings/chess/v2/stringer.go:11.33,12.39 1 0 -github.com/corentings/chess/v2/stringer.go:12.39,14.3 1 0 -github.com/corentings/chess/v2/stringer.go:15.2,15.58 1 0 -github.com/corentings/chess/v2/utils.go:3.29,5.2 1 1 -github.com/corentings/chess/v2/utils.go:7.28,9.2 1 1 -github.com/corentings/chess/v2/utils.go:11.33,13.2 1 1 -github.com/corentings/chess/v2/utils.go:15.30,17.2 1 1 -github.com/corentings/chess/v2/utils.go:20.27,22.2 1 1 -github.com/corentings/chess/v2/utils.go:24.35,26.2 1 1 -github.com/corentings/chess/v2/utils.go:29.27,31.2 1 1 -github.com/corentings/chess/v2/utils.go:33.27,35.2 1 1 -github.com/corentings/chess/v2/zobrist.go:26.38,33.2 1 0 -github.com/corentings/chess/v2/zobrist.go:36.40,43.2 1 1 -github.com/corentings/chess/v2/zobrist.go:46.36,48.19 1 1 -github.com/corentings/chess/v2/zobrist.go:48.19,50.3 1 0 -github.com/corentings/chess/v2/zobrist.go:53.2,55.16 3 1 -github.com/corentings/chess/v2/zobrist.go:55.16,57.3 1 0 -github.com/corentings/chess/v2/zobrist.go:59.2,59.15 1 1 -github.com/corentings/chess/v2/zobrist.go:63.37,65.2 1 1 -github.com/corentings/chess/v2/zobrist.go:67.27,69.21 2 1 -github.com/corentings/chess/v2/zobrist.go:69.21,71.3 1 0 -github.com/corentings/chess/v2/zobrist.go:72.2,72.30 1 1 -github.com/corentings/chess/v2/zobrist.go:72.30,74.3 1 1 -github.com/corentings/chess/v2/zobrist.go:77.36,79.21 2 0 -github.com/corentings/chess/v2/zobrist.go:79.21,81.3 1 0 -github.com/corentings/chess/v2/zobrist.go:82.2,82.23 1 0 -github.com/corentings/chess/v2/zobrist.go:82.23,83.35 1 0 -github.com/corentings/chess/v2/zobrist.go:85.2,85.30 1 0 -github.com/corentings/chess/v2/zobrist.go:85.30,87.3 1 0 -github.com/corentings/chess/v2/zobrist.go:91.53,97.2 2 1 -github.com/corentings/chess/v2/zobrist.go:100.51,101.14 1 1 -github.com/corentings/chess/v2/zobrist.go:101.14,103.3 1 1 -github.com/corentings/chess/v2/zobrist.go:105.2,105.17 1 1 -github.com/corentings/chess/v2/zobrist.go:105.17,108.3 2 1 -github.com/corentings/chess/v2/zobrist.go:110.2,113.50 3 1 -github.com/corentings/chess/v2/zobrist.go:113.50,116.3 2 0 -github.com/corentings/chess/v2/zobrist.go:118.2,119.25 2 1 -github.com/corentings/chess/v2/zobrist.go:123.63,124.20 1 1 -github.com/corentings/chess/v2/zobrist.go:124.20,126.3 1 1 -github.com/corentings/chess/v2/zobrist.go:127.2,127.12 1 1 -github.com/corentings/chess/v2/zobrist.go:131.64,132.14 1 1 -github.com/corentings/chess/v2/zobrist.go:132.14,134.3 1 1 -github.com/corentings/chess/v2/zobrist.go:136.2,136.30 1 1 -github.com/corentings/chess/v2/zobrist.go:136.30,138.3 1 1 -github.com/corentings/chess/v2/zobrist.go:139.2,139.30 1 1 -github.com/corentings/chess/v2/zobrist.go:139.30,141.3 1 1 -github.com/corentings/chess/v2/zobrist.go:142.2,142.30 1 1 -github.com/corentings/chess/v2/zobrist.go:142.30,144.3 1 1 -github.com/corentings/chess/v2/zobrist.go:145.2,145.30 1 1 -github.com/corentings/chess/v2/zobrist.go:145.30,147.3 1 1 -github.com/corentings/chess/v2/zobrist.go:149.2,149.12 1 1 -github.com/corentings/chess/v2/zobrist.go:153.62,155.21 2 1 -github.com/corentings/chess/v2/zobrist.go:155.21,158.3 2 1 -github.com/corentings/chess/v2/zobrist.go:160.2,160.25 1 1 -github.com/corentings/chess/v2/zobrist.go:160.25,163.38 3 1 -github.com/corentings/chess/v2/zobrist.go:163.38,165.17 2 1 -github.com/corentings/chess/v2/zobrist.go:166.13,168.97 2 1 -github.com/corentings/chess/v2/zobrist.go:168.97,170.6 1 0 -github.com/corentings/chess/v2/zobrist.go:171.5,171.97 1 1 -github.com/corentings/chess/v2/zobrist.go:171.97,173.6 1 0 -github.com/corentings/chess/v2/zobrist.go:174.5,174.11 1 1 -github.com/corentings/chess/v2/zobrist.go:175.13,177.97 2 1 -github.com/corentings/chess/v2/zobrist.go:177.97,179.6 1 0 -github.com/corentings/chess/v2/zobrist.go:180.5,180.97 1 1 -github.com/corentings/chess/v2/zobrist.go:180.97,182.6 1 0 -github.com/corentings/chess/v2/zobrist.go:183.5,183.11 1 1 -github.com/corentings/chess/v2/zobrist.go:184.13,186.11 2 1 -github.com/corentings/chess/v2/zobrist.go:187.13,189.11 2 1 -github.com/corentings/chess/v2/zobrist.go:190.13,192.11 2 1 -github.com/corentings/chess/v2/zobrist.go:193.13,195.11 2 1 -github.com/corentings/chess/v2/zobrist.go:196.13,198.11 2 1 -github.com/corentings/chess/v2/zobrist.go:199.13,201.11 2 1 -github.com/corentings/chess/v2/zobrist.go:202.13,204.11 2 1 -github.com/corentings/chess/v2/zobrist.go:205.13,207.11 2 1 -github.com/corentings/chess/v2/zobrist.go:208.13,210.11 2 1 -github.com/corentings/chess/v2/zobrist.go:211.13,213.11 2 1 -github.com/corentings/chess/v2/zobrist.go:214.48,215.29 1 1 -github.com/corentings/chess/v2/zobrist.go:216.12,218.15 2 0 -github.com/corentings/chess/v2/zobrist.go:221.3,221.16 1 1 -github.com/corentings/chess/v2/zobrist.go:221.16,223.4 1 1 -github.com/corentings/chess/v2/zobrist.go:225.2,225.12 1 1 -github.com/corentings/chess/v2/zobrist.go:229.67,237.20 6 1 -github.com/corentings/chess/v2/zobrist.go:237.20,239.3 1 1 -github.com/corentings/chess/v2/zobrist.go:241.2,244.61 2 1 -github.com/corentings/chess/v2/zobrist.go:244.61,246.3 1 0 -github.com/corentings/chess/v2/zobrist.go:248.2,248.23 1 1 -github.com/corentings/chess/v2/zobrist.go:248.23,250.3 1 0 -github.com/corentings/chess/v2/zobrist.go:252.2,258.19 5 1 -github.com/corentings/chess/v2/zobrist.go:258.19,260.3 1 0 -github.com/corentings/chess/v2/zobrist.go:262.2,265.17 3 1 -github.com/corentings/chess/v2/zobrist.go:265.17,267.3 1 1 -github.com/corentings/chess/v2/zobrist.go:269.2,269.35 1 1 -github.com/corentings/chess/v2/zobrist.go:272.46,274.21 1 1 -github.com/corentings/chess/v2/zobrist.go:274.21,276.3 1 1 -github.com/corentings/chess/v2/zobrist.go:279.2,280.16 2 1 -github.com/corentings/chess/v2/zobrist.go:280.16,282.3 1 0 -github.com/corentings/chess/v2/zobrist.go:284.2,284.15 1 1 diff --git a/doc.go b/doc.go index 30bf063e..f56e8506 100644 --- a/doc.go +++ b/doc.go @@ -1,25 +1,28 @@ /* Package chess is a go library designed to accomplish the following: - - chess game / turn management + - chess game state and move tree management - move validation - PGN encoding / decoding - FEN encoding / decoding +Game values are mutable and are not safe for concurrent use by multiple +goroutines. Callers that share a Game between goroutines must provide their own +synchronization. + Using Moves game := chess.NewGame() moves := game.ValidMoves() - game.Move(moves[0]) + game.Move(moves[0], nil) -Using Algebraic Notation +Using Strict SAN Move Text game := chess.NewGame() - game.MoveStr("e4") + game.PushMove("e4", nil) Using PGN - pgn, _ := chess.PGN(pgnReader) - game := chess.NewGame(pgn) + game, _ := chess.ParsePGN(pgnReader) Using FEN @@ -34,7 +37,7 @@ Random Game "fmt" "math/rand" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) func main() { @@ -44,7 +47,7 @@ Random Game // select a random move moves := game.ValidMoves() move := moves[rand.Intn(len(moves))] - game.Move(move) + game.Move(move, nil) } // print outcome and game PGN fmt.Println(game.Position().Board().Draw()) diff --git a/engine_test.go b/engine_test.go deleted file mode 100644 index 2709fb60..00000000 --- a/engine_test.go +++ /dev/null @@ -1,426 +0,0 @@ -package chess - -import ( - "testing" -) - -// Common test positions -var ( - // Starting position - startingPos = mustPosition("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") - - // Middle game position with lots of possible moves - middlePos = mustPosition("r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPP1PPP/R1BQK2R w KQkq - 0 1") - - // Endgame position with few pieces - endPos = mustPosition("4k3/8/8/8/8/8/4P3/4K3 w - - 0 1") - - // Position with multiple possible pawn promotions - promoPos = mustPosition("4k3/PPPP4/8/8/8/8/4pppp/4K3 w - - 0 1") -) - -func BenchmarkStandardMoves(b *testing.B) { - benchmarks := []struct { - name string - pos *Position - wantFirst bool - }{ - {"StartingPos_AllMoves", startingPos, false}, - {"StartingPos_FirstMove", startingPos, true}, - {"MiddleGame_AllMoves", middlePos, false}, - {"MiddleGame_FirstMove", middlePos, true}, - {"Endgame_AllMoves", endPos, false}, - {"Endgame_FirstMove", endPos, true}, - {"Promotions_AllMoves", promoPos, false}, - {"Promotions_FirstMove", promoPos, true}, - } - - for _, bm := range benchmarks { - b.Run(bm.name, func(b *testing.B) { - // Reset timer to exclude setup - b.ResetTimer() - - // Enable allocation tracking - b.ReportAllocs() - - for i := 0; i < b.N; i++ { - moves := standardMoves(bm.pos, bm.wantFirst, false) - // Prevent compiler optimization - if len(moves) == 0 { - b.Fatal("unexpected zero moves") - } - } - }) - } -} - -// Benchmark specific scenarios -func BenchmarkStandardMoves_PawnPromotions(b *testing.B) { - pos := promoPos - b.ResetTimer() - b.ReportAllocs() - - for i := 0; i < b.N; i++ { - moves := standardMoves(pos, false, false) - if len(moves) == 0 { - b.Fatal("unexpected zero moves") - } - } -} - -// Benchmark with different board sizes to understand allocation scaling -func BenchmarkStandardMoves_BoardDensity(b *testing.B) { - positions := []struct { - name string - fen string - }{ - {"Empty", "4k3/8/8/8/8/8/8/4K3 w - - 0 1"}, - {"QuarterFull", "rnbqk3/pppp4/8/8/8/8/8/4K3 w - - 0 1"}, - {"HalfFull", "rnbqkbnr/pppp4/8/8/8/8/8/4K3 w - - 0 1"}, - {"Full", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"}, - } - - for _, p := range positions { - pos := mustPosition(p.fen) - b.Run(p.name, func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - moves := standardMoves(pos, false, false) - if len(moves) == 0 && p.name != "Empty" { - b.Fatal("unexpected zero moves") - } - } - }) - } -} - -func TestMoveTags(t *testing.T) { - tests := []struct { - name string - move Move - want MoveTag - fen string - }{ - { - name: "move with queen side castle", - move: Move{s1: E8, s2: C8}, - want: QueenSideCastle, - fen: "r3kb1r/p2nqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/R3K2R b KQkq - 1 12", - }, - { - name: "move with king side castle", - move: Move{s1: E1, s2: G1}, - want: KingSideCastle | Check, - fen: "r4b1r/ppp3pp/8/4p3/2Pq4/3P4/PP2QPPP/2k1K2R w K - 0 18", - }, - { - name: "move with king side castle and check", - move: Move{s1: E1, s2: G1}, - want: KingSideCastle | Check, - fen: "r4b1r/ppp3pp/8/4p3/2Pq4/3P1Q2/PP3PPP/1k2K2R w K - 2 19", - }, - { - name: "move with check", - move: Move{s1: D7, s2: A4}, - want: Check, - fen: "rn2k1r1/ppqb4/4p1n1/3pPp1Q/8/P1PP4/4NPPP/R1BK1B1R b q - 0 14", - }, - { - name: "move leaves king in check", - move: Move{s1: G6, s2: F8}, - want: inCheck, - fen: "r3k1r1/ppq5/2n1p1n1/3p1pBQ/b2P3P/P1P5/4NPP1/R3KB1R b q - 0 18", - }, - { - name: "capture move", - move: Move{s1: G2, s2: G3}, - want: Capture, - fen: "8/7p/3k2p1/8/2p2P2/R5bP/6K1/4r3 w - - 0 44", - }, - { - name: "normal move without tags", - move: Move{s1: D6, s2: D5}, - want: 0, - fen: "8/7p/3k2p1/8/2p2P2/R5KP/8/4r3 b - - 0 44", - }, - { - name: "en passant move", - move: Move{s1: E4, s2: F3}, - want: EnPassant | Check, - fen: "r3k2r/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRKR2 b kq f3 0 1", - }, - { - name: "normal move without tags", - move: Move{s1: B7, s2: A6}, - want: 0, - fen: "r3k2r/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRKR2 b kq f3 0 1", - }, - { - name: "en passant move with check", - move: Move{s1: E4, s2: F3}, - want: EnPassant | Check, - fen: "r3k1r1/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRK1R1 b q f3 0 2", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - test.move.tags = moveTags(test.move, mustPosition(test.fen)) - - if test.move.tags != test.want { - t.Errorf("fen: %s | move: %s\ntags(%d) == expected_tags(%d)", test.fen, test.move.String(), test.move.tags, test.want) - } - }) - } -} - -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 TestPromotionCheckTagIsolation(t *testing.T) { - // FEN from issue #112: k7/4P3/8/8/8/8/8/K7 w - - 0 1 - // White pawn on E7 can promote to E8. Only Queen and Rook give check - // to the black king on A8. Bishop and Knight should NOT have Check. - pos := mustPosition("k7/4P3/8/8/8/8/8/K7 w - - 0 1") - moves := pos.ValidMoves() - - // Verify total move count (3 king moves + 4 promotions = 7) - if len(moves) != 7 { - t.Fatalf("expected 7 moves, got %d", len(moves)) - } - - // Helper: find move by s1, s2, promo fields (order-independent) - findMove := func(s1, s2 Square, promo PieceType) (Move, bool) { - for _, m := range moves { - if m.s1 == s1 && m.s2 == s2 && m.promo == promo { - return m, true - } - } - return Move{}, false - } - - tests := []struct { - name string - promo PieceType - wantCheck bool - }{ - {"queen promotion with check", Queen, true}, - {"rook promotion with check", Rook, true}, - {"bishop promotion without check", Bishop, false}, - {"knight promotion without check", Knight, false}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - m, ok := findMove(E7, E8, test.promo) - if !ok { - t.Fatalf("expected to find E7-E8=%s promotion move", test.promo.String()) - } - gotCheck := m.HasTag(Check) - if gotCheck != test.wantCheck { - t.Errorf("E7-E8=%s: Check=%v, want %v", test.promo.String(), gotCheck, test.wantCheck) - } - }) - } -} - -func TestPromotionNoCheck(t *testing.T) { - // Position where no promotion gives check against an existing black king. - // Black king on A6 is not attacked by any promoted piece on E8. - pos := mustPosition("8/4P3/k7/8/8/8/8/7K w - - 0 1") - moves := pos.ValidMoves() - - for _, m := range moves { - if m.s1 == E7 && m.s2 == E8 { - if m.HasTag(Check) { - t.Errorf("E7-E8=%s should NOT have Check tag", m.promo.String()) - } - } - } -} - -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()) - } - } -} - -func TestDiscoveredCheck(t *testing.T) { - // White rook on e1 is blocked by white knight on e4 from checking black king on e8 - // Moving Ne4-f6 reveals the check - pos := mustPosition("4k3/8/8/8/4N3/8/8/4R1K1 w - - 0 1") - moves := pos.ValidMoves() - - foundDiscoveredCheck := false - for _, m := range moves { - if m.s1 == E4 && m.HasTag(Check) { - foundDiscoveredCheck = true - break - } - } - if !foundDiscoveredCheck { - t.Error("expected at least one move from e4 with discovered check") - } -} - -func TestEnPassantDiscoveredCheck(t *testing.T) { - // White king on e1, black rook on e8, black pawn on e4 blocks the rook. - // White pawn on d2 moves to d4. Black captures e4xd3 en passant. - // After capture, pawn is on d3 (not on e-file), rook gives check. - pos := mustPosition("4r3/8/8/8/4p3/8/3P4/4K1k1 w - - 0 1") - // White plays d2-d4 - moves := pos.ValidMoves() - var d2d4 Move - found := false - for _, m := range moves { - if m.s1 == D2 && m.s2 == D4 { - d2d4 = m - found = true - break - } - } - if !found { - t.Fatal("expected d2-d4 move") - } - - // Apply the move and get the new position - pos2 := pos.Update(&d2d4) - // Black to move, en passant on d3 - moves2 := pos2.ValidMoves() - - foundEPCheck := false - for _, m := range moves2 { - if m.HasTag(EnPassant) && m.HasTag(Check) { - foundEPCheck = true - break - } - } - if !foundEPCheck { - t.Error("expected en passant move with discovered check") - } -} - -func TestDoubleCheck(t *testing.T) { - // Black king on e8 is in double check from white queen on h5 and knight on f6 - // Clear diagonal h5-e8 by removing pawn on f7: white knight on f6, queen on h5 - // The position itself should have inCheck=true, and only king moves should be legal - pos := mustPosition("rnb1kbnr/pppp2pp/5N2/6pQ/8/8/PPPPPPPP/RNB1KB1R b KQkq - 0 1") - - // Verify the position itself has the black king in check - if !pos.inCheck { - t.Error("expected black king to be in check in this position") - } - - moves := pos.ValidMoves() - // In double check, only king moves are legal - for _, m := range moves { - if m.s1 != E8 { - t.Errorf("in double check, only king moves should be legal, got %s", m.String()) - } - } -} - -func TestKingMoveEscapesCheck(t *testing.T) { - // Black king on e8 is in check from white rook on e1 - // Ke8-d8 should escape check - pos := mustPosition("4k3/8/8/8/8/8/8/4R3 b - - 0 1") - moves := pos.ValidMoves() - - findMove := func(s1, s2 Square) (Move, bool) { - for _, m := range moves { - if m.s1 == s1 && m.s2 == s2 { - return m, true - } - } - return Move{}, false - } - - m, ok := findMove(E8, D8) - if !ok { - t.Fatal("expected to find Ke8-d8 move") - } - if m.HasTag(inCheck) { - t.Error("Ke8-d8 should not leave king in check") - } -} - -func TestKingMoveWalksIntoCheck(t *testing.T) { - // White king on e1, black rook on e8 - // Ke1-e2 should be illegal (walks into check) - pos := mustPosition("4r3/8/8/8/8/8/8/4K3 w - - 0 1") - moves := engine{}.UnsafeMoves(pos) - - findMove := func(s1, s2 Square) (Move, bool) { - for _, m := range moves { - if m.s1 == s1 && m.s2 == s2 { - return m, true - } - } - return Move{}, false - } - - m, ok := findMove(E1, E2) - if !ok { - t.Fatal("expected to find Ke1-e2 unsafe move") - } - if !m.HasTag(inCheck) { - t.Error("Ke1-e2 should have inCheck tag") - } -} - -func BenchmarkMoveTags(b *testing.B) { - pos := mustPosition("r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPP1PPP/R1BQK2R w KQkq - 0 1") - moves := engine{}.CalcMoves(pos, false) - if len(moves) == 0 { - b.Fatal("expected moves") - } - - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - for _, m := range moves { - _ = moveTags(m, pos) - } - } -} -// Helper function to convert FEN to Position -func mustPosition(fen string) *Position { - fenObject, err := FEN(fen) - pos := NewGame(fenObject).Position() - if err != nil { - panic(err) - } - return pos -} diff --git a/errors.go b/errors.go index 75232178..fa27d483 100644 --- a/errors.go +++ b/errors.go @@ -25,9 +25,9 @@ func (e *PGNError) Is(target error) bool { return e.msg == t.msg } -// Custom error types for different PGN errors -// -//nolint:gochecknoglobals // this is a custom error type. +// Package-level error sentinels and constructors used throughout the PGN +// parser. These are immutable factories, not mutable global state, so the +// gochecknoglobals warning is a false positive. var ( ErrUnterminatedComment = func(pos int) error { return &PGNError{"unterminated comment", pos} } ErrUnterminatedQuote = func(pos int) error { return &PGNError{"unterminated quote", pos} } @@ -37,6 +37,10 @@ var ( ErrInvalidRank = func(pos int) error { return &PGNError{"invalid rank", pos} } ErrNoGameFound = errors.New("no game found in PGN data") + + // ErrGameAlreadyEnded is returned when a move is applied to a Game that + // already has a terminal Outcome. Callers must ClearOutcome first. + ErrGameAlreadyEnded = errors.New("chess: game already ended") ) type ParserError struct { @@ -47,6 +51,6 @@ type ParserError struct { } func (e *ParserError) Error() string { - return fmt.Sprintf("Parser error at position %d: %s (Token: %v, Value: %s)", + return fmt.Sprintf("parser error at position %d: %s (Token: %v, Value: %s)", e.Position, e.Message, e.TokenType, e.TokenValue) } diff --git a/export_test.go b/export_test.go new file mode 100644 index 00000000..e0efb005 --- /dev/null +++ b/export_test.go @@ -0,0 +1,28 @@ +package chess + +import ( + "errors" + "io" + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func PGN(r io.Reader) (func(*Game), error) { + game, err := ParsePGN(r) + if errors.Is(err, io.EOF) { + return nil, ErrNoGameFound + } + if err != nil { + return nil, err + } + return func(g *Game) { g.copy(game) }, nil +} + +func NewParser(tokens []Token) *Parser { + return newParser(tokens) +} diff --git a/fen.go b/fen.go index 3ac01c66..0e40ad2b 100644 --- a/fen.go +++ b/fen.go @@ -21,7 +21,7 @@ func decodeFEN(fen string) (*Position, error) { } b, err := fenBoard(parts[0]) if err != nil { - return nil, err + return nil, fmt.Errorf("chess: fen: board: %w", err) } turn, ok := fenTurnMap[parts[1]] if !ok { @@ -29,11 +29,11 @@ func decodeFEN(fen string) (*Position, error) { } rights, err := formCastleRights(parts[2]) if err != nil { - return nil, err + return nil, fmt.Errorf("chess: fen: castle rights: %w", err) } sq, err := formEnPassant(parts[3]) if err != nil { - return nil, err + return nil, fmt.Errorf("chess: fen: en passant: %w", err) } halfMoveClock, err := strconv.Atoi(parts[4]) if err != nil || halfMoveClock < 0 { @@ -44,7 +44,7 @@ func decodeFEN(fen string) (*Position, error) { return nil, errors.New("chess: fen invalid move count") } pos := &Position{ - board: b, + board: *b, turn: turn, castleRights: rights, enPassantSquare: sq, @@ -66,7 +66,7 @@ var ( //note: this is a sync.Pool //nolint:gochecknoglobals // this is a pool. pieceMapPool = sync.Pool{ - New: func() interface{} { + New: func() any { return make(map[Square]Piece, pieceMapSize) }, } @@ -75,17 +75,26 @@ var ( //note: this is a sync.Pool //nolint:gochecknoglobals // this is a pool. fileMapPool = sync.Pool{ - New: func() interface{} { + New: func() any { return make(map[File]Piece, fileMapSize) }, } ) -// clearMap helper to clear a map without deallocating. -func clearMap[K comparable, V any](m map[K]V) { - for k := range m { - delete(m, k) +func getPieceMap() map[Square]Piece { + m, ok := pieceMapPool.Get().(map[Square]Piece) + if !ok || m == nil { + return make(map[Square]Piece, pieceMapSize) } + return m +} + +func getFileMap() map[File]Piece { + m, ok := fileMapPool.Get().(map[File]Piece) + if !ok || m == nil { + return make(map[File]Piece, fileMapSize) + } + return m } // fenBoard generates board from FEN format while minimizing allocations. @@ -97,12 +106,12 @@ func fenBoard(boardStr string) (*Board, error) { var rankBuffer [maxRankLen]string // Get maps from pools - m, _ := pieceMapPool.Get().(map[Square]Piece) - fileMap, _ := fileMapPool.Get().(map[File]Piece) + m := getPieceMap() + fileMap := getFileMap() // Clear maps (in case they were reused) - clearMap(m) - clearMap(fileMap) + clear(m) + clear(fileMap) // Ensure maps are returned to pools on exit defer func() { @@ -141,11 +150,11 @@ func fenBoard(boardStr string) (*Board, error) { rank := Rank(7 - i) // Clear fileMap for reuse - clearMap(fileMap) + clear(fileMap) // Parse rank into reused map if err := fenFormRank(rankBuffer[i], fileMap); err != nil { - return nil, err + return nil, fmt.Errorf("chess: fen: rank %d: %w", 8-i, err) } // Transfer pieces to main map @@ -158,7 +167,7 @@ func fenBoard(boardStr string) (*Board, error) { // Note: NewBoard must copy the map since we're returning m to the pool board, err := NewBoard(m) if err != nil { - return nil, err + return nil, fmt.Errorf("chess: fen: board construction: %w", err) } return board, nil } @@ -195,16 +204,15 @@ func fenFormRank(rankStr string, m map[File]Piece) error { } func formCastleRights(castleStr string) (CastleRights, error) { - // check for duplicates aka. KKkq right now is valid - for _, s := range []string{"K", "Q", "k", "q", "-"} { - if strings.Count(castleStr, s) > 1 { - return "-", fmt.Errorf("chess: fen invalid castle rights %s", castleStr) - } - } - for _, r := range castleStr { - c := fmt.Sprintf("%c", r) + var seen [256]bool + for i := range castleStr { + c := castleStr[i] switch c { - case "K", "Q", "k", "q", "-": + case 'K', 'Q', 'k', 'q', '-': + if seen[c] { + return "-", fmt.Errorf("chess: fen invalid castle rights %s", castleStr) + } + seen[c] = true default: return "-", fmt.Errorf("chess: fen invalid castle rights %s", castleStr) } @@ -217,7 +225,7 @@ func formEnPassant(enPassant string) (Square, error) { return NoSquare, nil } sq := SquareFromString(enPassant) - if sq == NoSquare || !(sq.Rank() == Rank3 || sq.Rank() == Rank6) { + if sq == NoSquare || (sq.Rank() != Rank3 && sq.Rank() != Rank6) { return NoSquare, fmt.Errorf("chess: fen invalid En Passant square %s", enPassant) } return sq, nil diff --git a/fen_test.go b/fen_test.go index ed5bab0f..1588c959 100644 --- a/fen_test.go +++ b/fen_test.go @@ -86,6 +86,22 @@ func TestInvalidFENs(t *testing.T) { } } +func TestFenBoardHandlesInvalidPooledMaps(t *testing.T) { + pieceMapPool.Put("not a piece map") + fileMapPool.Put("not a file map") + + board, err := fenBoard("8/8/8/8/8/8/8/R6K") + if err != nil { + t.Fatalf("fenBoard returned error: %v", err) + } + if got := board.Piece(A1); got != WhiteRook { + t.Fatalf("A1 = %v, want %v", got, WhiteRook) + } + if got := board.Piece(H1); got != WhiteKing { + t.Fatalf("H1 = %v, want %v", got, WhiteKing) + } +} + func BenchmarkFenBoard(b *testing.B) { // Test cases representing different scenarios benchmarks := []struct { @@ -120,7 +136,7 @@ func BenchmarkFenBoard(b *testing.B) { b.ResetTimer() b.ReportAllocs() // Run the benchmark - for i := 0; i < b.N; i++ { + for range b.N { board, err := fenBoard(bm.fen) if err != nil { b.Fatal(err) diff --git a/scanner.go b/framer.go similarity index 56% rename from scanner.go rename to framer.go index 1d0abc21..095373b5 100644 --- a/scanner.go +++ b/framer.go @@ -1,23 +1,9 @@ -/* -Package chess provides functionality for reading and parsing chess games in PGN -(Portable Game Notation) format. It includes a scanner for reading multiple games -from a single source and a tokenizer for converting PGN text into processable tokens. -The scanner handles PGN-specific syntax including game metadata, moves, comments, -and variations. It supports streaming processing of large PGN files and provides -proper handling of game boundaries and special notation. -Example usage: - // Create scanner for PGN input - scanner := NewScanner(reader) - - // Read all games - for scanner.HasNext() { - game, err := scanner.ParseNext() - if err != nil { - log.Fatalf("Failed to parse game: %v", err) - } - // Process game - } -*/ +// PGN game framing: splits a byte stream into individual PGN game records. +// +// The pgnFramer type reads from an io.Reader, buffers data, and uses +// splitPGNGames to emit complete PGN records as strings. The splitPGNGames +// function and its helpers handle PGN-specific syntax including game metadata, +// moves, comments, and variations. package chess @@ -49,10 +35,20 @@ func TokenizeGame(game *GameScanned) ([]Token, error) { if game == nil { return nil, nil } + // Preallocate the token slice. Empirically a PGN byte produces ~3 tokens + // (move pairs, NAGs, comments, tags), so size to avoid reallocation + // during growth. Slice growth from a nil starting point throws away + // every prior backing array, which previously dominated allocations. + return tokenizeInto(game, make([]Token, 0, len(game.Raw)/3+16)) +} - lexer := NewLexer(game.Raw) - var tokens []Token +func tokenizeInto(game *GameScanned, tokens []Token) ([]Token, error) { + if game == nil { + return nil, nil + } + lexer := NewLexer(game.Raw) + tokens = tokens[:0] for { token := lexer.NextToken() if token.Type == EOF { @@ -64,151 +60,6 @@ func TokenizeGame(game *GameScanned) ([]Token, error) { return tokens, nil } -// Scanner provides functionality to read chess games from a PGN source. -// 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 - nextParsedGames []*Game // only valid when ExpandVariations==true - opts ScannerOpts -} - -type ScannerOption func(*Scanner) - -// WithExpandVariations() instructs the scanner to expand all variations in -// a single GameScanned into multiple Game instances (1 per variation) rather -// than a single Game instance. -func WithExpandVariations() ScannerOption { - return func(s *Scanner) { - s.opts.ExpandVariations = true - } -} - -type ScannerOpts struct { - ExpandVariations bool // default false -} - -// NewScanner creates a new PGN scanner that reads from the provided reader. -// The scanner is configured to properly split PGN games and handle -// PGN-specific syntax. -// -// Example: -// -// scanner := NewScanner(strings.NewReader(pgnText)) -func NewScanner(r io.Reader, opts ...ScannerOption) *Scanner { - s := bufio.NewScanner(r) - s.Split(splitPGNGames) - ret := &Scanner{ - scanner: s, - nextParsedGames: make([]*Game, 0), - } - - // apply all the options - for _, opt := range opts { - opt(ret) - } - - return ret -} - -// ScanGame reads and returns the next game from the source. -// Returns nil and io.EOF when no more games are available. -// Returns nil and an error if reading fails. -// -// Example: -// -// game, err := scanner.ScanGame() -// if err == io.EOF { -// // No more games -// } -func (s *Scanner) ScanGame() (*GameScanned, error) { - // If we have a buffered game from HasNext(), return it - if s.nextGame != nil { - game := s.nextGame - s.nextGame = nil - return game, nil - } - - // Otherwise scan the next game - if s.scanner.Scan() { - return &GameScanned{Raw: s.scanner.Text()}, nil - } - - // Check for errors - if err := s.scanner.Err(); err != nil { - return nil, err - } - return nil, io.EOF -} - -// HasNext returns true if there are more games available to read. -// This method can be used to iterate over all games in the source. -// -// Example: -// -// for scanner.HasNext() { -// scangame, err := scanner.ScanGame() -// // Process scangame -// } -func (s *Scanner) HasNext() bool { - // If we already have a buffered game, return true - if s.nextGame != nil || len(s.nextParsedGames) > 0 { - return true - } - - // Try to scan the next game - if s.scanner.Scan() { - // Store the game in the buffer - s.nextGame = &GameScanned{Raw: s.scanner.Text()} - return true - } - - // Store any error that occurred - s.lastError = s.scanner.Err() - return false -} - -// ParseNext is a convenience wrapper combining the functionality of -// ScanGame(), TokenizeGame(), NewParser(), and Parse() enabling -// callers to simplify iterating over each Game within a pgn file. -// -// Example: -// -// for scanner.HasNext() { -// game, err := scanner.ParseNext() -// // Process game -// } -func (s *Scanner) ParseNext() (*Game, error) { - if len(s.nextParsedGames) > 0 { - ret := s.nextParsedGames[0] - s.nextParsedGames = s.nextParsedGames[1:] - return ret, nil - } - - scannedGame, err := s.ScanGame() - if err != nil { - return nil, err - } - tokens, err := TokenizeGame(scannedGame) - if err != nil { - return nil, err - } - parser := NewParser(tokens) - game, err := parser.Parse() - if err != nil { - return nil, err - } - if !s.opts.ExpandVariations { - return game, nil - } // else - - parsedGames := game.Split() - s.nextParsedGames = parsedGames[1:] - return parsedGames[0], nil -} - // Split function for bufio.Scanner to split PGN games. func splitPGNGames(data []byte, atEOF bool) (int, []byte, error) { // Skip leading whitespace @@ -259,7 +110,7 @@ func findGameStart(data []byte, start int, atEOF bool) int { return start } -// Helper to find the start of a game without tags +// Helper to find the start of a game without tags. func findTaglessGameStart(data []byte, start int, atEOF bool) int { // If the first character is not '[', find the next '[' character if start < len(data) && data[start] != '1' { @@ -351,6 +202,12 @@ func checkForResult(data []byte, i int) (bool, int) { const minLength = 3 // Minimum length for results like "1-0" const fullResultLength = 7 // Length for "1/2-1/2" + switch data[i] { + case '1', '0', '*': + default: + return false, -1 + } + if len(data)-i >= minLength { switch { case bytes.HasPrefix(data[i:], []byte("1-0")): @@ -367,3 +224,111 @@ func checkForResult(data []byte, i int) (bool, int) { } return false, -1 } + +type pgnFramer struct { + reader *bufio.Reader + buffer []byte + chunk []byte + baseOffset int64 + index int64 + readErr error + eof bool +} + +func newPGNFramer(r io.Reader, opts ...PGNOption) *pgnFramer { + options := applyPGNOptions(opts) + bufferSize := options.bufferSize + if bufferSize <= 0 { + bufferSize = 32 * 1024 + } + return &pgnFramer{reader: bufio.NewReaderSize(r, bufferSize), chunk: make([]byte, bufferSize)} +} + +func (f *pgnFramer) next() (PGNRecord, error) { + for { + if f.readErr != nil && len(f.buffer) == 0 { + err := f.readErr + f.readErr = nil + return PGNRecord{}, err + } + + advance, token, err := splitPGNGames(f.buffer, f.eof) + if err != nil { + return PGNRecord{}, err + } + if advance == 0 && token == nil { + if f.eof { + return PGNRecord{}, io.EOF + } + if readErr := f.readMore(); readErr != nil && len(f.buffer) == 0 { + return PGNRecord{}, readErr + } + continue + } + start := f.baseOffset + if len(token) > 0 { + if rel := bytes.Index(f.buffer[:advance], token); rel >= 0 { + start = f.baseOffset + int64(rel) + } + } + f.advance(advance) + f.baseOffset += int64(advance) + if len(token) == 0 { + continue + } + f.index++ + return PGNRecord{Index: f.index, Offset: start, Raw: string(token)}, nil + } +} + +func (f *pgnFramer) readMore() error { + n, err := f.reader.Read(f.chunk) + if n > 0 { + f.buffer = append(f.buffer, f.chunk[:n]...) + } + if err == io.EOF { + f.eof = true + return nil + } + if err != nil { + f.readErr = err + } + return err +} + +func (f *pgnFramer) advance(n int) { + if n >= len(f.buffer) { + f.buffer = f.buffer[:0] + return + } + retained := len(f.buffer) - n + if retained*4 < cap(f.buffer) { + f.buffer = append([]byte(nil), f.buffer[n:]...) + return + } + f.buffer = f.buffer[n:] +} + +func parsePGNText(raw string, options pgnOptions) (*Game, error) { + return newParserFromSource(&lexerTokenSource{lexer: NewLexer(raw)}, options).Parse() +} + +type lexerTokenSource struct { + lexer *Lexer +} + +func (s *lexerTokenSource) NextToken() (Token, error) { + return s.lexer.NextToken(), nil +} + +func applyPGNOptions(opts []PGNOption) pgnOptions { + options := defaultPGNOptions() + for _, opt := range opts { + opt(&options) + } + return options +} + +func defaultPGNOptions() pgnOptions { + return pgnOptions{moveTextCodec: PGNImportSAN()} +} diff --git a/game.go b/game.go index 947e0475..c06edf49 100644 --- a/game.go +++ b/game.go @@ -26,59 +26,7 @@ import ( "errors" "fmt" "io" - "slices" - "strings" -) - -// A Outcome is the result of a game. -type Outcome string - -const ( - UnknownOutcome Outcome = "" - // NoOutcome indicates that a game is in progress or ended without a result. - NoOutcome Outcome = "*" - // WhiteWon indicates that white won the game. - WhiteWon Outcome = "1-0" - // BlackWon indicates that black won the game. - BlackWon Outcome = "0-1" - // Draw indicates that game was a draw. - Draw Outcome = "1/2-1/2" -) - -// String implements the fmt.Stringer interface. -func (o Outcome) String() string { - return string(o) -} - -// A Method is the method that generated the outcome. -type Method uint8 - -const ( - // NoMethod indicates that an outcome hasn't occurred or that the method can't be determined. - NoMethod Method = iota - // Checkmate indicates that the game was won checkmate. - Checkmate - // Resignation indicates that the game was won by resignation. - Resignation - // DrawOffer indicates that the game was drawn by a draw offer. - DrawOffer - // Stalemate indicates that the game was drawn by stalemate. - Stalemate - // ThreefoldRepetition indicates that the game was drawn when the game - // state was repeated three times and a player requested a draw. - ThreefoldRepetition - // FivefoldRepetition indicates that the game was automatically drawn - // by the game state being repeated five times. - FivefoldRepetition - // FiftyMoveRule indicates that the game was drawn by the half - // move clock being one hundred or greater when a player requested a draw. - FiftyMoveRule - // SeventyFiveMoveRule indicates that the game was automatically drawn - // when the half move clock was one hundred and fifty or greater. - SeventyFiveMoveRule - // InsufficientMaterial indicates that the game was automatically drawn - // because there was insufficient material for checkmate. - InsufficientMaterial + "maps" ) // TagPairs represents a collection of PGN tag pairs. @@ -86,11 +34,9 @@ type TagPairs map[string]string // A Game represents a single chess game. type Game struct { - pos *Position // Current position outcome Outcome // Game result tagPairs TagPairs // PGN tag pairs - rootMove *Move // Root of move tree - currentMove *Move // Current position in tree + tree *MoveTree // Move tree and active cursor comments [][]string // Game comments method Method // How the game ended ignoreFivefoldRepetitionDraw bool // Flag for automatic FivefoldRepetition draw handling @@ -98,40 +44,6 @@ type Game struct { ignoreInsufficientMaterialDraw bool // Flag for automatic InsufficientMaterial draw handling } -// PGN takes a reader and returns a function that updates -// the game to reflect the PGN data. The PGN can use any -// move notation supported by this package. The returned -// function is designed to be used in the NewGame constructor. -// An error is returned if there is a problem parsing the PGN data. -func PGN(r io.Reader) (func(*Game), error) { - scanner := NewScanner(r) - - if !scanner.HasNext() { - return nil, ErrNoGameFound - } - - gameScanned, err := scanner.ScanGame() - if err != nil { - return nil, err - } - - tokens, err := TokenizeGame(gameScanned) - if err != nil { - return nil, err - } - - parser := NewParser(tokens) - game, err := parser.Parse() - if err != nil { - return nil, err - } - - // Return a function that updates the game with the parsed game state - return func(g *Game) { - g.copy(game) - }, nil -} - // FEN takes a string and returns a function that updates // the game to reflect the FEN data. Since FEN doesn't encode // prior moves, the move list will be empty. The returned @@ -140,15 +52,14 @@ func PGN(r io.Reader) (func(*Game), error) { func FEN(fen string) (func(*Game), error) { pos, err := decodeFEN(fen) if err != nil { - return nil, err + return nil, fmt.Errorf("chess: game FEN option: %w", err) } if pos == nil { return nil, errors.New("chess: invalid FEN") } return func(g *Game) { pos.inCheck = isInCheck(pos) - g.pos = pos - g.rootMove.position = pos + g.tree.setRootPosition(pos) g.evaluatePositionStatus() }, nil } @@ -165,17 +76,12 @@ func FEN(fen string) (func(*Game), error) { // game := NewGame(FEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")) func NewGame(options ...func(*Game)) *Game { pos := StartingPosition() - rootMove := &Move{ - position: pos, - } game := &Game{ - rootMove: rootMove, - tagPairs: make(map[string]string), - currentMove: rootMove, - pos: pos, - outcome: NoOutcome, - method: NoMethod, + tree: newMoveTree(pos), + tagPairs: make(map[string]string), + outcome: NoOutcome, + method: NoMethod, } for _, f := range options { if f != nil { @@ -185,106 +91,38 @@ func NewGame(options ...func(*Game)) *Game { return game } -// AddVariation adds a new variation to the game. -// The parent move must be a move in the game or nil to add a variation to the root. -func (g *Game) AddVariation(parent *Move, newMove *Move) { - parent.children = append(parent.children, newMove) - newMove.parent = parent -} - -// NavigateToMainLine navigates to the main line of the game. -// The main line is the first child of each move. -func (g *Game) NavigateToMainLine() { - current := g.currentMove - - // First, navigate up to find a move that's part of the main line - for current.parent != nil && !isMainLine(current) { - current = current.parent - } - - // If there are no moves in the game, stay at root - if len(g.rootMove.children) == 0 { - g.currentMove = g.rootMove - return - } - - // Otherwise, navigate to the first move of the main line - g.currentMove = g.rootMove.children[0] -} - -func isMainLine(move *Move) bool { - if move.parent == nil { - return true - } - return move == move.parent.children[0] && isMainLine(move.parent) -} - -// GoBack navigates to the previous move in the game. -// Returns true if the move was successful. Returns false if there are no moves to go back to. -// If the game is at the start, it will return false. -func (g *Game) GoBack() bool { - if g.currentMove != nil && g.currentMove.parent != nil { - g.currentMove = g.currentMove.parent - g.pos = g.currentMove.position.copy() - return true - } - return false -} - -// GoForward navigates to the next move in the game. -// Returns true if the move was successful. Returns false if there are no moves to go forward to. -// If the game is at the end, it will return false. -func (g *Game) GoForward() bool { - // Check if current move exists and has children - if g.currentMove != nil && len(g.currentMove.children) > 0 { - g.currentMove = g.currentMove.children[0] // Follow main line - g.pos = g.currentMove.position - return true - } - return false -} +// MoveTree returns the game's move tree. +func (g *Game) MoveTree() *MoveTree { return g.tree } // IsAtStart returns true if the game is at the start. func (g *Game) IsAtStart() bool { - return g.currentMove == nil || g.currentMove == g.rootMove + return g.tree == nil || g.tree.Current() == nil || g.tree.Current() == g.tree.Root() } // IsAtEnd returns true if the game is at the end. func (g *Game) IsAtEnd() bool { - return g.currentMove != nil && len(g.currentMove.children) == 0 + return g.tree != nil && g.tree.Current() != nil && len(g.tree.Current().children) == 0 } // ValidMoves returns all legal moves in the current position. func (g *Game) ValidMoves() []Move { - return g.pos.ValidMoves() + return g.currentPosition().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() + return g.currentPosition().UnsafeMoves() } -// Moves returns the move history of the game following the main line. -func (g *Game) Moves() []*Move { - if g.rootMove == nil { - return nil - } - - moves := make([]*Move, 0) - current := g.rootMove - - // Traverse the main line (first child of each move) - for current != nil { - moves = append(moves, current) - if len(current.children) == 0 { - break - } - // Follow main line (first variation) - current = current.children[0] +// Moves returns the main-line move values of the game. +func (g *Game) Moves() []Move { + nodes := g.tree.MainLine() + moves := make([]Move, len(nodes)) + for i, node := range nodes { + moves[i] = node.move } - - return moves[1:] // Skip the root move + return moves } // MoveHistory is a move's result from Game's MoveHistory method. @@ -292,7 +130,7 @@ func (g *Game) Moves() []*Move { type MoveHistory struct { PrePosition *Position PostPosition *Position - Move *Move + Move Move Comments []string } @@ -300,12 +138,13 @@ type MoveHistory struct { // positions and any comments. Variations are not included. // Returns an empty slice for games with no moves. func (g *Game) MoveHistory() []*MoveHistory { - if g.rootMove == nil || len(g.rootMove.children) == 0 { + root := g.tree.Root() + if root == nil || len(root.children) == 0 { return []*MoveHistory{} } history := make([]*MoveHistory, 0) - current := g.rootMove + current := root for current != nil && len(current.children) > 0 { move := current.children[0] @@ -320,7 +159,7 @@ func (g *Game) MoveHistory() []*MoveHistory { history = append(history, &MoveHistory{ PrePosition: current.position, PostPosition: move.position, - Move: move, + Move: move.move, Comments: comments, }) current = move @@ -329,43 +168,47 @@ func (g *Game) MoveHistory() []*MoveHistory { return history } -// GetRootMove returns the root move of the game. -func (g *Game) GetRootMove() *Move { - return g.rootMove -} - -// Variations returns all alternative moves at the given position. -func (g *Game) Variations(move *Move) []*Move { - if move == nil || len(move.children) <= 1 { - return nil - } - // Return all moves except the main line (first child) - return move.children[1:] -} - -// Comments returns the comments for the game indexed by moves. // Comments returns the comments for the game indexed by moves. func (g *Game) Comments() [][]string { if g.comments == nil { return [][]string{} } - return append([][]string(nil), g.comments...) + return copyComments(g.comments) +} + +// copyComments returns a deep copy of comments. Internal use; the exported +// Comments method goes through it as well. +func copyComments(src [][]string) [][]string { + if src == nil { + return [][]string{} + } + out := make([][]string, len(src)) + for i, c := range src { + if c == nil { + out[i] = nil + continue + } + cc := make([]string, len(c)) + copy(cc, c) + out[i] = cc + } + return out } // Position returns the game's current position. func (g *Game) Position() *Position { - return g.pos + pos := g.currentPosition() + if pos == nil { + return nil + } + return pos.copy() } -// CurrentPosition returns the game's current move position. -// This is the position at the current pointer in the move tree. -// This should be used to get the current position of the game instead of Position(). -func (g *Game) CurrentPosition() *Position { - if g.currentMove == nil { - return g.pos +func (g *Game) currentPosition() *Position { + if g == nil || g.tree == nil { + return nil } - - return g.currentMove.position + return g.tree.position() } // Outcome returns the game outcome. @@ -380,284 +223,19 @@ func (g *Game) Method() Method { // FEN returns the FEN notation of the current position. func (g *Game) FEN() string { - return g.pos.String() -} - -// escapeTagValue escapes backslash and double-quote characters so that the -// resulting string is safe to embed inside a PGN tag value. -func escapeTagValue(v string) string { - var sb strings.Builder - for i := 0; i < len(v); i++ { - c := v[i] - if c == '\\' || c == '"' { - sb.WriteByte('\\') - } - sb.WriteByte(c) - } - return sb.String() + return g.currentPosition().String() } // String implements the fmt.Stringer interface and returns -// the game's PGN. +// the game's PGN. It delegates to DefaultPGNRenderer. func (g *Game) String() string { - var sb strings.Builder - - tagPairList := make([]sortableTagPair, len(g.tagPairs)) - - var idx uint = 0 - for tag, value := range g.tagPairs { - tagPairList[idx] = sortableTagPair{ - Key: tag, - Value: value, - } - idx++ - } - - slices.SortFunc(tagPairList, cmpTags) - - // Write tag pairs. - for _, tagPair := range tagPairList { - sb.WriteString(fmt.Sprintf("[%s \"%s\"]\n", tagPair.Key, escapeTagValue(tagPair.Value))) - } - - // Append empty line after tag pairs as per definition - if len(g.tagPairs) > 0 { - sb.WriteString("\n") - } - - // Assume g.rootMove is a dummy root (holding the initial position) - // and that its first child is the first actual move. - needTrailingSpace := false - if g.rootMove != nil { - if len(g.rootMove.children) > 0 { - needTrailingSpace = !writeMoves(g.rootMove, - g.rootMove.Position().moveCount, - g.rootMove.Position().Turn() == White, &sb, false, false, true) - } else if g.rootMove.hasAnnotations() { - writeAnnotations(g.rootMove, &sb) - } - } - - // Append the game result. - if needTrailingSpace { - sb.WriteString(" ") - } - sb.WriteString(g.Outcome().String()) // outcomeString() returns the result as a string (e.g. "1-0") - return sb.String() -} - -// sortableTagPair is its own -type sortableTagPair struct { - Key string - Value string -} - -// Compares two tags to determine in which order they should be brought up -func cmpTags(a, b sortableTagPair) int { - // Don't re-order duplicate keys - if a.Key == b.Key { - return 0 - } - - // PGN defined tags take priority - for _, req := range []string{ - "Event", - "Site", - "Date", - "Round", - "White", - "Black", - "Result", - } { - if a.Key == req { - return -1 - } - if b.Key == req { - return +1 - } - } - - // Finally compare the keys directly and sort by ascending - if a.Key < b.Key { - return -1 - } else if b.Key < a.Key { - return +1 - } - return 0 -} - -// writeMoves recursively writes the PGN-formatted move sequence starting from the given move node into the provided strings.Builder. -// It handles move numbering for white and black moves, encodes moves using algebraic notation based on the appropriate position, -// and appends comments and command annotations if present. The function distinguishes between main line moves and sub-variations; -// when processing a sub-variation, moves are enclosed in parentheses. -// -// Parameters: -// -// node - pointer to the current move node from which to write moves. -// moveNum - the current move number corresponding to white’s moves. -// isWhite - true if it is white’s move, false if it is black’s move. -// sb - pointer to a strings.Builder where the formatted move notation is appended. -// subVariation - true if the current call is within a sub-variation, affecting formatting details. -// closedVariation - true if the prior call closed a sub-variation, affecting formatting details. -// isRoot - true if the current move is the root move of a game, affecting formatting details. -// -// The function recurses through the move tree, writing the main line first and then processing any additional variations, -// 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 { - trailingSpace := false - - // If no moves remain, stop. - if node == nil { - return trailingSpace - } - - // Handle root move comments before processing children - if isRoot && node.hasAnnotations() { - writeAnnotations(node, sb) - } - - var currentMove *Move - - // The main line is the first child. - if subVariation { - currentMove = node - } else { - if len(node.children) == 0 { - return trailingSpace // nothing to print if no child exists (should not happen for a proper game) - } - currentMove = node.children[0] - } - - writeMoveNumber(moveNum, isWhite, subVariation, closedVariation, isRoot, sb) - - // Encode the move using your AlgebraicNotation. - writeMoveEncoding(node, currentMove, subVariation, sb) - - writeAnnotations(currentMove, sb) - - // TODO: Add support for all nags values in the future - - if len(node.children) > 1 || len(currentMove.children) > 0 { - sb.WriteString(" ") - } - // Process any variations (children beyond the first). - // In PGN, variations are enclosed in parentheses. - closedVar := writeVariations(node, moveNum, isWhite, sb) - - if len(currentMove.children) > 0 { - var nextMoveNum int - var nextIsWhite bool - if isWhite { - // After white's move, black plays using the same move number. - nextMoveNum = moveNum - nextIsWhite = false - } else { - // After black's move, increment move number. - nextMoveNum = moveNum + 1 - nextIsWhite = true - } - writeMoves(currentMove, nextMoveNum, nextIsWhite, sb, false, closedVar, - false) - } - - return trailingSpace -} - -func writeMoveNumber(moveNum int, isWhite bool, subVariation, closedVariation, - isRoot bool, sb *strings.Builder, -) { - if closedVariation { - sb.WriteString(" ") - } - if isWhite { - sb.WriteString(fmt.Sprintf("%d. ", moveNum)) - } else if subVariation || closedVariation || isRoot { - sb.WriteString(fmt.Sprintf("%d... ", moveNum)) - } -} - -func writeMoveEncoding(node *Move, currentMove *Move, subVariation bool, sb *strings.Builder) { - if subVariation && node.Parent() != nil { - moveStr := AlgebraicNotation{}.Encode(node.Parent().Position(), currentMove) - sb.WriteString(moveStr) - } else { - sb.WriteString(AlgebraicNotation{}.Encode(node.Position(), currentMove)) - } -} - -func sortedCommandKeys(commands map[string]string) []string { - keys := make([]string, 0, len(commands)) - for key := range commands { - keys = append(keys, key) - } - slices.Sort(keys) - return keys -} - -func writeAnnotations(move *Move, sb *strings.Builder) { - if move == nil { - return - } - - move.ensureCommentBlocksFromLegacy() - if len(move.commentBlocks) > 0 { - writeCommentBlocks(move.commentBlocks, sb) - return - } -} - -func writeCommentBlocks(blocks []CommentBlock, sb *strings.Builder) { - for _, block := range blocks { - if len(block.Items) == 0 { - continue - } - - sb.WriteString(" {") - for _, item := range block.Items { - switch item.Kind { - case CommentText: - sb.WriteString(item.Text) - case CommentCommand: - if needsCommandSeparator(sb) { - sb.WriteString(" ") - } - sb.WriteString("[%") - sb.WriteString(item.Key) - sb.WriteString(" ") - sb.WriteString(item.Value) - sb.WriteString("]") - } - } - sb.WriteString("}") - } -} - -func needsCommandSeparator(sb *strings.Builder) bool { - s := sb.String() - return len(s) > 0 && s[len(s)-1] != ' ' + return DefaultPGNRenderer.Render(g) } -func writeVariations(node *Move, moveNum int, isWhite bool, sb *strings.Builder) bool { - wroteAtLeastOneVar := false - - if len(node.children) > 1 { - for i := 1; i < len(node.children); i++ { - if wroteAtLeastOneVar { - sb.WriteString(" ") - } - wroteAtLeastOneVar = true - - variation := node.children[i] - sb.WriteString("(") - writeMoves(variation, moveNum, isWhite, sb, true, false, false) - sb.WriteString(")") - } - } - - return wroteAtLeastOneVar +// WritePGN writes the game's PGN to w. It delegates to DefaultPGNRenderer +// and returns any write error. +func (g *Game) WritePGN(w io.Writer) error { + return DefaultPGNRenderer.RenderGameTo(g, w) } // MarshalText implements the encoding.TextMarshaler interface and @@ -671,69 +249,15 @@ func (g *Game) MarshalText() ([]byte, error) { func (g *Game) UnmarshalText(text []byte) error { r := bytes.NewReader(text) - toGame, err := PGN(r) + game, err := ParsePGN(r) if err != nil { - return err + return fmt.Errorf("chess: unmarshal game PGN: %w", err) } - toGame(g) + g.copy(game) return nil } -// Draw attempts to draw the game by the given method. If the -// method is valid, then the game is updated to a draw by that -// method. If the method isn't valid then an error is returned. -func (g *Game) Draw(method Method) error { - const halfMoveClockForFiftyMoveRule = 100 - const numOfRepetitionsForThreefoldRepetition = 3 - - switch method { - case ThreefoldRepetition: - if g.numOfRepetitions() < numOfRepetitionsForThreefoldRepetition { - return errors.New("chess: draw by ThreefoldRepetition requires at least three repetitions of the current board state") - } - case FiftyMoveRule: - if g.pos.halfMoveClock < halfMoveClockForFiftyMoveRule { - return errors.New("chess: draw by FiftyMoveRule requires a half move clock of 100 or greater") - } - case DrawOffer: - default: - return errors.New("chess: invalid draw method") - } - g.outcome = Draw - g.method = method - return nil -} - -// Resign resigns the game for the given color. If the game has -// already been completed then the game is not updated. -func (g *Game) Resign(color Color) { - if g.outcome != NoOutcome || color == NoColor { - return - } - if color == White { - g.outcome = BlackWon - } else { - g.outcome = WhiteWon - } - g.method = Resignation -} - -// EligibleDraws returns valid inputs for the Draw() method. -func (g *Game) EligibleDraws() []Method { - const halfMoveClockForFiftyMoveRule = 100 - const numOfRepetitionsForThreefoldRepetition = 3 - - draws := []Method{DrawOffer} - if g.numOfRepetitions() >= numOfRepetitionsForThreefoldRepetition { - draws = append(draws, ThreefoldRepetition) - } - if g.pos.halfMoveClock >= halfMoveClockForFiftyMoveRule { - draws = append(draws, FiftyMoveRule) - } - return draws -} - // AddTagPair adds or updates a tag pair with the given key and // value and returns true if the value is overwritten. func (g *Game) AddTagPair(k, v string) bool { @@ -765,55 +289,13 @@ func (g *Game) RemoveTagPair(k string) bool { return false } -// evaluatePositionStatus updates the game's outcome and method based on the current position. -func (g *Game) evaluatePositionStatus() { - method := g.pos.Status() - switch method { - case Stalemate: - g.method = Stalemate - g.outcome = Draw - case Checkmate: - g.method = Checkmate - g.outcome = WhiteWon - if g.pos.Turn() == White { - g.outcome = BlackWon - } - } - if g.outcome != NoOutcome { - return - } - - // five fold rep creates automatic draw - if !g.ignoreFivefoldRepetitionDraw && g.numOfRepetitions() >= 5 { - g.outcome = Draw - g.method = FivefoldRepetition - } - - // 75 move rule creates automatic draw - if !g.ignoreSeventyFiveMoveRuleDraw && g.pos.halfMoveClock >= 150 && g.method != Checkmate { - g.outcome = Draw - g.method = SeventyFiveMoveRule - } - - // insufficient material creates automatic draw - if !g.ignoreInsufficientMaterialDraw && !g.pos.board.hasSufficientMaterial() { - g.outcome = Draw - g.method = InsufficientMaterial - } -} - // copy copies the game state from the given game. func (g *Game) copy(game *Game) { - g.tagPairs = make(map[string]string) - for k, v := range game.tagPairs { - g.tagPairs[k] = v - } - g.rootMove = game.rootMove - g.currentMove = game.currentMove - g.pos = game.pos + g.tagPairs = maps.Clone(game.tagPairs) + g.tree = game.tree g.outcome = game.outcome g.method = game.method - g.comments = game.Comments() + g.comments = copyComments(game.comments) g.ignoreFivefoldRepetitionDraw = game.ignoreFivefoldRepetitionDraw g.ignoreSeventyFiveMoveRuleDraw = game.ignoreSeventyFiveMoveRuleDraw g.ignoreInsufficientMaterialDraw = game.ignoreInsufficientMaterialDraw @@ -823,47 +305,16 @@ func (g *Game) copy(game *Game) { func (g *Game) Clone() *Game { ret := &Game{} ret.copy(g) - - // we have to also deep copy the moves so that modifications to the - // clone do not impact the parent - ret.rootMove = g.rootMove.Clone() - ret.rootMove.cloneChildren(g.rootMove.children) - if g.currentMove == nil { - ret.currentMove = ret.rootMove - } else { - ret.currentMove = findClonedMove(g.rootMove, ret.rootMove, g.currentMove) - if ret.currentMove == nil { - ret.currentMove = ret.rootMove - } - } - ret.pos = ret.currentMove.position + ret.tree = g.tree.Clone() return ret } -func findClonedMove(original, clone, target *Move) *Move { - if original == nil || clone == nil || target == nil { - return nil - } - if original == target { - return clone - } - for i, child := range original.children { - if i >= len(clone.children) { - return nil - } - if found := findClonedMove(child, clone.children[i], target); found != nil { - return found - } - } - return nil -} - // Positions returns all positions in the game in the main line. // This includes the starting position and all positions after each move. func (g *Game) Positions() []*Position { positions := make([]*Position, 0) - current := g.rootMove + current := g.tree.Root() for current != nil { if current.position != nil { @@ -880,312 +331,15 @@ func (g *Game) Positions() []*Position { func (g *Game) numOfRepetitions() int { count := 0 - for _, pos := range g.Positions() { - if pos == nil { - continue - } - if g.pos.samePosition(pos) { + pos := g.currentPosition() + for current := g.tree.Root(); current != nil; { + if current.position != nil && pos.SamePosition(current.position) { count++ } - } - return count -} - -// PushMoveOptions contains options for pushing a move to the game -type PushMoveOptions struct { - // ForceMainline makes this move the main line if variations exist - ForceMainline bool -} - -// Deprecated: use PushNotationMove instead. -// -// PushMove adds a move in algebraic notation to the game. -// Returns an error if the move is invalid. -// This method now validates moves for consistency with other move methods. -// -// Example: -// -// err := game.PushMove("e4", &PushMoveOptions{ForceMainline: true}) -func (g *Game) PushMove(algebraicMove string, options *PushMoveOptions) error { - return g.PushNotationMove(algebraicMove, AlgebraicNotation{}, options) -} - -// PushNotationMove adds a move to the game using any supported notation. -// It validates the move before adding it to ensure game correctness. -// For high-performance scenarios where moves are pre-validated, use UnsafePushNotationMove. -// -// Example: -// -// err := game.PushNotationMove("e4", chess.AlgebraicNotation{}, &PushMoveOptions{ForceMainline: true}) -// if err != nil { -// panic(err) -// } -// -// game.PushNotationMove("c7c5", chess.UCINotation{}, nil) -// game.PushNotationMove("Nc1f3", chess.LongAlgebraicNotation{}, nil) -func (g *Game) PushNotationMove(moveStr string, notation Notation, options *PushMoveOptions) error { - move, err := notation.Decode(g.pos, moveStr) - if err != nil { - return err - } - - return g.Move(move, options) -} - -// UnsafePushNotationMove adds a move to the game using any supported notation without validation. -// This method is intended for high-performance scenarios where moves are known to be valid. -// Use this method only when you have already validated the move or are certain it's legal. -// For general use, prefer PushNotationMove which includes validation. -// -// Example: -// -// // Only use when you're certain the move is valid -// err := game.UnsafePushNotationMove("e4", chess.AlgebraicNotation{}, nil) -// if err != nil { -// panic(err) // Should not happen with valid notation/moves -// } -func (g *Game) UnsafePushNotationMove(moveStr string, notation Notation, options *PushMoveOptions) error { - move, err := notation.Decode(g.pos, moveStr) - if err != nil { - return err - } - - return g.UnsafeMove(move, options) -} - -// Move method adds a move to the game using a Move struct. -// It returns an error if the move is invalid. -// This method validates the move before adding it to ensure game correctness. -// For high-performance scenarios where moves are pre-validated, use UnsafeMove. -// -// Example: -// -// possibleMove := game.ValidMoves()[0] -// -// err := game.Move(&possibleMove, nil) -// if err != nil { -// panic(err) -// } -func (g *Game) Move(move *Move, options *PushMoveOptions) error { - if options == nil { - options = &PushMoveOptions{} - } - - // Validate the move before adding it - if err := g.validateMove(move); err != nil { - return err - } - - return g.moveUnchecked(move, options) -} - -// UnsafeMove adds a move to the game without validation. -// This method is intended for high-performance scenarios where moves are known to be valid. -// Use this method only when you have already validated the move or are certain it's legal. -// For general use, prefer the Move method which includes validation. -// -// Example: -// -// // Only use when you're certain the move is valid -// validMoves := game.ValidMoves() -// move := &validMoves[0] // We know this is valid -// err := game.UnsafeMove(move, nil) -// if err != nil { -// panic(err) // Should not happen with valid moves -// } -func (g *Game) UnsafeMove(move *Move, options *PushMoveOptions) error { - if options == nil { - options = &PushMoveOptions{} - } - - return g.moveUnchecked(move, options) -} - -// moveUnchecked is the internal implementation that performs the move without validation. -// This is shared by both Move (after validation) and MoveUnchecked. -func (g *Game) moveUnchecked(move *Move, options *PushMoveOptions) error { - if move == nil { - return errors.New("move cannot be nil") - } - - existingMove := g.findExistingMove(move) - g.addOrReorderMove(move, existingMove, options.ForceMainline) - - g.updatePosition(move) - g.currentMove = move - - g.evaluatePositionStatus() - - return nil -} - -// validateMove checks if the given move is valid for the current position. -// It returns an error if the move is invalid. -func (g *Game) validateMove(move *Move) error { - if move == nil { - return errors.New("move cannot be nil") - } - - if g.pos == nil { - return errors.New("no current position") - } - - // Check if the move exists in the list of valid moves for the current position - validMoves := g.pos.ValidMovesUnsafe() - for _, validMove := range validMoves { - if validMove.s1 == move.s1 && validMove.s2 == move.s2 && validMove.promo == move.promo { - return nil // Move is valid - } - } - - return fmt.Errorf("move %s is not valid for the current position", move.String()) -} - -func (g *Game) findExistingMove(move *Move) *Move { - if g.currentMove == nil { - return nil - } - for _, child := range g.currentMove.children { - if child.s1 == move.s1 && child.s2 == move.s2 && child.promo == move.promo { - return child - } - } - return nil -} - -func (g *Game) addOrReorderMove(move, existingMove *Move, forceMainline bool) { - move.parent = g.currentMove - - if existingMove != nil { - if forceMainline && existingMove != g.currentMove.children[0] { - g.reorderMoveToFront(existingMove) - } - } else { - g.addNewMove(move, forceMainline) - } -} - -func (g *Game) reorderMoveToFront(move *Move) { - children := g.currentMove.children - for i, child := range children { - if child == move { - copy(children[1:i+1], children[:i]) - children[0] = move + if len(current.children) == 0 { break } + current = current.children[0] } -} - -func (g *Game) addNewMove(move *Move, forceMainline bool) { - if forceMainline { - g.currentMove.children = append([]*Move{move}, g.currentMove.children...) - } else { - g.currentMove.children = append(g.currentMove.children, move) - } -} - -func (g *Game) updatePosition(move *Move) { - if newPos := g.pos.Update(move); newPos != nil { - g.pos = newPos - move.position = newPos - } -} - -// Split takes a Game with a main line and 0 or more variations and returns a -// slice of Games (one for each variation), each containing exactly only a main -// line and 0 variations -func (g *Game) Split() []*Game { - // Collect all move paths starting from the root's children - var paths [][]*Move - for _, m := range g.rootMove.children { - paths = append(paths, collectPaths(m)...) - } - - // Build a Game for each path - var games []*Game - for _, path := range paths { - newG := g.buildOneGameFromPath(path) - games = append(games, newG) - } - - return games -} - -// collectPaths returns all paths from the given move to each leaf node. -// Each path is represented as a slice of *Move, starting with the given node -// and ending with a leaf (a move with no children). -func collectPaths(node *Move) [][]*Move { - if node == nil { - return nil - } - // If leaf, return a single path containing this node - if len(node.children) == 0 { - return [][]*Move{{node}} - } - // Otherwise, collect paths from each child and prepend this node - var paths [][]*Move - for _, c := range node.children { - childPaths := collectPaths(c) - for _, p := range childPaths { - path := append([]*Move{node}, p...) - paths = append(paths, path) - } - } - return paths -} - -func (g *Game) buildOneGameFromPath(path []*Move) *Game { - rootMove := &Move{position: g.rootMove.position.copy()} - cur := rootMove - - for _, m := range path { - child := m.Clone() - child.parent = cur - - cur.children = []*Move{child} - cur = child - } - - newG := &Game{} - newG.copy(g) - newG.rootMove = rootMove - newG.currentMove = cur - newG.pos = cur.position - - return newG -} - -// ValidateSAN checks if a string is valid Standard Algebraic Notation (SAN) syntax. -// This function only validates the syntax, not whether the move is legal in any position. -// Examples of valid SAN: "e4", "Nf3", "O-O", "Qxd2+", "e8=Q#" -func ValidateSAN(s string) error { - _, err := algebraicNotationParts(s) - return err -} - -// IgnoreFivefoldRepetitionDraw returns a Game option that disables automatic draws -// caused by the fivefold repetition rule. When applied, the game will not -// automatically end in a draw if the same position occurs five times. -func IgnoreFivefoldRepetitionDraw() func(*Game) { - return func(g *Game) { - g.ignoreFivefoldRepetitionDraw = true - } -} - -// IgnoreSeventyFiveMoveRuleDraw returns a Game option that disables automatic draws -// triggered by the seventy-five move rule. When applied, the game will not -// automatically end in a draw if one hundred fifty half-moves pass without a pawn move or capture. -func IgnoreSeventyFiveMoveRuleDraw() func(*Game) { - return func(g *Game) { - g.ignoreSeventyFiveMoveRuleDraw = true - } -} - -// IgnoreInsufficientMaterialDraw returns a Game option that disables automatic draws -// caused by insufficient material. When applied, the game will not automatically -// end in a draw even if checkmate is impossible with the remaining pieces. -func IgnoreInsufficientMaterialDraw() func(*Game) { - return func(g *Game) { - g.ignoreInsufficientMaterialDraw = true - } + return count } diff --git a/game_invariants_test.go b/game_invariants_test.go index 6575d3f5..cc205c28 100644 --- a/game_invariants_test.go +++ b/game_invariants_test.go @@ -5,76 +5,122 @@ import ( "testing" ) -func assertGameCurrentPositionInvariant(t *testing.T, g *Game) { +func assertGameTreeCurrentPositionInvariant(t *testing.T, g *Game) { t.Helper() if g == nil { t.Fatal("game is nil") } - if g.pos == nil { - t.Fatal("game current position is nil") + if g.currentPosition() == nil { + t.Fatal("tree current position is nil") } if g.Position() == nil { t.Fatal("Position() is nil") } - if g.CurrentPosition() == nil { - t.Fatal("CurrentPosition() is nil") + if g.MoveTree().Current().Position() == nil { + t.Fatal("MoveTree().Current().Position() is nil") } - if g.Position().String() != g.pos.String() { - t.Fatalf("Position() = %q, want game current position %q", g.Position(), g.pos) + if g.Position().String() != g.currentPosition().String() { + t.Fatalf("Position() = %q, want tree current position %q", g.Position(), g.currentPosition()) } - if g.CurrentPosition().String() != g.pos.String() { - t.Fatalf("CurrentPosition() = %q, want game current position %q", g.CurrentPosition(), g.pos) + if g.MoveTree().Current().Position().String() != g.currentPosition().String() { + t.Fatalf("MoveTree().Current().Position() = %q, want tree current position %q", g.MoveTree().Current().Position(), g.currentPosition()) } - if g.currentMove != nil && g.currentMove.position != nil && g.currentMove.position.String() != g.pos.String() { - t.Fatalf("current move position = %q, want game current position %q", g.currentMove.position, g.pos) + if g.MoveTree().Current() != nil && g.MoveTree().Current().position != nil && g.MoveTree().Current().position.String() != g.currentPosition().String() { + t.Fatalf("current move position = %q, want tree current position %q", g.MoveTree().Current().position, g.currentPosition()) } } -func TestGameCurrentPositionInvariantAfterClonePreservesCursor(t *testing.T) { +func TestGameTreeCurrentPositionInvariantAfterClonePreservesCursor(t *testing.T) { g := NewGame() for _, move := range []string{"e4", "e5", "Nf3"} { - if err := g.PushMove(move, nil); err != nil { + if _, err := g.PushMove(move, nil); err != nil { t.Fatal(err) } } - if !g.GoBack() { + if !g.MoveTree().GoBack() { t.Fatal("expected to navigate back") } - want := g.CurrentPosition().String() + want := g.MoveTree().Current().Position().String() clone := g.Clone() - assertGameCurrentPositionInvariant(t, clone) - if got := clone.CurrentPosition().String(); got != want { + assertGameTreeCurrentPositionInvariant(t, clone) + if got := clone.MoveTree().Current().Position().String(); got != want { t.Fatalf("clone current position = %q, want %q", got, want) } } -func TestGameCurrentPositionInvariantAfterDirectGameOperations(t *testing.T) { +func TestGameTreeCurrentPositionInvariantAfterDirectGameOperations(t *testing.T) { g := NewGame() - assertGameCurrentPositionInvariant(t, g) + assertGameTreeCurrentPositionInvariant(t, g) for _, move := range []string{"e4", "e5", "Nf3"} { - if err := g.PushMove(move, nil); err != nil { + if _, err := g.PushMove(move, nil); err != nil { t.Fatal(err) } - assertGameCurrentPositionInvariant(t, g) + assertGameTreeCurrentPositionInvariant(t, g) } - if !g.GoBack() { + if !g.MoveTree().GoBack() { t.Fatal("expected to navigate back") } - assertGameCurrentPositionInvariant(t, g) + assertGameTreeCurrentPositionInvariant(t, g) - if !g.GoForward() { + if !g.MoveTree().GoForward() { t.Fatal("expected to navigate forward") } - assertGameCurrentPositionInvariant(t, g) + assertGameTreeCurrentPositionInvariant(t, g) } -func TestGameCurrentPositionInvariantAfterPGNParse(t *testing.T) { +func TestPositionReturnsDefensiveCopyAfterGoForward(t *testing.T) { + g := NewGame() + for _, move := range []string{"e4", "e5"} { + if _, err := g.PushMove(move, nil); err != nil { + t.Fatal(err) + } + } + + if !g.MoveTree().GoBack() { + t.Fatal("expected to navigate back") + } + if !g.MoveTree().GoForward() { + t.Fatal("expected to navigate forward") + } + if g.Position() == g.MoveTree().Current().position { + t.Fatal("Position shared current position pointer with move node") + } + assertGameTreeCurrentPositionInvariant(t, g) +} + +func TestAddVariationStoresPosition(t *testing.T) { + g := NewGame() + move, err := algebraicNotation{}.Decode(g.Position(), "e4") + if err != nil { + t.Fatal(err) + } + + if _, err := g.MoveTree().AddVariation(nil, move); err != nil { + t.Fatal(err) + } + children := g.MoveTree().Root().Children() + if len(children) != 1 { + t.Fatalf("root children = %d, want 1", len(children)) + } + if children[0].Position() == nil { + t.Fatal("variation position is nil") + } + if got, want := children[0].Position().String(), g.Position().Update(move).String(); got != want { + t.Fatalf("variation position = %q, want %q", got, want) + } + if !g.MoveTree().GoForward() { + t.Fatal("expected to navigate into variation") + } + assertGameTreeCurrentPositionInvariant(t, g) +} + +func TestGameTreeCurrentPositionInvariantAfterPGNParse(t *testing.T) { opt, err := PGN(strings.NewReader("1. e4 e5 2. Nf3 *")) if err != nil { t.Fatal(err) @@ -82,21 +128,38 @@ func TestGameCurrentPositionInvariantAfterPGNParse(t *testing.T) { g := NewGame(opt) - assertGameCurrentPositionInvariant(t, g) + assertGameTreeCurrentPositionInvariant(t, g) +} + +func TestGameTreeCurrentPositionInvariantAfterPGNParseWithNestedVariations(t *testing.T) { + opt, err := PGN(strings.NewReader("1. e4 (1. d4 d5 (1... Nf6)) e5 2. Nf3 *")) + if err != nil { + t.Fatal(err) + } + + g := NewGame(opt) + + assertGameTreeCurrentPositionInvariant(t, g) + if !g.IsAtEnd() { + t.Fatalf("parsed tree current = %s, want main-line leaf", g.MoveTree().Current().Move()) + } + if got, want := len(g.MoveTree().Variations(g.MoveTree().Root())), 1; got != want { + t.Fatalf("root variations = %d, want %d", got, want) + } } -func TestGameCurrentPositionInvariantAfterSplitUsesLineLeaf(t *testing.T) { +func TestGameTreeCurrentPositionInvariantAfterSplitUsesLineLeaf(t *testing.T) { g := NewGame() for _, move := range []string{"e4", "e5", "Nf3"} { - if err := g.PushMove(move, nil); err != nil { + if _, err := g.PushMove(move, nil); err != nil { t.Fatal(err) } } - if !g.GoBack() { + if !g.MoveTree().GoBack() { t.Fatal("expected to navigate back before adding a variation") } - if err := g.PushMove("Nc3", nil); err != nil { + if _, err := g.PushMove("Nc3", nil); err != nil { t.Fatal(err) } @@ -105,9 +168,9 @@ func TestGameCurrentPositionInvariantAfterSplitUsesLineLeaf(t *testing.T) { t.Fatalf("split game count = %d, want 2", len(splitGames)) } for _, splitGame := range splitGames { - assertGameCurrentPositionInvariant(t, splitGame) + assertGameTreeCurrentPositionInvariant(t, splitGame) if !splitGame.IsAtEnd() { - t.Fatalf("split game current position = %q, want leaf position", splitGame.CurrentPosition()) + t.Fatalf("split tree current position = %q, want leaf position", splitGame.MoveTree().Current().Position()) } } } diff --git a/game_move.go b/game_move.go new file mode 100644 index 00000000..f2c6881d --- /dev/null +++ b/game_move.go @@ -0,0 +1,170 @@ +package chess + +import ( + "cmp" + "errors" + "fmt" +) + +// PushMove adds a move in algebraic notation to the game. +// Returns an error if the move is invalid. +// This method now validates moves for consistency with other move methods. +// +// Example: +// +// node, err := game.PushMove("e4", &MoveInsertOptions{PromoteToMainLine: true}) +func (g *Game) PushMove(algebraicMove string, options *MoveInsertOptions) (*MoveNode, error) { + return g.PushMoveText(algebraicMove, SAN(), options) +} + +// PushMoveText adds a move to the game using an explicit move text codec. +// It decodes against the current position so the inserted move carries +// position-derived tags. +func (g *Game) PushMoveText(moveText string, codec MoveTextCodec, options *MoveInsertOptions) (*MoveNode, error) { + move, err := codec.Decode(g.currentPosition(), moveText) + if err != nil { + return nil, fmt.Errorf("chess: decode %s move text %q: %w", codec, moveText, err) + } + + return g.Move(move, options) +} + +// UnsafePushMoveText adds fully specified move text without legal move +// verification. It supports only codecs that can raw-decode a move without SAN +// resolution. +func (g *Game) UnsafePushMoveText(moveText string, codec MoveTextCodec, options *MoveInsertOptions) (*MoveNode, error) { + raw, err := codec.DecodeRaw(moveText) + if err != nil { + if errors.Is(err, ErrMoveTextUnsupportedRawDecode) { + return nil, fmt.Errorf("%w: %s", ErrUnsafeMoveTextUnsupported, codec) + } + return nil, fmt.Errorf("chess: decode raw %s move text %q: %w", codec, moveText, err) + } + + move := raw.Move() + if !move.HasTag(Null) { + pos := g.currentPosition() + if pos == nil { + return nil, ErrMoveTextMissingPosition + } + move.tags = moveTags(move, pos) + } + + return g.UnsafeMove(move, options) +} + +// Move method adds a move to the game using a Move struct. +// It returns an error if the move is invalid. +// This method validates the move before adding it to ensure game correctness. +// For high-performance scenarios where moves are pre-validated, use UnsafeMove. +// +// Example: +// +// possibleMove := game.ValidMoves()[0] +// +// err := game.Move(possibleMove, nil) +// if err != nil { +// panic(err) +// } +func (g *Game) Move(move Move, options *MoveInsertOptions) (*MoveNode, error) { + options = cmp.Or(options, &MoveInsertOptions{}) + + // Validate the move before adding it + if err := g.validateMove(move); err != nil { + return nil, err + } + + return g.moveUnchecked(move, options) +} + +// UnsafeMove adds a move to the game without validation. +// This method is intended for high-performance scenarios where moves are known to be valid. +// Use this method only when you have already validated the move or are certain it's legal. +// For general use, prefer the Move method which includes validation. +// +// Example: +// +// // Only use when you're certain the move is valid +// validMoves := game.ValidMoves() +// move := validMoves[0] // We know this is valid +// err := game.UnsafeMove(move, nil) +// if err != nil { +// panic(err) // Should not happen with valid moves +// } +func (g *Game) UnsafeMove(move Move, options *MoveInsertOptions) (*MoveNode, error) { + options = cmp.Or(options, &MoveInsertOptions{}) + + return g.moveUnchecked(move, options) +} + +// moveUnchecked is the internal implementation that performs the move without validation. +// This is shared by both Move (after validation) and MoveUnchecked. +func (g *Game) moveUnchecked(move Move, options *MoveInsertOptions) (*MoveNode, error) { + if g.outcome != NoOutcome { + return nil, ErrGameAlreadyEnded + } + + node, err := g.tree.addMove(move, options) + if err != nil { + return nil, err + } + + g.evaluatePositionStatus() + + return node, nil +} + +// NullMove appends a null move (a side-to-move flip with no piece movement) +// to the current position's main line. Null moves are never part of the +// legal moves returned by ValidMoves, so they cannot be inserted via Game.Move. +// Use this method or Game.UnsafeMove(NullMove()) to add one explicitly. +// +// Pass nil (or no argument) to use default options. +func (g *Game) NullMove(options ...*MoveInsertOptions) (*MoveNode, error) { + var opts *MoveInsertOptions + if len(options) > 0 { + opts = options[0] + } + return g.insertByMove(NewNullMove(), opts) +} + +// insertByMove is shared by UnsafeMove and NullMove. It validates only the +// structural preconditions (game in progress) and lets moveUnchecked handle +// tree wiring and position updates. +func (g *Game) insertByMove(move Move, options *MoveInsertOptions) (*MoveNode, error) { + options = cmp.Or(options, &MoveInsertOptions{}) + return g.moveUnchecked(move, options) +} + +// validateMove checks if the given move is valid for the current position. +// It returns an error if the move is invalid. +func (g *Game) validateMove(move Move) error { + pos := g.currentPosition() + if pos == nil { + return errors.New("no current position") + } + + // Null moves are never part of a position's legal moves; they must + // be inserted via UnsafeMove or NullMove. + if move.HasTag(Null) { + return fmt.Errorf("null move %s is not valid for the current position", move.String()) + } + + // Check if the move exists in the list of valid moves for the current position + validMoves := pos.ValidMovesUnsafe() + for _, validMove := range validMoves { + if validMove.s1 == move.s1 && validMove.s2 == move.s2 && validMove.promo == move.promo { + return nil // Move is valid + } + } + + return fmt.Errorf("move %s is not valid for the current position", move.String()) +} + +// ValidateSAN checks if a string is valid Standard Algebraic notation (SAN) syntax. +// This function only validates the syntax, not whether the move is legal in any position. +// Examples of valid SAN: "e4", "Nf3", "O-O", "Qxd2+", "e8=Q#". +func ValidateSAN(s string) error { + _, err := algebraicNotationParts(s) + return err +} diff --git a/game_move_text_test.go b/game_move_text_test.go new file mode 100644 index 00000000..0bd9e321 --- /dev/null +++ b/game_move_text_test.go @@ -0,0 +1,78 @@ +package chess_test + +import ( + "testing" + + "github.com/corentings/chess/v3" +) + +func TestGamePushMoveTextWithCodecs(t *testing.T) { + tests := []struct { + name string + codec chess.MoveTextCodec + text string + want string + }{ + { + name: "SAN", + codec: chess.SAN(), + text: "e4", + want: "1. e4 *", + }, + { + name: "UCI", + codec: chess.UCI(), + text: "e2e4", + want: "1. e4 *", + }, + { + name: "long algebraic", + codec: chess.LongAlgebraic(), + text: "e2e4", + want: "1. e4 *", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + game := chess.NewGame() + if _, err := game.PushMoveText(tt.text, tt.codec, nil); err != nil { + t.Fatalf("PushMoveText() error = %v", err) + } + if got := game.String(); got != tt.want { + t.Fatalf("game.String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGamePushMoveTextUsesGeneratedMoveTags(t *testing.T) { + game := chess.NewGame() + if _, err := game.PushMoveText("e2e4", chess.UCI(), nil); err != nil { + t.Fatalf("PushMoveText(e2e4) error = %v", err) + } + if _, err := game.PushMoveText("d7d5", chess.UCI(), nil); err != nil { + t.Fatalf("PushMoveText(d7d5) error = %v", err) + } + node, err := game.PushMoveText("e4d5", chess.UCI(), nil) + if err != nil { + t.Fatalf("PushMoveText(e4d5) error = %v", err) + } + if !node.Move().HasTag(chess.Capture) { + t.Fatalf("inserted move missing Capture tag: %v", node.Move()) + } + if got := game.String(); got != "1. e4 d5 2. exd5 *" { + t.Fatalf("game.String() = %q, want capture SAN", got) + } +} + +func TestGamePushMoveIsStrictSANShorthand(t *testing.T) { + opt, err := chess.FEN("6k1/4P3/8/8/8/8/8/4K3 w - - 0 1") + if err != nil { + t.Fatalf("FEN() error = %v", err) + } + game := chess.NewGame(opt) + if _, err := game.PushMove("e8Q", nil); err == nil { + t.Fatalf("PushMove accepted import-only SAN") + } +} diff --git a/game_outcome.go b/game_outcome.go new file mode 100644 index 00000000..605e2132 --- /dev/null +++ b/game_outcome.go @@ -0,0 +1,163 @@ +package chess + +import ( + "errors" + "fmt" +) + +// OutcomeMethodPair pairs an Outcome with the Method that produced it. +type OutcomeMethodPair struct { + Outcome Outcome + Method Method +} + +// SetOutcomeMethod sets the game's outcome and method together after validating +// that the pair is internally consistent. It returns an error for invalid pairs +// such as WhiteWon with Stalemate or Draw with Checkmate. It does not modify +// any Result tag in tagPairs. +func (g *Game) SetOutcomeMethod(pair OutcomeMethodPair) error { + if !validOutcomeMethodPair(pair) { + return fmt.Errorf("chess: invalid outcome/method pair: outcome=%s method=%s", pair.Outcome, pair.Method) + } + g.outcome = pair.Outcome + g.method = pair.Method + return nil +} + +func validOutcomeMethodPair(pair OutcomeMethodPair) bool { + if pair.Outcome == UnknownOutcome { + return false + } + switch pair.Outcome { + case NoOutcome: + return pair.Method == NoMethod + case WhiteWon, BlackWon: + switch pair.Method { + case NoMethod, Checkmate, Resignation: + return true + } + case Draw: + switch pair.Method { + case NoMethod, DrawOffer, Stalemate, ThreefoldRepetition, + FivefoldRepetition, FiftyMoveRule, SeventyFiveMoveRule, InsufficientMaterial: + return true + } + } + return false +} + +// ClearOutcome resets the game to NoOutcome/NoMethod. It does not modify any +// tag in tagPairs (including the Result tag): PGN tag metadata is treated as +// caller-owned data, separate from the live outcome. Callers that want PGN +// tag parity must update tagPairs["Result"] themselves, or rebuild from the +// game state via Split which does sync the tag. +func (g *Game) ClearOutcome() { + g.outcome = NoOutcome + g.method = NoMethod +} + +// Draw attempts to draw the game by the given method. If the +// method is valid, then the game is updated to a draw by that +// method. If the method isn't valid then an error is returned. +// Returns ErrGameAlreadyEnded if the game is already in a terminal state. +func (g *Game) Draw(method Method) error { + const halfMoveClockForFiftyMoveRule = 100 + const numOfRepetitionsForThreefoldRepetition = 3 + + if g.outcome != NoOutcome { + return ErrGameAlreadyEnded + } + switch method { + case ThreefoldRepetition: + if g.numOfRepetitions() < numOfRepetitionsForThreefoldRepetition { + return errors.New("chess: draw by ThreefoldRepetition requires at least three repetitions of the current board state") + } + case FiftyMoveRule: + if g.currentPosition().halfMoveClock < halfMoveClockForFiftyMoveRule { + return errors.New("chess: draw by FiftyMoveRule requires a half move clock of 100 or greater") + } + case DrawOffer: + default: + return errors.New("chess: invalid draw method") + } + g.outcome = Draw + g.method = method + return nil +} + +// Resign resigns the game for the given color. If the game has +// already been completed or the color is invalid, Resign returns an error +// and does not update the game. +func (g *Game) Resign(color Color) error { + if color == NoColor { + return errors.New("chess: cannot resign with NoColor") + } + if g.outcome != NoOutcome { + return ErrGameAlreadyEnded + } + if color == White { + g.outcome = BlackWon + } else { + g.outcome = WhiteWon + } + g.method = Resignation + return nil +} + +// EligibleDraws returns valid inputs for the Draw() method. +func (g *Game) EligibleDraws() []Method { + const halfMoveClockForFiftyMoveRule = 100 + const numOfRepetitionsForThreefoldRepetition = 3 + + draws := []Method{DrawOffer} + if g.numOfRepetitions() >= numOfRepetitionsForThreefoldRepetition { + draws = append(draws, ThreefoldRepetition) + } + if g.currentPosition().halfMoveClock >= halfMoveClockForFiftyMoveRule { + draws = append(draws, FiftyMoveRule) + } + return draws +} + +// evaluatePositionStatus updates the game's outcome and method based on the +// current position. It runs the Full outcome policy (all automatic draws, +// honouring the Game's ignore flags) and is called after every Move and FEN. +func (g *Game) evaluatePositionStatus() { + g.outcome, g.method = classifyOutcome(g.currentPosition(), g.numOfRepetitions(), fullOutcomeRules(g)) +} + +// evaluateTerminalPositionStatus updates only board-derived terminal outcomes. +// PGN parsing calls this once on the final main-line position with the +// Terminal-only policy (mate/stalemate only, no automatic draws) so that the +// Result tag and movetext token remain authoritative; resolveOutcome then +// arbitrates between board, tag and token. +func (g *Game) evaluateTerminalPositionStatus() { + g.outcome, g.method = classifyOutcome(g.currentPosition(), 0, outcomeRules{}) +} + +// IgnoreFivefoldRepetitionDraw returns a Game option that disables automatic draws +// caused by the fivefold repetition rule. When applied, the game will not +// automatically end in a draw if the same position occurs five times. +func IgnoreFivefoldRepetitionDraw() func(*Game) { + return func(g *Game) { + g.ignoreFivefoldRepetitionDraw = true + } +} + +// IgnoreSeventyFiveMoveRuleDraw returns a Game option that disables automatic draws +// triggered by the seventy-five move rule. When applied, the game will not +// automatically end in a draw if one hundred fifty half-moves pass without a pawn move or capture. +func IgnoreSeventyFiveMoveRuleDraw() func(*Game) { + return func(g *Game) { + g.ignoreSeventyFiveMoveRuleDraw = true + } +} + +// IgnoreInsufficientMaterialDraw returns a Game option that disables automatic draws +// caused by insufficient material. When applied, the game will not automatically +// end in a draw even if checkmate is impossible with the remaining pieces. +func IgnoreInsufficientMaterialDraw() func(*Game) { + return func(g *Game) { + g.ignoreInsufficientMaterialDraw = true + } +} diff --git a/game_outcome_method_test.go b/game_outcome_method_test.go new file mode 100644 index 00000000..df702e57 --- /dev/null +++ b/game_outcome_method_test.go @@ -0,0 +1,334 @@ +package chess_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func ExampleGame_SetOutcomeMethod() { + g := chess.NewGame() + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{ + Outcome: chess.WhiteWon, + Method: chess.Resignation, + }); err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println(g.Outcome(), g.Method()) + // Output: 1-0 Resignation +} + +func TestSetOutcomeMethod(t *testing.T) { + tests := []struct { + name string + pair chess.OutcomeMethodPair + wantErr bool + }{ + {"NoOutcome/NoMethod", chess.OutcomeMethodPair{chess.NoOutcome, chess.NoMethod}, false}, + {"NoOutcome/Checkmate", chess.OutcomeMethodPair{chess.NoOutcome, chess.Checkmate}, true}, + {"NoOutcome/Stalemate", chess.OutcomeMethodPair{chess.NoOutcome, chess.Stalemate}, true}, + {"NoOutcome/Resignation", chess.OutcomeMethodPair{chess.NoOutcome, chess.Resignation}, true}, + {"WhiteWon/NoMethod", chess.OutcomeMethodPair{chess.WhiteWon, chess.NoMethod}, false}, + {"WhiteWon/Checkmate", chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}, false}, + {"WhiteWon/Resignation", chess.OutcomeMethodPair{chess.WhiteWon, chess.Resignation}, false}, + {"WhiteWon/Stalemate", chess.OutcomeMethodPair{chess.WhiteWon, chess.Stalemate}, true}, + {"WhiteWon/DrawOffer", chess.OutcomeMethodPair{chess.WhiteWon, chess.DrawOffer}, true}, + {"WhiteWon/FiftyMoveRule", chess.OutcomeMethodPair{chess.WhiteWon, chess.FiftyMoveRule}, true}, + {"BlackWon/NoMethod", chess.OutcomeMethodPair{chess.BlackWon, chess.NoMethod}, false}, + {"BlackWon/Checkmate", chess.OutcomeMethodPair{chess.BlackWon, chess.Checkmate}, false}, + {"BlackWon/Resignation", chess.OutcomeMethodPair{chess.BlackWon, chess.Resignation}, false}, + {"BlackWon/Stalemate", chess.OutcomeMethodPair{chess.BlackWon, chess.Stalemate}, true}, + {"BlackWon/InsufficientMaterial", chess.OutcomeMethodPair{chess.BlackWon, chess.InsufficientMaterial}, true}, + {"Draw/NoMethod", chess.OutcomeMethodPair{chess.Draw, chess.NoMethod}, false}, + {"Draw/DrawOffer", chess.OutcomeMethodPair{chess.Draw, chess.DrawOffer}, false}, + {"Draw/Stalemate", chess.OutcomeMethodPair{chess.Draw, chess.Stalemate}, false}, + {"Draw/ThreefoldRepetition", chess.OutcomeMethodPair{chess.Draw, chess.ThreefoldRepetition}, false}, + {"Draw/FivefoldRepetition", chess.OutcomeMethodPair{chess.Draw, chess.FivefoldRepetition}, false}, + {"Draw/FiftyMoveRule", chess.OutcomeMethodPair{chess.Draw, chess.FiftyMoveRule}, false}, + {"Draw/SeventyFiveMoveRule", chess.OutcomeMethodPair{chess.Draw, chess.SeventyFiveMoveRule}, false}, + {"Draw/InsufficientMaterial", chess.OutcomeMethodPair{chess.Draw, chess.InsufficientMaterial}, false}, + {"Draw/Checkmate", chess.OutcomeMethodPair{chess.Draw, chess.Checkmate}, true}, + {"Draw/Resignation", chess.OutcomeMethodPair{chess.Draw, chess.Resignation}, true}, + {"UnknownOutcome/NoMethod", chess.OutcomeMethodPair{chess.UnknownOutcome, chess.NoMethod}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := chess.NewGame() + err := g.SetOutcomeMethod(tt.pair) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if g.Outcome() != chess.NoOutcome { + t.Errorf("Outcome = %v after rejection, want NoOutcome", g.Outcome()) + } + if g.Method() != chess.NoMethod { + t.Errorf("Method = %v after rejection, want NoMethod", g.Method()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if g.Outcome() != tt.pair.Outcome { + t.Errorf("Outcome = %v, want %v", g.Outcome(), tt.pair.Outcome) + } + if g.Method() != tt.pair.Method { + t.Errorf("Method = %v, want %v", g.Method(), tt.pair.Method) + } + }) + } +} + +func TestTerminalOutcomeGuards(t *testing.T) { + setups := map[string]func(t *testing.T, g *chess.Game){ + "via_SetOutcomeMethod": func(t *testing.T, g *chess.Game) { + t.Helper() + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}); err != nil { + t.Fatal(err) + } + }, + "via_Resign": func(t *testing.T, g *chess.Game) { + t.Helper() + if err := g.Resign(chess.White); err != nil { + t.Fatal(err) + } + }, + "via_Draw": func(t *testing.T, g *chess.Game) { + t.Helper() + if err := g.Draw(chess.DrawOffer); err != nil { + t.Fatal(err) + } + }, + } + actions := []struct { + name string + call func(g *chess.Game) error + }{ + {"Move", func(g *chess.Game) error { _, err := g.Move(g.ValidMoves()[0], nil); return err }}, + {"UnsafeMove", func(g *chess.Game) error { _, err := g.UnsafeMove(g.ValidMoves()[0], nil); return err }}, + {"PushMove", func(g *chess.Game) error { _, err := g.PushMove("e4", nil); return err }}, + {"PushMoveText", func(g *chess.Game) error { + _, err := g.PushMoveText("e4", chess.SAN(), nil) + return err + }}, + {"UnsafePushMoveText", func(g *chess.Game) error { + _, err := g.UnsafePushMoveText("e2e4", chess.UCI(), nil) + return err + }}, + {"Resign", func(g *chess.Game) error { return g.Resign(chess.Black) }}, + {"Draw", func(g *chess.Game) error { return g.Draw(chess.DrawOffer) }}, + } + for setupName, setup := range setups { + t.Run(setupName, func(t *testing.T) { + for _, act := range actions { + t.Run(act.name, func(t *testing.T) { + g := chess.NewGame() + setup(t, g) + err := act.call(g) + if !errors.Is(err, chess.ErrGameAlreadyEnded) { + t.Errorf("%s after terminal: error = %v, want ErrGameAlreadyEnded", act.name, err) + } + }) + } + }) + } +} + +func TestResignWithNoColorReturnsError(t *testing.T) { + g := chess.NewGame() + err := g.Resign(chess.NoColor) + if err == nil { + t.Fatal("expected error when resigning with NoColor, got nil") + } + if g.Outcome() != chess.NoOutcome { + t.Errorf("Outcome = %s, want NoOutcome", g.Outcome()) + } +} + +func TestResignSetsOpponentWinner(t *testing.T) { + g := chess.NewGame() + if err := g.Resign(chess.White); err != nil { + t.Fatal(err) + } + if g.Outcome() != chess.BlackWon { + t.Errorf("Outcome = %v, want BlackWon", g.Outcome()) + } + if g.Method() != chess.Resignation { + t.Errorf("Method = %v, want Resignation", g.Method()) + } +} + +func TestMoveAfterGoBackFromTerminalStillRejected(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}); err != nil { + t.Fatal(err) + } + if !g.MoveTree().GoBack() { + t.Fatal("GoBack failed") + } + if g.Outcome() != chess.WhiteWon { + t.Fatalf("outcome lost after GoBack: %s", g.Outcome()) + } + move := g.ValidMoves()[0] + _, err := g.Move(move, nil) + if !errors.Is(err, chess.ErrGameAlreadyEnded) { + t.Errorf("error = %v, want ErrGameAlreadyEnded", err) + } +} + +func TestAddVariationAllowedAfterTerminalOutcome(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}); err != nil { + t.Fatal(err) + } + if !g.MoveTree().GoBack() { + t.Fatal("GoBack failed") + } + variation := g.ValidMoves()[0] + g.MoveTree().AddVariation(g.MoveTree().Root(), variation) + if len(g.MoveTree().Root().Children()) == 0 { + t.Error("AddVariation did not append child after terminal outcome") + } +} + +func TestClearOutcome(t *testing.T) { + g := chess.NewGame() + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}); err != nil { + t.Fatal(err) + } + g.ClearOutcome() + if g.Outcome() != chess.NoOutcome { + t.Errorf("Outcome after Clear = %s, want NoOutcome", g.Outcome()) + } + if g.Method() != chess.NoMethod { + t.Errorf("Method after Clear = %s, want NoMethod", g.Method()) + } + g.ClearOutcome() + if g.Outcome() != chess.NoOutcome || g.Method() != chess.NoMethod { + t.Error("ClearOutcome should be idempotent") + } +} + +func TestClearOutcomeDoesNotModifyResultTag(t *testing.T) { + g := chess.NewGame() + g.AddTagPair("Result", "1-0") + if err := g.SetOutcomeMethod(chess.OutcomeMethodPair{chess.WhiteWon, chess.Checkmate}); err != nil { + t.Fatal(err) + } + g.ClearOutcome() + if got := g.GetTagPair("Result"); got != "1-0" { + t.Errorf("Result tag after ClearOutcome = %q, want 1-0", got) + } +} + +func TestTagPairLifecycle(t *testing.T) { + g := chess.NewGame() + if g.AddTagPair("Event", "E1") { + t.Error("first AddTagPair should return false (no prior value)") + } + if got := g.GetTagPair("Event"); got != "E1" { + t.Errorf("GetTagPair = %q, want E1", got) + } + if !g.AddTagPair("Event", "E2") { + t.Error("overwriting AddTagPair should return true") + } + if got := g.GetTagPair("Event"); got != "E2" { + t.Errorf("GetTagPair after overwrite = %q, want E2", got) + } + if !g.RemoveTagPair("Event") { + t.Error("RemoveTagPair on existing key should return true") + } + if got := g.GetTagPair("Event"); got != "" { + t.Errorf("GetTagPair after remove = %q, want empty", got) + } + if g.RemoveTagPair("Event") { + t.Error("RemoveTagPair on absent key should return false") + } + if g.RemoveTagPair("NeverExisted") { + t.Error("RemoveTagPair on never-existing key should return false") + } +} + +func TestEligibleDraws_FreshGameReturnsOnlyDrawOffer(t *testing.T) { + g := chess.NewGame() + draws := g.EligibleDraws() + if len(draws) != 1 || draws[0] != chess.DrawOffer { + t.Errorf("fresh game EligibleDraws = %v, want [DrawOffer]", draws) + } +} + +func TestEligibleDraws_IncludesThreefoldRepetitionWhenConditionsMet(t *testing.T) { + g := chess.NewGame() + for _, mv := range []string{"Nf3", "Nf6", "Ng1", "Ng8", "Nf3", "Nf6", "Ng1", "Ng8"} { + if _, err := g.PushMove(mv, nil); err != nil { + t.Fatal(err) + } + } + draws := g.EligibleDraws() + found := false + for _, d := range draws { + if d == chess.ThreefoldRepetition { + found = true + } + } + if !found { + t.Errorf("EligibleDraws = %v, want to include ThreefoldRepetition", draws) + } +} + +func TestDraw_RejectsInvalidMethod(t *testing.T) { + tests := []struct { + name string + method chess.Method + wantSub string + }{ + {"no_method", chess.NoMethod, "invalid draw method"}, + {"checkmate", chess.Checkmate, "invalid draw method"}, + {"resignation", chess.Resignation, "invalid draw method"}, + {"stalemate", chess.Stalemate, "invalid draw method"}, + {"fivefold_repetition", chess.FivefoldRepetition, "invalid draw method"}, + {"seventyfive_move_rule", chess.SeventyFiveMoveRule, "invalid draw method"}, + {"insufficient_material", chess.InsufficientMaterial, "invalid draw method"}, + {"threefold_repetition_unmet", chess.ThreefoldRepetition, "ThreefoldRepetition"}, + {"fifty_move_rule_unmet", chess.FiftyMoveRule, "FiftyMoveRule"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := chess.NewGame() + err := g.Draw(tt.method) + if err == nil { + t.Fatalf("Draw(%s) = nil, want error", tt.method) + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("Draw(%s) error = %q, want substring %q", tt.method, err.Error(), tt.wantSub) + } + if g.Outcome() != chess.NoOutcome { + t.Errorf("Outcome = %v after rejected Draw, want NoOutcome", g.Outcome()) + } + }) + } +} + +func TestDrawOfferSucceeds(t *testing.T) { + g := chess.NewGame() + if err := g.Draw(chess.DrawOffer); err != nil { + t.Fatalf("Draw(DrawOffer) = %v, want nil", err) + } + if g.Outcome() != chess.Draw { + t.Errorf("Outcome = %v, want Draw", g.Outcome()) + } + if g.Method() != chess.DrawOffer { + t.Errorf("Method = %v, want DrawOffer", g.Method()) + } +} diff --git a/game_split.go b/game_split.go new file mode 100644 index 00000000..f4317c1e --- /dev/null +++ b/game_split.go @@ -0,0 +1,56 @@ +package chess + +// Split takes a Game with a main line and 0 or more variations and returns a +// slice of Games (one for each variation), each containing exactly only a main +// line and 0 variations. +func (g *Game) Split() []*Game { + // Build a Game for each path + var games []*Game + for _, path := range g.tree.Lines() { + newG := g.buildOneGameFromPath(path) + games = append(games, newG) + } + + return games +} + +func (g *Game) buildOneGameFromPath(path []*MoveNode) *Game { + rootMove := &MoveNode{position: g.tree.Root().position.copy()} + cur := rootMove + + for _, m := range path { + child := &MoveNode{ + move: m.move, + position: m.position.copy(), + number: m.number, + nags: append([]string(nil), m.nags...), + commentBlocks: copyCommentBlocks(m.commentBlocks), + } + child.parent = cur + + cur.children = []*MoveNode{child} + cur = child + } + + newG := &Game{} + newG.copy(g) + newG.tree = &MoveTree{root: rootMove, current: cur} + + // Discard any manual outcome inherited from the parent game and recompute + // from the leaf using the Full policy (all automatic draws, honouring the + // ignore flags). Split is the only path that syncs the Result tag, because + // each split game is built from scratch and must carry a self-consistent + // tag for PGN serialization. + newG.outcome, newG.method = classifyOutcome(newG.currentPosition(), newG.numOfRepetitions(), fullOutcomeRules(newG)) + newG.syncResultTag() + + return newG +} + +func (g *Game) syncResultTag() { + if g.outcome == NoOutcome { + delete(g.tagPairs, "Result") + return + } + g.tagPairs["Result"] = string(g.outcome) +} diff --git a/game_split_recompute_test.go b/game_split_recompute_test.go new file mode 100644 index 00000000..5905d975 --- /dev/null +++ b/game_split_recompute_test.go @@ -0,0 +1,227 @@ +package chess_test + +import ( + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func assertNoVariations(t *testing.T, g *chess.Game) { + t.Helper() + var walk func(node *chess.MoveNode) + walk = func(node *chess.MoveNode) { + if children := node.Children(); len(children) > 1 { + t.Errorf("node has %d children, split games must have 0 variations", len(children)) + } + for _, c := range node.Children() { + walk(c) + } + } + walk(g.MoveTree().Root()) +} + +func TestSplit_DropsManualResignationOnDivergingLine(t *testing.T) { + g := chess.NewGame() + for _, m := range []string{"e4", "e5", "Nf3", "Nc6"} { + if _, err := g.PushMove(m, nil); err != nil { + t.Fatalf("push %s: %v", m, err) + } + } + if !g.MoveTree().GoBack() { + t.Fatal("GoBack failed") + } + if _, err := g.PushMove("d6", nil); err != nil { + t.Fatalf("push d6: %v", err) + } + if _, err := g.PushMove("d4", nil); err != nil { + t.Fatalf("push d4: %v", err) + } + if err := g.Resign(chess.White); err != nil { + t.Fatalf("Resign: %v", err) + } + + if g.Outcome() != chess.BlackWon || g.Method() != chess.Resignation { + t.Fatalf("parent outcome/method = %s/%s, want BlackWon/Resignation", g.Outcome(), g.Method()) + } + + splitGames := g.Split() + if len(splitGames) != 2 { + t.Fatalf("split game count = %d, want 2", len(splitGames)) + } + + for i, sg := range splitGames { + if sg.Outcome() != chess.NoOutcome { + t.Errorf("split[%d] outcome = %s, want NoOutcome (resignation is not recomputed)", i, sg.Outcome()) + } + if sg.Method() != chess.NoMethod { + t.Errorf("split[%d] method = %s, want NoMethod", i, sg.Method()) + } + assertNoVariations(t, sg) + } +} + +func TestSplit_RecomputesTerminalOutcomesFromLeaf(t *testing.T) { + tests := []struct { + name string + pgn string + wantOutcome chess.Outcome + wantMethod chess.Method + wantResult string + }{ + { + name: "checkmate", + pgn: "1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7# 1-0", + wantOutcome: chess.WhiteWon, + wantMethod: chess.Checkmate, + wantResult: "1-0", + }, + { + name: "stalemate", + pgn: `[Event "E"] +[Site "S"] +[Date "2026.06.19"] +[Round "1"] +[White "A"] +[Black "B"] +[Result "1/2-1/2"] + +1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6 1/2-1/2`, + wantOutcome: chess.Draw, + wantMethod: chess.Stalemate, + wantResult: "1/2-1/2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, err := chess.ParsePGN(strings.NewReader(tt.pgn)) + if err != nil { + t.Fatal(err) + } + + splitGames := g.Split() + if len(splitGames) == 0 { + t.Fatal("expected at least one split game") + } + + found := false + for _, sg := range splitGames { + if sg.Outcome() == tt.wantOutcome && sg.Method() == tt.wantMethod { + found = true + if got := sg.GetTagPair("Result"); got != tt.wantResult { + t.Errorf("Result tag = %q, want %q (Split must sync the Result tag)", got, tt.wantResult) + } + } + assertNoVariations(t, sg) + } + if !found { + t.Errorf("no split line recomputed to %s/%s", tt.wantOutcome, tt.wantMethod) + } + }) + } +} + +// TestSplit_HonoursIgnoreFlags pins the recomputeOutcomeFromLeaf bug fix: +// Split previously checked insufficient material without consulting the +// Game's ignore flag, so a parent game kept in progress by +// IgnoreInsufficientMaterialDraw was wrongly drawn after Split. +func TestSplit_HonoursIgnoreFlags(t *testing.T) { + fenOpt, err := chess.FEN("4k3/8/8/8/8/8/8/3K4 w - - 0 60") // KvK: insufficient material + if err != nil { + t.Fatal(err) + } + // The ignore option must precede FEN: FEN evaluates status while applied, + // so the flag has to be set first for the parent to stay in progress. + g := chess.NewGame(chess.IgnoreInsufficientMaterialDraw(), fenOpt) + if g.Outcome() != chess.NoOutcome { + t.Fatalf("parent outcome = %s, want NoOutcome (ignore flag honoured at FEN)", g.Outcome()) + } + // Split yields a game per line; push a quiet king move (position stays KvK + // insufficient) so there is a line to split. + if _, err := g.PushMove("Kd2", nil); err != nil { + t.Fatalf("push Kd2: %v", err) + } + if g.Outcome() != chess.NoOutcome { + t.Fatalf("parent outcome after move = %s, want NoOutcome", g.Outcome()) + } + + splitGames := g.Split() + if len(splitGames) != 1 { + t.Fatalf("split game count = %d, want 1", len(splitGames)) + } + sg := splitGames[0] + if sg.Outcome() != chess.NoOutcome || sg.Method() != chess.NoMethod { + t.Errorf("split outcome/method = %s/%s, want NoOutcome/NoMethod (ignore flag must carry through Split)", + sg.Outcome(), sg.Method()) + } + assertNoVariations(t, sg) +} + +// TestSplit_EmitsAutoDraws pins the observable change: Split now derives +// automatic draws (fivefold, seventy-five move rule) from the rebuilt main +// line. The deleted recomputeOutcomeFromLeaf only handled mate/stalemate and +// insufficient material, so these positions previously stayed NoOutcome. +func TestSplit_EmitsAutoDraws(t *testing.T) { + t.Run("seventy-five move rule", func(t *testing.T) { + // Start one half-move below the threshold with sufficient material + // (rook), then push a quiet rook move to reach clock 150. + fenOpt, err := chess.FEN("4k3/8/8/8/8/8/8/R3K3 w - - 149 60") + if err != nil { + t.Fatal(err) + } + g := chess.NewGame(fenOpt) + if g.Outcome() != chess.NoOutcome { + t.Fatalf("parent outcome at clock 149 = %s, want NoOutcome", g.Outcome()) + } + if _, err := g.PushMove("Ra2", nil); err != nil { + t.Fatalf("push Ra2: %v", err) + } + if g.Outcome() != chess.Draw || g.Method() != chess.SeventyFiveMoveRule { + t.Fatalf("parent outcome/method = %s/%s, want Draw/SeventyFiveMoveRule", g.Outcome(), g.Method()) + } + + splitGames := g.Split() + if len(splitGames) != 1 { + t.Fatalf("split game count = %d, want 1", len(splitGames)) + } + sg := splitGames[0] + if sg.Outcome() != chess.Draw || sg.Method() != chess.SeventyFiveMoveRule { + t.Errorf("split outcome/method = %s/%s, want Draw/SeventyFiveMoveRule", sg.Outcome(), sg.Method()) + } + if got := sg.GetTagPair("Result"); got != "1/2-1/2" { + t.Errorf("Result tag = %q, want \"1/2-1/2\" (Split must sync the tag)", got) + } + assertNoVariations(t, sg) + }) + + t.Run("fivefold repetition", func(t *testing.T) { + // Shuffle both knights out and back four times: the position returns + // to the start after each 4-ply cycle, giving five occurrences and an + // automatic fivefold-repetition draw. + g := chess.NewGame() + cycle := []string{"Nf3", "Nf6", "Ng1", "Ng8"} + for range 4 { + for _, m := range cycle { + if _, err := g.PushMove(m, nil); err != nil { + t.Fatalf("push %s: %v", m, err) + } + } + } + if g.Outcome() != chess.Draw || g.Method() != chess.FivefoldRepetition { + t.Fatalf("parent outcome/method = %s/%s, want Draw/FivefoldRepetition", g.Outcome(), g.Method()) + } + + splitGames := g.Split() + if len(splitGames) != 1 { + t.Fatalf("split game count = %d, want 1", len(splitGames)) + } + sg := splitGames[0] + if sg.Outcome() != chess.Draw || sg.Method() != chess.FivefoldRepetition { + t.Errorf("split outcome/method = %s/%s, want Draw/FivefoldRepetition", sg.Outcome(), sg.Method()) + } + if got := sg.GetTagPair("Result"); got != "1/2-1/2" { + t.Errorf("Result tag = %q, want \"1/2-1/2\" (Split must sync the tag)", got) + } + assertNoVariations(t, sg) + }) +} diff --git a/game_test.go b/game_test.go index 1fe3b9a0..33b50897 100644 --- a/game_test.go +++ b/game_test.go @@ -8,6 +8,32 @@ import ( "time" ) +func TestGameAccessorsReturnDefensiveCopies(t *testing.T) { + game := NewGame() + if _, err := game.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + game.MoveTree().AddVariation(nil, Move{s1: D2, s2: D4}) + + children := game.MoveTree().Root().Children() + children[0] = nil + if game.MoveTree().Root().children[0] == nil { + t.Fatal("Children returned mutable backing slice") + } + + variations := game.MoveTree().Variations(game.MoveTree().Root()) + variations[0] = nil + if game.MoveTree().Root().children[1] == nil { + t.Fatal("Variations returned mutable backing slice") + } + + pos := game.Position() + pos.board.mailbox[E4] = NoPiece + if got := game.Position().Board().Piece(E4); got != WhitePawn { + t.Fatalf("Position returned mutable game position, E4 = %v, want %v", got, WhitePawn) + } +} + func TestCheckmate(t *testing.T) { fenStr := "rn1qkbnr/pbpp1ppp/1p6/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 0 1" fen, err := FEN(fenStr) @@ -15,7 +41,7 @@ func TestCheckmate(t *testing.T) { t.Fatal(err) } g := NewGame(fen) - if err := g.PushMove("Qxf7#", nil); err != nil { + if _, err := g.PushMove("Qxf7#", nil); err != nil { t.Fatal(err) } if g.Method() != Checkmate { @@ -32,7 +58,7 @@ func TestCheckmate(t *testing.T) { t.Fatal(err) } g = NewGame(fen) - if err := g.PushMove("O-O-O", nil); err != nil { + if _, err := g.PushMove("O-O-O", nil); err != nil { t.Fatal(err) } t.Log(g.Position().String()) @@ -67,7 +93,7 @@ func TestStalemate(t *testing.T) { t.Fatal(err) } g := NewGame(fen) - if err := g.PushMove("Qb6", nil); err != nil { + if _, err := g.PushMove("Qb6", nil); err != nil { t.Fatal(err) } if g.Method() != Stalemate { @@ -86,7 +112,7 @@ func TestInvalidStalemate(t *testing.T) { t.Fatal(err) } g := NewGame(fen) - if err := g.PushMove("d8=Q", nil); err != nil { + if _, err := g.PushMove("d8=Q", nil); err != nil { t.Fatal(err) } if g.Outcome() != NoOutcome { @@ -101,7 +127,7 @@ func TestThreeFoldRepetition(t *testing.T) { "Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -120,7 +146,7 @@ func TestInvalidThreeFoldRepetition(t *testing.T) { "Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -138,7 +164,7 @@ func TestFiveFoldRepetition(t *testing.T) { "Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -156,7 +182,7 @@ func TestFiveFoldRepetitionIgnored(t *testing.T) { "Nf3", "Nf6", "Ng1", "Ng8", } for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -184,7 +210,7 @@ func TestInvalidFiftyMoveRule(t *testing.T) { func TestSeventyFiveMoveRule(t *testing.T) { fen, _ := FEN("2r3k1/1q1nbppp/r3p3/3pP3/pPpP4/P1Q2N2/2RN1PPP/2R4K b - b3 149 80") g := NewGame(fen) - if err := g.PushMove("Kf8", nil); err != nil { + if _, err := g.PushMove("Kf8", nil); err != nil { t.Fatal(err) } if g.Outcome() != Draw || g.Method() != SeventyFiveMoveRule { @@ -195,7 +221,7 @@ func TestSeventyFiveMoveRule(t *testing.T) { func TestSeventyFiveMoveRuleIgnored(t *testing.T) { fen, _ := FEN("2r3k1/1q1nbppp/r3p3/3pP3/pPpP4/P1Q2N2/2RN1PPP/2R4K b - b3 149 80") g := NewGame(fen, IgnoreSeventyFiveMoveRuleDraw()) - if err := g.PushMove("Kf8", nil); err != nil { + if _, err := g.PushMove("Kf8", nil); err != nil { t.Fatal(err) } if g.Outcome() == Draw && g.Method() == SeventyFiveMoveRule { @@ -295,11 +321,11 @@ func BenchmarkStalemateStatus(b *testing.B) { b.Fatal(err) } g := NewGame(fen) - if err := g.PushMove("Qb6", nil); err != nil { + if _, err := g.PushMove("Qb6", nil); err != nil { b.Fatal(err) } b.ResetTimer() - for n := 0; n < b.N; n++ { + for range b.N { g.Position().Status() } } @@ -311,11 +337,11 @@ func BenchmarkInvalidStalemateStatus(b *testing.B) { b.Fatal(err) } g := NewGame(fen) - if err := g.PushMove("d8=Q", nil); err != nil { + if _, err := g.PushMove("d8=Q", nil); err != nil { b.Fatal(err) } b.ResetTimer() - for n := 0; n < b.N; n++ { + for range b.N { g.Position().Status() } } @@ -328,83 +354,101 @@ func BenchmarkPositionHash(b *testing.B) { } g := NewGame(fen) b.ResetTimer() - for n := 0; n < b.N; n++ { + for range b.N { g.Position().ZobristHash() } } func TestAddVariationToEmptyParent(t *testing.T) { g := NewGame() - parent := &Move{} - newMove := &Move{} - g.AddVariation(parent, newMove) - if len(parent.children) != 1 || parent.children[0] != newMove { + parent := g.MoveTree().Root() + newMove := Move{s1: E2, s2: E4} + node, err := g.MoveTree().AddVariation(parent, newMove) + if err != nil { + t.Fatal(err) + } + if len(parent.children) != 1 || parent.children[0] != node || parent.children[0].move != newMove { t.Fatalf("expected newMove to be added to parent's children") } - if newMove.parent != parent { + if parent.children[0].parent != parent { t.Fatalf("expected newMove's parent to be set to parent") } } func TestAddVariationToNonEmptyParent(t *testing.T) { g := NewGame() - parent := &Move{children: []*Move{{}}} - newMove := &Move{} - g.AddVariation(parent, newMove) - if len(parent.children) != 2 || parent.children[1] != newMove { + parent := g.MoveTree().Root() + if _, err := g.MoveTree().AddVariation(parent, Move{s1: E2, s2: E4}); err != nil { + t.Fatal(err) + } + newMove := Move{s1: D2, s2: D4} + node, err := g.MoveTree().AddVariation(parent, newMove) + if err != nil { + t.Fatal(err) + } + if len(parent.children) != 2 || parent.children[1] != node || parent.children[1].move != newMove { t.Fatalf("expected newMove to be added to parent's children") } - if newMove.parent != parent { + if parent.children[1].parent != parent { t.Fatalf("expected newMove's parent to be set to parent") } } func TestAddVariationWithNilParent(t *testing.T) { g := NewGame() - newMove := &Move{} - defer func() { - if r := recover(); r == nil { - t.Fatalf("expected panic when parent is nil") - } - }() - g.AddVariation(nil, newMove) + newMove := Move{s1: E2, s2: E4} + if _, err := g.MoveTree().AddVariation(nil, newMove); err != nil { + t.Fatal(err) + } + if len(g.MoveTree().Root().children) != 1 { + t.Fatalf("expected variation attached to root, got %d children", len(g.MoveTree().Root().children)) + } + if g.MoveTree().Root().children[0].move != newMove { + t.Fatalf("expected variation move %v, got %v", newMove, g.MoveTree().Root().children[0].move) + } + if g.MoveTree().Root().children[0].parent != g.MoveTree().Root() { + t.Fatal("expected variation parent pointer to be the root move") + } } func TestNavigateToMainLineFromLeaf(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3", "Nc6", "Bb5"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - g.NavigateToMainLine() - if g.currentMove != g.rootMove.children[0] { + g.MoveTree().NavigateToMainLine() + if g.MoveTree().Current() != g.MoveTree().Root().children[0] { t.Fatalf("expected to navigate to main line root move") } } -func TestNavigateToMainLineFromBranch(t *testing.T) { +func TestNavigateToMainLineFromVariation(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3", "Nc6", "Bb5"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - variationMove := &Move{} - g.AddVariation(g.currentMove, variationMove) - g.currentMove = variationMove - g.NavigateToMainLine() - if g.currentMove != g.rootMove.children[0] { + variationMove := Move{s1: A7, s2: A6} + variationNode, err := g.MoveTree().AddVariation(g.MoveTree().Current(), variationMove) + if err != nil { + t.Fatal(err) + } + g.tree.setCurrent(variationNode) + g.MoveTree().NavigateToMainLine() + if g.MoveTree().Current() != g.MoveTree().Root().children[0] { t.Fatalf("expected to navigate to main line root move") } } func TestNavigateToMainLineFromRoot(t *testing.T) { g := NewGame() - g.NavigateToMainLine() - if g.currentMove != g.rootMove { + g.MoveTree().NavigateToMainLine() + if g.MoveTree().Current() != g.MoveTree().Root() { t.Fatalf("expected to stay at root move") } } @@ -413,24 +457,24 @@ func TestGoBackFromLeaf(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3", "Nc6", "Bb5"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - if !g.GoBack() { + if !g.MoveTree().GoBack() { t.Fatalf("expected to go back from leaf move") } - if g.currentMove != g.rootMove.children[0].children[0].children[0].children[0] { + if g.MoveTree().Current() != g.MoveTree().Root().children[0].children[0].children[0].children[0] { t.Fatalf("expected current move to be Bb5's parent") } } func TestGoBackFromRoot(t *testing.T) { g := NewGame() - if g.GoBack() { + if g.MoveTree().GoBack() { t.Fatalf("expected not to go back from root move") } - if g.currentMove != g.rootMove { + if g.MoveTree().Current() != g.MoveTree().Root() { t.Fatalf("expected to stay at root move") } } @@ -439,27 +483,27 @@ func TestGoBackFromMainLine(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - if !g.GoBack() { + if !g.MoveTree().GoBack() { t.Fatalf("expected to go back from main line move") } - if g.currentMove != g.rootMove.children[0].children[0] { + if g.MoveTree().Current() != g.MoveTree().Root().children[0].children[0] { t.Fatalf("expected current move to be e5's parent") } } func TestGoForwardFromRoot(t *testing.T) { g := NewGame() - _ = g.PushMove("e4", nil) - _ = g.PushMove("e5", nil) - g.currentMove = g.rootMove // Reset to root - if !g.GoForward() { + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e5", nil) + g.tree.setCurrent(g.MoveTree().Root()) // Reset to root + if !g.MoveTree().GoForward() { t.Fatalf("expected to go forward from root move") } - if g.currentMove != g.rootMove.children[0] { + if g.MoveTree().Current() != g.MoveTree().Root().children[0] { t.Fatalf("expected current move to be the first child of root move") } } @@ -468,35 +512,40 @@ func TestGoForwardFromLeaf(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3", "Nc6", "Bb5"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - if g.GoForward() { + if g.MoveTree().GoForward() { t.Fatalf("expected not to go forward from leaf move") } - if g.currentMove != g.rootMove.children[0].children[0].children[0].children[0].children[0] { + if g.MoveTree().Current() != g.MoveTree().Root().children[0].children[0].children[0].children[0].children[0] { t.Fatalf("expected current move to stay at leaf move") } } -func TestGoForwardFromBranch(t *testing.T) { +func TestGoForwardFromVariation(t *testing.T) { g := NewGame() moves := []string{"e4", "e5", "Nf3", "Nc6"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } - variationMove := &Move{} - g.AddVariation(g.currentMove, variationMove) - childMove := &Move{} // Add this line - g.AddVariation(variationMove, childMove) // Add this line - g.currentMove = variationMove - if !g.GoForward() { - t.Fatalf("expected to go forward from branch move") + variationMove := Move{s1: F1, s2: B5} + variationNode, err := g.MoveTree().AddVariation(g.MoveTree().Current(), variationMove) + if err != nil { + t.Fatal(err) + } + childMove := Move{s1: A7, s2: A6} + if _, err := g.MoveTree().AddVariation(variationNode, childMove); err != nil { + t.Fatal(err) } - if g.currentMove != childMove { // Change this line + g.tree.setCurrent(variationNode) + if !g.MoveTree().GoForward() { + t.Fatalf("expected to go forward from variation move") + } + if g.MoveTree().Current().move != childMove { t.Fatalf("expected current move to be the child of the variation move") } } @@ -510,7 +559,7 @@ func TestIsAtStartWhenAtRoot(t *testing.T) { func TestIsAtStartWhenNotAtRoot(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } if g.IsAtStart() { @@ -520,7 +569,7 @@ func TestIsAtStartWhenNotAtRoot(t *testing.T) { func TestIsAtEndWhenAtLeaf(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } if !g.IsAtEnd() { @@ -530,14 +579,14 @@ func TestIsAtEndWhenAtLeaf(t *testing.T) { func TestIsAtEndWhenNotAtLeaf(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } - if err := g.PushMove("e5", nil); err != nil { + if _, err := g.PushMove("e5", nil); err != nil { t.Fatal(err) } // Add this line to move back to a non-leaf position - g.GoBack() + g.MoveTree().GoBack() if g.IsAtEnd() { t.Fatalf("expected not to be at end when not at leaf move") } @@ -545,8 +594,8 @@ func TestIsAtEndWhenNotAtLeaf(t *testing.T) { func TestVariationsWithNoChildren(t *testing.T) { g := NewGame() - move := &Move{} - variations := g.Variations(move) + move := &MoveNode{} + variations := g.MoveTree().Variations(move) if variations != nil { t.Fatalf("expected no variations for move with no children") } @@ -554,8 +603,8 @@ func TestVariationsWithNoChildren(t *testing.T) { func TestVariationsWithOneChild(t *testing.T) { g := NewGame() - move := &Move{children: []*Move{{}}} - variations := g.Variations(move) + move := &MoveNode{children: []*MoveNode{{}}} + variations := g.MoveTree().Variations(move) if variations != nil { t.Fatalf("expected no variations for move with one child") } @@ -563,8 +612,8 @@ func TestVariationsWithOneChild(t *testing.T) { func TestVariationsWithMultipleChildren(t *testing.T) { g := NewGame() - move := &Move{children: []*Move{{}, {}}} - variations := g.Variations(move) + move := &MoveNode{children: []*MoveNode{{}, {}}} + variations := g.MoveTree().Variations(move) if len(variations) != 1 { t.Fatalf("expected one variation for move with multiple children") } @@ -572,7 +621,7 @@ func TestVariationsWithMultipleChildren(t *testing.T) { func TestVariationsWithNilMove(t *testing.T) { g := NewGame() - variations := g.Variations(nil) + variations := g.MoveTree().Variations(nil) if variations != nil { t.Fatalf("expected no variations for nil move") } @@ -616,13 +665,13 @@ func TestCommentsWithNilComments(t *testing.T) { func TestPushMove(t *testing.T) { tests := []struct { name string - setupMoves []string // Moves to set up the position - move string // Move to push - goBack bool // Whether to go back one move before pushing - options *PushMoveOptions // Options for the push - wantErr bool // Whether we expect an error - wantPosition string // Expected FEN after the move - checkMainline []string // Expected mainline moves in UCI notation + setupMoves []string // Moves to set up the position + move string // Move to push + goBack bool // Whether to go back one move before pushing + options *MoveInsertOptions // Options for the push + wantErr bool // Whether we expect an error + wantPosition string // Expected FEN after the move + checkMainline []string // Expected mainline moves in UCI notation }{ { name: "basic pawn move", @@ -648,7 +697,7 @@ func TestPushMove(t *testing.T) { setupMoves: []string{"e4", "e5", "Nf3"}, move: "Nc3", goBack: true, - options: &PushMoveOptions{}, + options: &MoveInsertOptions{}, wantPosition: "rnbqkbnr/pppp1ppp/8/4p3/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq - 1 2", checkMainline: []string{"e2e4", "e7e5", "g1f3"}, // Original mainline remains }, @@ -657,7 +706,7 @@ func TestPushMove(t *testing.T) { setupMoves: []string{"e4", "e5", "Nf3"}, move: "Nc3", goBack: true, - options: &PushMoveOptions{ForceMainline: true}, + options: &MoveInsertOptions{PromoteToMainLine: true}, wantPosition: "rnbqkbnr/pppp1ppp/8/4p3/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq - 1 2", checkMainline: []string{"e2e4", "e7e5", "b1c3"}, // New mainline }, @@ -666,7 +715,7 @@ func TestPushMove(t *testing.T) { setupMoves: []string{"e4", "e5", "Nf3"}, move: "Nf3", goBack: true, - options: &PushMoveOptions{}, + options: &MoveInsertOptions{}, wantPosition: "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", checkMainline: []string{"e2e4", "e7e5", "g1f3"}, }, @@ -700,19 +749,19 @@ func TestPushMove(t *testing.T) { // Setup moves for _, move := range tt.setupMoves { - err := game.PushMove(move, nil) + _, err := game.PushMove(move, nil) if err != nil { t.Fatalf("setup failed: %v", err) } } // Go back one move if needed for the test - if tt.goBack && game.currentMove != nil && game.currentMove.parent != nil { - game.GoBack() + if tt.goBack && game.MoveTree().Current() != nil && game.MoveTree().Current().parent != nil { + game.MoveTree().GoBack() } // Test the move - err := game.PushMove(tt.move, tt.options) + _, err := game.PushMove(tt.move, tt.options) // Check error expectation if (err != nil) != tt.wantErr { @@ -726,7 +775,7 @@ func TestPushMove(t *testing.T) { // Check position if tt.wantPosition != "" { - gotFEN := game.pos.String() + gotFEN := game.currentPosition().String() if gotFEN != tt.wantPosition { t.Errorf("Position after move = %v, want %v", gotFEN, tt.wantPosition) } @@ -746,7 +795,7 @@ func TestPushMove(t *testing.T) { // Helper function to get the mainline moves from a game func getMainline(game *Game) []string { var moves []string - current := game.rootMove + current := game.MoveTree().Root() for len(current.children) > 0 { current = current.children[0] // Follow main line (first variation) @@ -757,20 +806,21 @@ func getMainline(game *Game) []string { } // Helper function to convert a move to algebraic notation -func algebraicMove(move *Move) string { +func algebraicMove(move *MoveNode) string { + mv := move.Move() // This is a simplified version - you might want to implement proper algebraic notation - if move.HasTag(KingSideCastle) { + if mv.HasTag(KingSideCastle) { return "O-O" } - if move.HasTag(QueenSideCastle) { + if mv.HasTag(QueenSideCastle) { return "O-O-O" } - s1 := move.s1.String() - s2 := move.s2.String() + s1 := mv.s1.String() + s2 := mv.s2.String() - if move.promo != NoPieceType { - return s1 + s2 + "=" + move.promo.String() + if mv.promo != NoPieceType { + return s1 + s2 + "=" + mv.promo.String() } return s1 + s2 @@ -791,18 +841,18 @@ func moveSlicesEqual(a, b []string) bool { func TestCopyGameState(t *testing.T) { original := NewGame() - _ = original.PushMove("e4", nil) - _ = original.PushMove("e5", nil) - _ = original.PushMove("Nf3", nil) + _, _ = original.PushMove("e4", nil) + _, _ = original.PushMove("e5", nil) + _, _ = original.PushMove("Nf3", nil) newGame := NewGame() newGame.copy(original) - if newGame.pos.String() != original.pos.String() { - t.Fatalf("expected position %s but got %s", original.pos.String(), newGame.pos.String()) + if newGame.currentPosition().String() != original.currentPosition().String() { + t.Fatalf("expected position %s but got %s", original.currentPosition().String(), newGame.currentPosition().String()) } - if newGame.currentMove != original.currentMove { - t.Fatalf("expected current move to be %v but got %v", original.currentMove, newGame.currentMove) + if newGame.MoveTree().Current() != original.MoveTree().Current() { + t.Fatalf("expected current move to be %v but got %v", original.MoveTree().Current(), newGame.MoveTree().Current()) } if newGame.outcome != original.outcome { t.Fatalf("expected outcome %s but got %s", original.outcome, newGame.outcome) @@ -841,22 +891,22 @@ func TestCopyGameStateWithTagPairs(t *testing.T) { func TestCloneGameState(t *testing.T) { original := NewGame() - _ = original.PushMove("e4", nil) - _ = original.PushMove("e5", nil) - _ = original.PushMove("Nf3", nil) + _, _ = original.PushMove("e4", nil) + _, _ = original.PushMove("e5", nil) + _, _ = original.PushMove("Nf3", nil) clone := original.Clone() - if clone.pos.String() != original.pos.String() { - t.Fatalf("expected position %s but got %s", original.pos.String(), clone.pos.String()) + if clone.currentPosition().String() != original.currentPosition().String() { + t.Fatalf("expected position %s but got %s", original.currentPosition().String(), clone.currentPosition().String()) } - if clone.currentMove.String() != original.currentMove.String() { - t.Fatalf("expected current move to be %v but got %v", original.currentMove, clone.currentMove) + if clone.MoveTree().Current().Move().String() != original.MoveTree().Current().Move().String() { + t.Fatalf("expected current move to be %v but got %v", original.MoveTree().Current(), clone.MoveTree().Current()) } - if clone.currentMove == original.currentMove { + if clone.MoveTree().Current() == original.MoveTree().Current() { t.Errorf("clone failed to deep copy currentMove") } - if clone.rootMove == original.rootMove { + if clone.MoveTree().Root() == original.MoveTree().Root() { t.Errorf("clone failed to deep copy rootMove") } if clone.outcome != original.outcome { @@ -870,11 +920,11 @@ func TestCloneGameState(t *testing.T) { } // make sure we can modify the clone without impact on the original - err := clone.PushMove("Nf6", nil) + _, err := clone.PushMove("Nf6", nil) if err != nil { t.Fatalf("failed to push Nf6") } - if clone.pos.String() == original.pos.String() { + if clone.currentPosition().String() == original.currentPosition().String() { t.Error("modifying the clone incorrectly mutates the original position") } if len(clone.Moves()) == len(original.Moves()) { @@ -971,7 +1021,7 @@ func TestEligibleDrawsWithThreeRepetitions(t *testing.T) { g := NewGame() moves := []string{"Nf3", "Nf6", "Ng1", "Ng8", "Nf3", "Nf6", "Ng1", "Ng8", "Nf3", "Nf6"} for _, m := range moves { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -1078,8 +1128,8 @@ func TestPGNWithValidData(t *testing.T) { if len(g.Positions()) != 7 { t.Fatalf("expected 7 positions got %v", len(g.Positions())) } - if g.currentMove.String() != "a7a6" { - t.Fatalf("expected current move a7a6 but got %v", g.currentMove.String()) + if g.MoveTree().Current().Move().String() != "a7a6" { + t.Fatalf("expected current move a7a6 but got %v", g.MoveTree().Current().Move().String()) } } @@ -1124,7 +1174,7 @@ func TestGameString(t *testing.T) { name: "GameStringWithSingleMove", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e4", nil) return g }, expected: "1. e4 *", @@ -1133,9 +1183,9 @@ func TestGameString(t *testing.T) { name: "GameStringWithMultipleMoves", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - _ = g.PushMove("e5", nil) - _ = g.PushMove("Nf3", nil) + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e5", nil) + _, _ = g.PushMove("Nf3", nil) return g }, expected: "1. e4 e5 2. Nf3 *", @@ -1144,15 +1194,15 @@ func TestGameString(t *testing.T) { name: "GameStringWithLongerGame", setup: func() *Game { g := NewGame() - _ = g.PushMove("Nf3", nil) - _ = g.PushMove("Nc6", nil) - _ = g.PushMove("Nc3", nil) - _ = g.PushMove("e6", nil) - _ = g.PushMove("e4", nil) - _ = g.PushMove("a6", nil) - _ = g.PushMove("Ne2", nil) - _ = g.PushMove("Nf6", nil) - _ = g.PushMove("Ned4", nil) + _, _ = g.PushMove("Nf3", nil) + _, _ = g.PushMove("Nc6", nil) + _, _ = g.PushMove("Nc3", nil) + _, _ = g.PushMove("e6", nil) + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("a6", nil) + _, _ = g.PushMove("Ne2", nil) + _, _ = g.PushMove("Nf6", nil) + _, _ = g.PushMove("Ned4", nil) return g }, expected: "1. Nf3 Nc6 2. Nc3 e6 3. e4 a6 4. Ne2 Nf6 5. Ned4 *", @@ -1161,8 +1211,8 @@ func TestGameString(t *testing.T) { name: "GameStringWithComments", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - g.currentMove.comments = "Good move" + _, _ = g.PushMove("e4", nil) + g.MoveTree().Current().SetComment("Good move") return g }, expected: "1. e4 {Good move} *", @@ -1171,11 +1221,11 @@ func TestGameString(t *testing.T) { name: "GameStringWithVariations", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - _ = g.PushMove("e5", nil) - _ = g.PushMove("Nf3", nil) - g.GoBack() - _ = g.PushMove("Nc3", nil) + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e5", nil) + _, _ = g.PushMove("Nf3", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("Nc3", nil) return g }, expected: "1. e4 e5 2. Nf3 (2. Nc3) *", @@ -1221,9 +1271,9 @@ func TestGameString(t *testing.T) { name: "GameStringWithCommentsAndClock", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - g.currentMove.comments = "Good move" - g.currentMove.SetCommand("clk", "10:00:00") + _, _ = g.PushMove("e4", nil) + g.MoveTree().Current().SetComment("Good move") + g.MoveTree().Current().SetCommand("clk", "10:00:00") return g }, expected: "1. e4 {Good move [%clk 10:00:00]} *", @@ -1232,41 +1282,41 @@ func TestGameString(t *testing.T) { name: "GameStringWithCommandsOnly", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - g.currentMove.SetCommand("eval", "0.24") - g.currentMove.SetCommand("clk", "10:00:00") + _, _ = g.PushMove("e4", nil) + g.MoveTree().Current().SetCommand("eval", "0.24") + g.MoveTree().Current().SetCommand("clk", "10:00:00") return g }, - expected: "1. e4 { [%clk 10:00:00] [%eval 0.24]} *", + expected: "1. e4 { [%eval 0.24] [%clk 10:00:00]} *", }, { name: "GameStringWithCommentsAndMultipleCommands", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - g.currentMove.comments = "Good move" - g.currentMove.SetCommand("eval", "0.24") - g.currentMove.SetCommand("clk", "10:00:00") + _, _ = g.PushMove("e4", nil) + g.MoveTree().Current().SetComment("Good move") + g.MoveTree().Current().SetCommand("eval", "0.24") + g.MoveTree().Current().SetCommand("clk", "10:00:00") return g }, - expected: "1. e4 {Good move [%clk 10:00:00] [%eval 0.24]} *", + expected: "1. e4 {Good move [%eval 0.24] [%clk 10:00:00]} *", }, { name: "GameStringWithMultipleNestedVariations", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - _ = g.PushMove("e5", nil) - _ = g.PushMove("Nf3", nil) - g.GoBack() - _ = g.PushMove("Nc3", nil) - g.GoBack() - _ = g.PushMove("d4", nil) - _ = g.PushMove("d5", nil) - _ = g.PushMove("c4", nil) - g.GoBack() - _ = g.PushMove("c3", nil) - g.GoBack() + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e5", nil) + _, _ = g.PushMove("Nf3", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("Nc3", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("d4", nil) + _, _ = g.PushMove("d5", nil) + _, _ = g.PushMove("c4", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("c3", nil) + g.MoveTree().GoBack() return g }, expected: "1. e4 e5 2. Nf3 (2. Nc3) (2. d4 d5 3. c4 (3. c3)) *", @@ -1275,14 +1325,14 @@ func TestGameString(t *testing.T) { name: "GameStringWithVariationsForBlack", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - _ = g.PushMove("e5", nil) - _ = g.PushMove("Nf3", nil) - _ = g.PushMove("Nc6", nil) - _ = g.PushMove("Bb5", nil) - _ = g.PushMove("a6", nil) - g.GoBack() - _ = g.PushMove("d6", nil) + _, _ = g.PushMove("e4", nil) + _, _ = g.PushMove("e5", nil) + _, _ = g.PushMove("Nf3", nil) + _, _ = g.PushMove("Nc6", nil) + _, _ = g.PushMove("Bb5", nil) + _, _ = g.PushMove("a6", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("d6", nil) return g }, expected: "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 (3... d6) *", @@ -1291,9 +1341,9 @@ func TestGameString(t *testing.T) { name: "GameStringWithVariationsOnRoot", setup: func() *Game { g := NewGame() - _ = g.PushMove("e4", nil) - g.GoBack() - _ = g.PushMove("d4", nil) + _, _ = g.PushMove("e4", nil) + g.MoveTree().GoBack() + _, _ = g.PushMove("d4", nil) return g }, expected: "1. e4 (1. d4) *", @@ -1310,29 +1360,29 @@ func TestGameString(t *testing.T) { } } -func FuzzTestPushNotationMove(f *testing.F) { +func FuzzTestPushMoveText(f *testing.F) { f.Add("e2e4", 0) f.Add("e4", 1) f.Add("Nb1c3", 2) - f.Fuzz(func(t *testing.T, move string, notationType int) { + f.Fuzz(func(_ *testing.T, move string, codecType int) { game := NewGame() - var notation Notation - switch notationType % 3 { + var codec MoveTextCodec + switch codecType % 3 { case 0: - notation = UCINotation{} + codec = UCI() case 1: - notation = AlgebraicNotation{} + codec = SAN() case 2: - notation = LongAlgebraicNotation{} + codec = LongAlgebraic() } - _ = game.PushNotationMove(move, notation, nil) + _, _ = game.PushMoveText(move, codec, nil) }) } -func TestInvalidPushNotationMove(t *testing.T) { +func TestInvalidPushMoveText(t *testing.T) { fen := "r1bqk1nr/pp1pppbp/6p1/1Bp1P3/P2n1P2/2N2N2/1PPP2PP/R1BQK2R w KQkq - 0 1" bogusMv := "Kxh1" opt, err := FEN(fen) @@ -1341,17 +1391,17 @@ func TestInvalidPushNotationMove(t *testing.T) { } game := NewGame(opt) - err = game.PushNotationMove(bogusMv, UCINotation{}, nil) + _, err = game.PushMoveText(bogusMv, UCI(), nil) if err == nil { - t.Errorf("PushNotationMove() (uci) succeeded in pushing bogus mv when it should have failed") + t.Errorf("PushMoveText() (uci) succeeded in pushing bogus mv when it should have failed") } - err = game.PushNotationMove(bogusMv, AlgebraicNotation{}, nil) + _, err = game.PushMoveText(bogusMv, SAN(), nil) if err == nil { - t.Errorf("PushNotationMove() (alg) succeeded in pushing bogus mv when it should have failed") + t.Errorf("PushMoveText() (SAN) succeeded in pushing bogus mv when it should have failed") } } -func TestValidPushNotationMove(t *testing.T) { +func TestValidPushMoveText(t *testing.T) { pgn := strings.NewReader("1. e4 (1. g4) 1... c5 2. Nc3 Nc6 3. f4 g6 4. Nf3 Bg7 5. a4 Nf6 6. e5 *") mv := "Ng4" opt, err := PGN(pgn) @@ -1363,25 +1413,24 @@ func TestValidPushNotationMove(t *testing.T) { startMlen := len(game.Moves()) startPlen := len(game.Positions()) - err = game.PushNotationMove(mv, AlgebraicNotation{}, &PushMoveOptions{ - ForceMainline: true, + _, err = game.PushMoveText(mv, SAN(), &MoveInsertOptions{ + PromoteToMainLine: true, }) if err != nil { - t.Errorf("PushNotationMove() failed but should have succeeded") + t.Errorf("PushMoveText() failed but should have succeeded") } if len(game.Moves()) != startMlen+1 { - t.Errorf("PushNotationMove() failed to update game.Moves()") + t.Errorf("PushMoveText() failed to update game.Moves()") } if len(game.Positions()) != startPlen+1 { - t.Errorf("PushNotationMove() failed to update game.Positions()") + t.Errorf("PushMoveText() failed to update game.Positions()") } } func validateSplit(t *testing.T, origPgn string, expectedLastLines []string) { reader := strings.NewReader(origPgn) - scanner := NewScanner(reader) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(reader).Decode() if err != nil { t.Fatalf("fail to parse game: %s", err.Error()) } @@ -1437,7 +1486,7 @@ func TestRootMoveComments(t *testing.T) { game := NewGame() // Add a comment to the root move - root := game.GetRootMove() + root := game.MoveTree().Root() root.AddComment("This is a comment before the first move") // Add some moves @@ -1479,7 +1528,7 @@ func TestRootMoveComments(t *testing.T) { game := NewGame() // Add a comment to the root move - root := game.GetRootMove() + root := game.MoveTree().Root() root.AddComment("Comment on empty game") // Generate PGN @@ -1495,7 +1544,7 @@ func TestRootMoveComments(t *testing.T) { game := NewGame() // Add multiple comments to the root move - root := game.GetRootMove() + root := game.MoveTree().Root() root.AddComment("First comment. ") root.AddComment("Second comment.") @@ -1519,7 +1568,7 @@ func TestRootMoveComments(t *testing.T) { game.AddTagPair("Site", "Test Site") // Add a comment to the root move - root := game.GetRootMove() + root := game.MoveTree().Root() root.AddComment("Comment with tags") // Add some moves @@ -1565,13 +1614,13 @@ func TestRootMoveComments(t *testing.T) { game := NewGame() // Add a comment to the root move - root := game.GetRootMove() + root := game.MoveTree().Root() root.AddComment("Comment before variations") // Add moves and create variations game.PushMove("e4", nil) game.PushMove("e5", nil) - game.GoBack() + game.MoveTree().GoBack() game.PushMove("d5", nil) // Generate PGN @@ -1602,7 +1651,7 @@ func TestRootMoveComments(t *testing.T) { }) } -func TestWriteAnnotationsLegacyBranches(t *testing.T) { +func TestWriteAnnotations(t *testing.T) { t.Run("NilMove", func(t *testing.T) { var sb strings.Builder writeAnnotations(nil, &sb) @@ -1611,35 +1660,10 @@ func TestWriteAnnotationsLegacyBranches(t *testing.T) { } }) - t.Run("CommentOnly", func(t *testing.T) { - move := &Move{comments: "Good move"} - move.ensureCommentBlocksFromLegacy() - move.commentBlocks = nil - - var sb strings.Builder - writeAnnotations(move, &sb) - if sb.String() != " {Good move}" { - t.Fatalf("expected comment-only annotation, got %q", sb.String()) - } - }) - - t.Run("CommandOnly", func(t *testing.T) { - move := &Move{command: map[string]string{"clk": "0:05:00"}} - move.ensureCommentBlocksFromLegacy() - move.commentBlocks = nil - - var sb strings.Builder - writeAnnotations(move, &sb) - if sb.String() != " { [%clk 0:05:00]}" { - t.Fatalf("expected command-only annotation, got %q", sb.String()) - } - }) - - t.Run("CommentAndCommand", func(t *testing.T) { - move := &Move{comments: "Good move", command: map[string]string{"clk": "0:05:00"}} - move.ensureCommentBlocksFromLegacy() - move.commentBlocks = nil - + t.Run("StructuredCommentAndCommand", func(t *testing.T) { + move := &MoveNode{} + move.SetComment("Good move") + move.SetCommand("clk", "0:05:00") var sb strings.Builder writeAnnotations(move, &sb) if sb.String() != " {Good move [%clk 0:05:00]}" { @@ -1911,13 +1935,13 @@ func TestGameMoveValidation(t *testing.T) { tests := []struct { name string setupMoves []string // Moves to set up the position - move *Move // Move to test + move Move // Move to test wantErr bool // Whether we expect an error errorString string // Expected error string (if wantErr is true) }{ { name: "valid move should succeed", - move: &Move{ + move: Move{ s1: E2, s2: E4, }, @@ -1925,23 +1949,17 @@ func TestGameMoveValidation(t *testing.T) { }, { name: "invalid move should fail", - move: &Move{ + move: Move{ s1: E2, s2: E5, // Invalid move - pawn can't move three squares from e2 to e5 }, wantErr: true, errorString: "move e2e5 is not valid for the current position", }, - { - name: "nil move should fail", - move: nil, - wantErr: true, - errorString: "move cannot be nil", - }, { name: "invalid move from valid position should fail", setupMoves: []string{"e4", "e5"}, - move: &Move{ + move: Move{ s1: E4, s2: E6, // Invalid move - pawn can't move two squares from e4 to e6 }, @@ -1951,7 +1969,7 @@ func TestGameMoveValidation(t *testing.T) { { name: "valid move from valid position should succeed", setupMoves: []string{"e4", "e5"}, - move: &Move{ + move: Move{ s1: G1, s2: F3, }, @@ -1960,7 +1978,7 @@ func TestGameMoveValidation(t *testing.T) { { name: "valid promotion move should succeed", setupMoves: []string{"e4", "d5", "exd5", "c6", "dxc6", "Nf6", "cxb7", "Nbd7"}, - move: &Move{ + move: Move{ s1: B7, s2: A8, promo: Queen, @@ -1970,7 +1988,7 @@ func TestGameMoveValidation(t *testing.T) { { name: "invalid promotion move should fail", setupMoves: []string{"e4", "d5", "exd5", "c6", "dxc6", "Nf6", "cxb7", "Nbd7"}, - move: &Move{ + move: Move{ s1: B7, s2: A8, promo: King, // Invalid promotion piece @@ -1981,7 +1999,7 @@ func TestGameMoveValidation(t *testing.T) { { name: "valid castling move should succeed", setupMoves: []string{"e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "d3", "Nf6"}, - move: &Move{ + move: Move{ s1: E1, s2: G1, tags: KingSideCastle, @@ -1991,7 +2009,7 @@ func TestGameMoveValidation(t *testing.T) { { name: "invalid castling move should fail", setupMoves: []string{"e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "d3", "Nf6"}, - move: &Move{ + move: Move{ s1: E1, s2: H1, // Invalid castling destination tags: KingSideCastle, @@ -2008,14 +2026,14 @@ func TestGameMoveValidation(t *testing.T) { // Setup moves for _, move := range tt.setupMoves { - err := game.PushMove(move, nil) + _, err := game.PushMove(move, nil) if err != nil { t.Fatalf("setup failed: %v", err) } } // Test the move - err := game.Move(tt.move, nil) + _, err := game.Move(tt.move, nil) // Check error expectation if (err != nil) != tt.wantErr { @@ -2030,18 +2048,15 @@ func TestGameMoveValidation(t *testing.T) { return } - // If the move was successful, verify it was added to the game - if tt.move != nil { - // Check that the current move matches our move - if game.currentMove == nil { - t.Errorf("Move() succeeded but currentMove is nil") - return - } + // Check that the current move matches our move + if game.MoveTree().Current() == nil { + t.Errorf("Move() succeeded but currentMove is nil") + return + } - if game.currentMove.s1 != tt.move.s1 || game.currentMove.s2 != tt.move.s2 || game.currentMove.promo != tt.move.promo { - t.Errorf("Move() succeeded but currentMove doesn't match: got %v, want %v", - game.currentMove, tt.move) - } + if game.MoveTree().Current().Move() != tt.move { + t.Errorf("Move() succeeded but currentMove doesn't match: got %v, want %v", + game.MoveTree().Current().Move(), tt.move) } }) } @@ -2051,12 +2066,12 @@ func TestGameUnsafeMove(t *testing.T) { tests := []struct { name string setupMoves []string // Moves to set up the position - move *Move // Move to test + move Move // Move to test wantErr bool // Whether we expect an error }{ { name: "valid move should succeed without validation", - move: &Move{ + move: Move{ s1: E2, s2: E4, }, @@ -2064,21 +2079,16 @@ func TestGameUnsafeMove(t *testing.T) { }, { name: "invalid move should still succeed (no validation)", - move: &Move{ + move: Move{ s1: E2, s2: E5, // Invalid move but UnsafeMove doesn't validate }, wantErr: false, // UnsafeMove doesn't validate, so no error expected }, - { - name: "nil move should fail", - move: nil, - wantErr: true, - }, { name: "complex valid move should succeed", setupMoves: []string{"e4", "e5"}, - move: &Move{ + move: Move{ s1: G1, s2: F3, }, @@ -2087,7 +2097,7 @@ func TestGameUnsafeMove(t *testing.T) { { name: "promotion move should succeed without validation", setupMoves: []string{"e4", "d5", "exd5", "c6", "dxc6", "Nf6", "cxb7", "Nbd7"}, - move: &Move{ + move: Move{ s1: B7, s2: A8, promo: Queen, @@ -2103,14 +2113,14 @@ func TestGameUnsafeMove(t *testing.T) { // Setup moves for _, move := range tt.setupMoves { - err := game.PushMove(move, nil) + _, err := game.PushMove(move, nil) if err != nil { t.Fatalf("setup failed: %v", err) } } // Test the move - err := game.UnsafeMove(tt.move, nil) + _, err := game.UnsafeMove(tt.move, nil) // Check error expectation if (err != nil) != tt.wantErr { @@ -2122,18 +2132,15 @@ func TestGameUnsafeMove(t *testing.T) { return } - // If the move was successful, verify it was added to the game - if tt.move != nil { - // Check that the current move matches our move - if game.currentMove == nil { - t.Errorf("UnsafeMove() succeeded but currentMove is nil") - return - } + // Check that the current move matches our move + if game.MoveTree().Current() == nil { + t.Errorf("UnsafeMove() succeeded but currentMove is nil") + return + } - if game.currentMove.s1 != tt.move.s1 || game.currentMove.s2 != tt.move.s2 || game.currentMove.promo != tt.move.promo { - t.Errorf("UnsafeMove() succeeded but currentMove doesn't match: got %v, want %v", - game.currentMove, tt.move) - } + if game.MoveTree().Current().Move() != tt.move { + t.Errorf("UnsafeMove() succeeded but currentMove doesn't match: got %v, want %v", + game.MoveTree().Current().Move(), tt.move) } }) } @@ -2151,13 +2158,13 @@ func TestMoveVsUnsafeMovePerformance(t *testing.T) { t.Fatal("no valid moves available") } - move := &validMoves[0] + move := validMoves[0] // Test Move (with validation) start := time.Now() - for i := 0; i < 1000; i++ { + for range 1000 { gameClone := game.Clone() - err := gameClone.Move(move, nil) + _, err := gameClone.Move(move, nil) if err != nil { t.Fatalf("Move failed: %v", err) } @@ -2166,9 +2173,9 @@ func TestMoveVsUnsafeMovePerformance(t *testing.T) { // Test UnsafeMove (without validation) start = time.Now() - for i := 0; i < 1000; i++ { + for range 1000 { gameClone := game.Clone() - err := gameClone.UnsafeMove(move, nil) + _, err := gameClone.UnsafeMove(move, nil) if err != nil { t.Fatalf("UnsafeMove failed: %v", err) } @@ -2185,50 +2192,37 @@ func TestMoveVsUnsafeMovePerformance(t *testing.T) { } } -func TestUnsafePushNotationMove(t *testing.T) { +func TestUnsafePushMoveText(t *testing.T) { tests := []struct { name string setupMoves []string // Moves to set up the position moveStr string // Move to test - notation Notation // Notation to use - wantErr bool // Whether we expect an error + codec MoveTextCodec + wantErr bool // Whether we expect an error }{ { - name: "valid algebraic move should succeed without validation", - moveStr: "e4", - notation: AlgebraicNotation{}, - wantErr: false, - }, - { - name: "valid UCI move should succeed without validation", - moveStr: "e2e4", - notation: UCINotation{}, - wantErr: false, - }, - { - name: "valid long algebraic move should succeed without validation", - moveStr: "e2e4", - notation: LongAlgebraicNotation{}, - wantErr: false, + name: "valid UCI move should succeed without validation", + moveStr: "e2e4", + codec: UCI(), + wantErr: false, }, { - name: "invalid notation should fail during parsing", - moveStr: "xyz", - notation: AlgebraicNotation{}, - wantErr: true, // This should fail at notation parsing, not validation + name: "valid long algebraic move should succeed without validation", + moveStr: "e2e4", + codec: LongAlgebraic(), + wantErr: false, }, { - name: "complex valid move should succeed", - setupMoves: []string{"e4", "e5"}, - moveStr: "Nf3", - notation: AlgebraicNotation{}, - wantErr: false, + name: "invalid notation should fail during parsing", + moveStr: "xyz", + codec: UCI(), + wantErr: true, // This should fail at notation parsing, not validation }, { name: "invalid move should still succeed (no validation)", setupMoves: []string{"e4", "e5"}, - moveStr: "e2e3", // This move is illegal but UnsafePushNotationMove doesn't validate - notation: UCINotation{}, + moveStr: "e2e3", // This move is illegal but UnsafePushMoveText doesn't validate + codec: UCI(), wantErr: false, // No validation, so no error expected }, } @@ -2240,18 +2234,18 @@ func TestUnsafePushNotationMove(t *testing.T) { // Setup moves for _, move := range tt.setupMoves { - err := game.PushNotationMove(move, AlgebraicNotation{}, nil) + _, err := game.PushMoveText(move, SAN(), nil) if err != nil { t.Fatalf("setup failed: %v", err) } } // Test the move - err := game.UnsafePushNotationMove(tt.moveStr, tt.notation, nil) + _, err := game.UnsafePushMoveText(tt.moveStr, tt.codec, nil) // Check error expectation if (err != nil) != tt.wantErr { - t.Errorf("UnsafePushNotationMove() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("UnsafePushMoveText() error = %v, wantErr %v", err, tt.wantErr) return } @@ -2260,22 +2254,22 @@ func TestUnsafePushNotationMove(t *testing.T) { } // If the move was successful, verify it was added to the game - if game.currentMove == nil { - t.Errorf("UnsafePushNotationMove() succeeded but currentMove is nil") + if game.MoveTree().Current() == nil { + t.Errorf("UnsafePushMoveText() succeeded but currentMove is nil") return } // For successful cases, just verify that some move was made moves := game.Moves() if len(moves) == 0 { - t.Errorf("UnsafePushNotationMove() succeeded but no moves in game") + t.Errorf("UnsafePushMoveText() succeeded but no moves in game") } }) } } -// TestPushNotationMoveVsUnsafePushNotationMovePerformance demonstrates the performance difference -func TestPushNotationMoveVsUnsafePushNotationMovePerformance(t *testing.T) { +// TestPushMoveTextVsUnsafePushMoveTextPerformance demonstrates the performance difference. +func TestPushMoveTextVsUnsafePushMoveTextPerformance(t *testing.T) { if testing.Short() { t.Skip("skipping performance test in short mode") } @@ -2284,37 +2278,37 @@ func TestPushNotationMoveVsUnsafePushNotationMovePerformance(t *testing.T) { // Test with a common opening move moveStr := "e4" - notation := AlgebraicNotation{} + unsafeMoveStr := "e2e4" - // Test PushNotationMove (with validation) + // Test PushMoveText (with validation) start := time.Now() - for i := 0; i < 1000; i++ { + for range 1000 { gameClone := game.Clone() - err := gameClone.PushNotationMove(moveStr, notation, nil) + _, err := gameClone.PushMoveText(moveStr, SAN(), nil) if err != nil { - t.Fatalf("PushNotationMove failed: %v", err) + t.Fatalf("PushMoveText failed: %v", err) } } pushNotationMoveTime := time.Since(start) - // Test UnsafePushNotationMove (without validation) + // Test UnsafePushMoveText (without validation) start = time.Now() - for i := 0; i < 1000; i++ { + for range 1000 { gameClone := game.Clone() - err := gameClone.UnsafePushNotationMove(moveStr, notation, nil) + _, err := gameClone.UnsafePushMoveText(unsafeMoveStr, UCI(), nil) if err != nil { - t.Fatalf("UnsafePushNotationMove failed: %v", err) + t.Fatalf("UnsafePushMoveText failed: %v", err) } } unsafePushNotationMoveTime := time.Since(start) - t.Logf("PushNotationMove() (with validation): %v", pushNotationMoveTime) - t.Logf("UnsafePushNotationMove() (no validation): %v", unsafePushNotationMoveTime) + t.Logf("PushMoveText() (with validation): %v", pushNotationMoveTime) + t.Logf("UnsafePushMoveText() (no validation): %v", unsafePushNotationMoveTime) t.Logf("Performance improvement: %.2fx", float64(pushNotationMoveTime)/float64(unsafePushNotationMoveTime)) - // UnsafePushNotationMove should be faster + // UnsafePushMoveText should be faster if unsafePushNotationMoveTime >= pushNotationMoveTime { - t.Logf("Warning: UnsafePushNotationMove wasn't faster than PushNotationMove - this might be expected for simple positions") + t.Logf("Warning: UnsafePushMoveText wasn't faster than PushMoveText - this might be expected for simple positions") } } @@ -2417,11 +2411,11 @@ func TestCastlingInteractions(t *testing.T) { // Make first move pos := g.Position() - m1, err := AlgebraicNotation{}.Decode(pos, tt.firstMove) + m1, err := algebraicNotation{}.Decode(pos, tt.firstMove) if err != nil { t.Fatalf("Failed to decode first move %s: %v", tt.firstMove, err) } - if err := g.Move(m1, nil); err != nil { + if _, err := g.Move(m1, nil); err != nil { t.Fatalf("Failed to make first move %s: %v", tt.firstMove, err) } @@ -2462,7 +2456,7 @@ func TestMoveHistoryEmptyGame(t *testing.T) { func TestMoveHistoryMainLine(t *testing.T) { g := NewGame() for _, m := range []string{"e4", "e5", "Nf3"} { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -2473,10 +2467,10 @@ func TestMoveHistoryMainLine(t *testing.T) { if history[0].Move.String() != "e2e4" { t.Fatalf("expected first move e2e4, got %s", history[0].Move) } - if history[0].PrePosition != g.rootMove.position { + if history[0].PrePosition != g.MoveTree().Root().position { t.Fatalf("expected first pre-position to be root position") } - if history[0].PostPosition != history[0].Move.position { + if history[0].PostPosition != g.MoveTree().MainLine()[0].position { t.Fatalf("expected post-position to match move position") } if history[1].PrePosition != history[0].PostPosition { @@ -2486,10 +2480,10 @@ func TestMoveHistoryMainLine(t *testing.T) { func TestMoveHistoryComments(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } - g.currentMove.SetComment("good move") + g.MoveTree().Current().SetComment("good move") history := g.MoveHistory() if len(history) != 1 { t.Fatalf("expected 1 move history entry, got %d", len(history)) @@ -2501,7 +2495,7 @@ func TestMoveHistoryComments(t *testing.T) { func TestMoveHistoryNoComments(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } history := g.MoveHistory() @@ -2512,14 +2506,14 @@ func TestMoveHistoryNoComments(t *testing.T) { func TestMoveHistoryWithVariations(t *testing.T) { g := NewGame() - if err := g.PushMove("e4", nil); err != nil { + if _, err := g.PushMove("e4", nil); err != nil { t.Fatal(err) } - if err := g.PushMove("e5", nil); err != nil { + if _, err := g.PushMove("e5", nil); err != nil { t.Fatal(err) } - variationMove := &Move{} - g.AddVariation(g.rootMove.children[0], variationMove) + variationMove := Move{} + g.MoveTree().AddVariation(g.MoveTree().Root().children[0], variationMove) history := g.MoveHistory() if len(history) != 2 { t.Fatalf("expected 2 main line entries (variations excluded), got %d", len(history)) @@ -2529,7 +2523,7 @@ func TestMoveHistoryWithVariations(t *testing.T) { func TestMoveHistoryMatchesMovesLength(t *testing.T) { g := NewGame() for _, m := range []string{"e4", "e5", "Nf3", "Nc6", "Bb5"} { - if err := g.PushMove(m, nil); err != nil { + if _, err := g.PushMove(m, nil); err != nil { t.Fatal(err) } } @@ -2555,9 +2549,6 @@ func TestMoveHistoryFromPGN(t *testing.T) { } for i, h := range history { - if h.Move == nil { - t.Fatalf("entry %d: Move is nil", i) - } if h.PrePosition == nil { t.Fatalf("entry %d: PrePosition is nil", i) } diff --git a/game_unsafe_move_text_test.go b/game_unsafe_move_text_test.go new file mode 100644 index 00000000..015485b9 --- /dev/null +++ b/game_unsafe_move_text_test.go @@ -0,0 +1,88 @@ +package chess_test + +import ( + "errors" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestGameUnsafePushMoveTextRejectsSAN(t *testing.T) { + _, err := chess.NewGame().UnsafePushMoveText("e4", chess.SAN(), nil) + if !errors.Is(err, chess.ErrUnsafeMoveTextUnsupported) { + t.Fatalf("UnsafePushMoveText(SAN) error = %v, want ErrUnsafeMoveTextUnsupported", err) + } +} + +func TestGameUnsafePushMoveTextAcceptsIllegalCoordinateMove(t *testing.T) { + game := chess.NewGame() + if _, err := game.UnsafePushMoveText("e2e5", chess.UCI(), nil); err != nil { + t.Fatalf("UnsafePushMoveText() error = %v", err) + } + if got := game.Position().Board().Piece(chess.E5); got != chess.WhitePawn { + t.Fatalf("piece on e5 = %v, want white pawn", got) + } +} + +func TestGameUnsafePushMoveTextClassifiesSpecialMoves(t *testing.T) { + t.Run("capture from UCI", func(t *testing.T) { + game := chess.NewGame() + mustUnsafePushMoveText(t, game, "e2e4", chess.UCI()) + mustUnsafePushMoveText(t, game, "d7d5", chess.UCI()) + node := mustUnsafePushMoveText(t, game, "e4d5", chess.UCI()) + if !node.Move().HasTag(chess.Capture) { + t.Fatalf("capture move missing Capture tag: %v", node.Move()) + } + }) + + t.Run("castling from UCI", func(t *testing.T) { + game := mustGameFromFEN(t, "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") + node := mustUnsafePushMoveText(t, game, "e1g1", chess.UCI()) + if !node.Move().HasTag(chess.KingSideCastle) { + t.Fatalf("castle move missing KingSideCastle tag: %v", node.Move()) + } + if got := game.Position().Board().Piece(chess.F1); got != chess.WhiteRook { + t.Fatalf("piece on f1 = %v, want white rook", got) + } + }) + + t.Run("en passant from long algebraic", func(t *testing.T) { + game := mustGameFromFEN(t, "rnbqkbnr/pp2pppp/8/2ppP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3") + node := mustUnsafePushMoveText(t, game, "e5xd6", chess.LongAlgebraic()) + if !node.Move().HasTag(chess.EnPassant) { + t.Fatalf("en passant move missing EnPassant tag: %v", node.Move()) + } + if got := game.Position().Board().Piece(chess.D5); got != chess.NoPiece { + t.Fatalf("piece on d5 = %v, want empty square", got) + } + }) + + t.Run("null move from UCI", func(t *testing.T) { + game := chess.NewGame() + node := mustUnsafePushMoveText(t, game, "0000", chess.UCI()) + if !node.Move().HasTag(chess.Null) { + t.Fatalf("null move missing Null tag: %v", node.Move()) + } + if game.Position().Turn() != chess.Black { + t.Fatalf("turn = %v, want black", game.Position().Turn()) + } + }) +} + +func mustUnsafePushMoveText(t *testing.T, game *chess.Game, moveText string, codec chess.MoveTextCodec) *chess.MoveNode { + t.Helper() + node, err := game.UnsafePushMoveText(moveText, codec, nil) + if err != nil { + t.Fatalf("UnsafePushMoveText(%q, %s) error = %v", moveText, codec, err) + } + return node +} + +func mustGameFromFEN(t *testing.T, fen string) *chess.Game { + t.Helper() + opt, err := chess.FEN(fen) + if err != nil { + t.Fatalf("FEN() error = %v", err) + } + return chess.NewGame(opt) +} diff --git a/go.mod b/go.mod index 13931e98..ae2eb35c 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/corentings/chess/v2 +module github.com/corentings/chess/v3 go 1.25.0 -require github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b +require go.uber.org/goleak v1.3.0 diff --git a/go.sum b/go.sum index 38027bab..d39b9ad7 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,10 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= -github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= -github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= -github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +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/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.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hashes.go b/hashes.go index f984bffd..7ef80a03 100644 --- a/hashes.go +++ b/hashes.go @@ -1,23 +1,10 @@ package chess import ( - "encoding/hex" "fmt" "strconv" ) -var polyglotHashesBytes = func() [len(polyglotHashes)][8]byte { - var hashes [len(polyglotHashes)][8]byte - for i, hexStr := range polyglotHashes { - b, err := hex.DecodeString(hexStr) - if err != nil { - panic(fmt.Errorf("invalid polyglot hash at index %d: %w", i, err)) - } - copy(hashes[i][:], b) - } - return hashes -}() - // polyglotHashesUint64 provides the same hashes as uint64 values for fast XOR operations. var polyglotHashesUint64 = func() [len(polyglotHashes)]uint64 { var hashes [len(polyglotHashes)]uint64 @@ -31,15 +18,7 @@ var polyglotHashesUint64 = func() [len(polyglotHashes)]uint64 { return hashes }() -// GetPolyglotHashBytes returns a precomputed byte slice for a given index -func GetPolyglotHashBytes(index int) []byte { - if index < 0 || index >= len(polyglotHashesBytes) { - return nil - } - return polyglotHashesBytes[index][:] -} - -// GetPolyglotHashes returns a reference to the static hash array +// GetPolyglotHashes returns a reference to the static hash array. func GetPolyglotHashes() []string { return polyglotHashes[:] } diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 00000000..114e902a --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,56 @@ +package chess_test + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func mustReadFile(fname string) string { + f, err := os.Open(fname) + if err != nil { + panic(err) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return string(b) +} + +func mustPGNOption(t *testing.T, pgn string) func(*chess.Game) { + t.Helper() + opt, err := chess.PGN(strings.NewReader(pgn)) + if err != nil { + t.Fatal(err) + } + return opt +} + +func mustParseSingleGame(t *testing.T, pgn string) *chess.Game { + t.Helper() + game, err := chess.ParsePGN(strings.NewReader(pgn)) + if err != nil { + t.Fatalf("failed to parse pgn: %v", err) + } + if game == nil { + t.Fatal("expected game") + } + return game +} + +func withMinimalTags(moveText string) string { + return `[Event "Test"] +[Site "Internet"] +[Date "2026.06.19"] +[Round "1"] +[White "White"] +[Black "Black"] +[Result "*"] + +` + moveText +} diff --git a/image/README.md b/image/README.md index 7ec7fd07..7730763d 100644 --- a/image/README.md +++ b/image/README.md @@ -2,95 +2,132 @@ ## Introduction -**image** is an chess image utility that converts board positions into [SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics), or Scalable Vector Graphics, images. [svgo](https://github.com/ajstarks/svgo), the only outside dependency, is used to construct the SVG document. +**image** is a chess image utility that converts board positions into [SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics), or Scalable Vector Graphics, images. The package has no external dependencies; SVG is written directly to an `io.Writer`. + +### Piece Set + +The package ships with a single fixed piece set embedded from `internal/pieces/` (one SVG per color and piece type: `wK`, `wQ`, `wR`, `wB`, `wN`, `wP`, `bK`, …). The set cannot be changed at runtime; if you need custom piece artwork, fork the package or post-process the generated SVG. ## Usage -### SVG +### SVG -The SVG function is the primary exported function of the package. It writes an SVG document to the io.Writer given. +The `SVG` function is the primary exported function of the package. It writes an SVG document to the `io.Writer` given. Rendering options are configured through an `SVGOptions` value; `nil` or a zero-valued `SVGOptions` uses defaults (white perspective, default square colors, 45px squares, with rank/file coordinates visible). ```go -file, _ := os.Open("output.svg") -defer file.Close() +f, err := os.Create("output.svg") +if err != nil { + log.Fatal(err) +} +defer f.Close() + fenStr := "rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1" pos := &chess.Position{} -pos.UnmarshalText([]byte(fenStr)) -image.SVG(file, pos.Board()) +if err := pos.UnmarshalText([]byte(fenStr)); err != nil { + log.Fatal(err) +} + +if err := image.SVG(f, pos, nil); err != nil { + log.Fatal(err) +} ``` -### Dark / Light Square Customization +### Square Colors -The default colors, shown in the example SVG below, are (235, 209, 166) for light squares and (165, 117, 81) for dark squares. The light and dark squares can be customized using the SquareColors() option. +The default colors are `color.RGBA{235, 209, 166, 255}` for light squares and `color.RGBA{165, 117, 81, 255}` for dark squares. Both colors are opaque. Set `LightSquare` and `DarkSquare` on `SVGOptions` to customize them. Nil colors fall back to the defaults. ```go -white := color.RGBA{255, 255, 255, 1} -gray := color.RGBA{120, 120, 120, 1} -sqrs := image.SquareColors(white, gray) -image.SVG(file, pos.Board(), sqrs) +white := color.RGBA{255, 255, 255, 255} +gray := color.RGBA{120, 120, 120, 255} + +if err := image.SVG(f, pos, &image.SVGOptions{ + LightSquare: white, + DarkSquare: gray, +}); err != nil { + log.Fatal(err) +} ``` ### Marked Squares -MarkSquares is designed to be used as an optional argument to the SVG function. It marks the given squares with the color. A possible usage includes marking squares of the previous move. +`SVGOptions.Marks` highlights individual squares with a translucent overlay (fixed opacity 0.2; mark color controls hue only). A typical use is marking the squares involved in the previous move. Mark keys outside `A1`..`H8` and nil mark colors are ignored. ```go -yellow := color.RGBA{255, 255, 0, 1} -mark := image.MarkSquares(yellow, chess.D2, chess.D4) -image.SVG(file, pos.Board(), mark) +yellow := color.RGBA{255, 255, 0, 255} +if err := image.SVG(f, pos, &image.SVGOptions{ + Marks: map[chess.Square]color.Color{ + chess.D2: yellow, + chess.D4: yellow, + }, +}); err != nil { + log.Fatal(err) +} ``` ### Perspective -Perspective is designed to be used as an optional argument -to the SVG function. It draws the board from the perspective -of the given color. White is the default. The following -generates a board from Black's perspective: +`SVGOptions.Perspective` selects the orientation of the board. White is the default. Any value other than `chess.Black` falls back to white without error. + +```go +if err := image.SVG(f, pos, &image.SVGOptions{ + Perspective: chess.Black, +}); err != nil { + log.Fatal(err) +} +``` + +### Square Size and Coordinates + +`SquareSize` (in CSS pixels) scales the entire board. The default is 45. The board is always 8x8 squares, so the total SVG width and height are `8 * SquareSize`. A `SquareSize <= 0` falls back to the default. Set `HideCoordinates` to true to omit the rank and file labels. ```go -fromBlack := image.Perspective(chess.Black) -image.SVG(file, pos.Board(), fromBlack) +if err := image.SVG(f, pos, &image.SVGOptions{ + SquareSize: 90, + HideCoordinates: true, +}); err != nil { + log.Fatal(err) +} ``` -### Example Program +### Errors + +`SVG` returns a non-nil error when the writer is nil (`image: nil writer`), when the position is nil or has no board (`image: nil position`), or when an underlying write fails. The first write error is returned unchanged. + +## Example Program ```go package main import ( - "image/color" - "log" - "os" + "image/color" + "log" + "os" - "github.com/corentings/chess" - "github.com/corentings/chess/image" + "github.com/corentings/chess" + "github.com/corentings/chess/image" ) func main() { - // create file - f, err := os.Create("example.svg") + f, err := os.Create("output.svg") if err != nil { log.Fatal(err) } defer f.Close() - // create board position fenStr := "rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1" pos := &chess.Position{} if err := pos.UnmarshalText([]byte(fenStr)); err != nil { log.Fatal(err) } - // write board SVG to file - yellow := color.RGBA{255, 255, 0, 1} - mark := image.MarkSquares(yellow, chess.D2, chess.D4) - if err := image.SVG(f, pos.Board(), mark); err != nil { + yellow := color.RGBA{255, 255, 0, 255} + if err := image.SVG(f, pos, &image.SVGOptions{ + Marks: map[chess.Square]color.Color{ + chess.D2: yellow, + chess.D4: yellow, + }, + }); err != nil { log.Fatal(err) } } ``` - -### Example Program Result - -![rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1](example.svg) - diff --git a/image/black_example.svg b/image/black_example.svg deleted file mode 100644 index c6d710f9..00000000 --- a/image/black_example.svg +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -2 - - - - - - - - - - - - - - - - - - - - - - - - - - - -3 - - - - - - - - -4 - - - - - - - - - - - - -5 - - - - - - - - -6 - - - - - - - - - - - -7 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -8 -h - - - - - - - - - - -g - - - - - - - - - - - -f - - - - - - - - - - - -e - - - - - - - - - - - - - - - - - - - -d - - - - - - - - - - - -c - - - - - - - - - - -b - - - - - - - - - - - - - - - - -a - \ No newline at end of file diff --git a/image/example.svg b/image/example.svg deleted file mode 100644 index 33149051..00000000 --- a/image/example.svg +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -8 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -7 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -6 - - - - - - - - -5 - - - - - - - - -4 - - - - - - - - - - - - -3 - - - - - - - - - - - -2 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -1 -a - - - - - - - - - -b - - - - - - - - - - - -c - - - - - - - - - - - - - - -d - - - - - - - - - - - - -e - - - - - - - - - - - -f - - - - - - - - - -g - - - - - - - - - - - - -h - \ No newline at end of file diff --git a/image/image.go b/image/image.go old mode 100755 new mode 100644 index b01ca6b7..fe930aab --- a/image/image.go +++ b/image/image.go @@ -1,162 +1,223 @@ -// Package image is a go library that creates images from board positions +// Package image renders chess positions as SVG images. +// +// The package embeds a single piece set in internal/pieces (one SVG per +// color and piece type: wK, wQ, wR, wB, wN, wP, bK, ...). The set is fixed: +// there is no API to swap in a different piece set. To render with custom +// artwork, post-process the SVG or fork the package. package image import ( + "embed" + "errors" "fmt" "image/color" "io" "strings" - svg "github.com/ajstarks/svgo" - "github.com/corentings/chess/v2" - "github.com/corentings/chess/v2/image/internal" + "github.com/corentings/chess/v3" ) -// SVG writes the board SVG representation into the writer. -// An error is returned if there is there is an error writing data. -// SVG also takes options which can customize the image output. -func SVG(w io.Writer, b *chess.Board, opts ...func(*encoder)) error { - e := newEncoder(w, opts) - return e.EncodeSVG(b) +const ( + defaultSquareSize = 45 + markOpacity = "0.2" + pieceBaseSize = 45.0 +) + +var ( + defaultLightSquare = color.RGBA{R: 235, G: 209, B: 166, A: 255} + defaultDarkSquare = color.RGBA{R: 165, G: 117, B: 81, A: 255} + + orderOfRanks = []chess.Rank{chess.Rank8, chess.Rank7, chess.Rank6, chess.Rank5, chess.Rank4, chess.Rank3, chess.Rank2, chess.Rank1} + orderOfRanksBlack = []chess.Rank{chess.Rank1, chess.Rank2, chess.Rank3, chess.Rank4, chess.Rank5, chess.Rank6, chess.Rank7, chess.Rank8} + orderOfFiles = []chess.File{chess.FileA, chess.FileB, chess.FileC, chess.FileD, chess.FileE, chess.FileF, chess.FileG, chess.FileH} + orderOfFilesBlack = []chess.File{chess.FileH, chess.FileG, chess.FileF, chess.FileE, chess.FileD, chess.FileC, chess.FileB, chess.FileA} +) + +//go:embed internal/pieces/*.svg +var piecesFS embed.FS + +var pieceSVGs = loadPieceSVGs() + +// SVGOptions customizes SVG rendering. +// Zero-valued fields use defaults. +type SVGOptions struct { + LightSquare color.Color + DarkSquare color.Color + Marks map[chess.Square]color.Color + Perspective chess.Color + SquareSize int + HideCoordinates bool +} + +type svgOptions struct { + lightSquare color.Color + darkSquare color.Color + marks map[chess.Square]color.Color + perspective chess.Color + squareSize int + hideCoordinates bool } -// SquareColors is designed to be used as an optional argument -// to the SVG function. It changes the default light and -// dark square colors to the colors given. -func SquareColors(light, dark color.Color) func(*encoder) { - return func(e *encoder) { - e.light = light - e.dark = dark +// SVG writes the position SVG representation into the writer. +func SVG(w io.Writer, pos *chess.Position, opts *SVGOptions) error { + if w == nil { + return errors.New("image: nil writer") } + if pos == nil || pos.Board() == nil { + return errors.New("image: nil position") + } + + o := normalizeSVGOptions(opts) + r := &svgRenderer{w: w, opts: o} + r.render(pos) + return r.err } -// MarkSquares is designed to be used as an optional argument -// to the SVG function. It marks the given squares with the -// color. A possible usage includes marking squares of the -// previous move. -func MarkSquares(c color.Color, sqs ...chess.Square) func(*encoder) { - return func(e *encoder) { - for _, sq := range sqs { - e.marks[sq] = c +type svgRenderer struct { + w io.Writer + opts svgOptions + err error +} + +func (r *svgRenderer) render(pos *chess.Position) { + boardSize := r.opts.squareSize * 8 + r.write(`` + "\n") + r.writef(``+"\n", boardSize, boardSize, boardSize, boardSize) + + boardMap := pos.Board().SquareMap() + ranks, files := rankAndFileOrder(r.opts.perspective) + lastRank := len(ranks) - 1 + for i, rank := range ranks { + for j, file := range files { + x := j * r.opts.squareSize + y := i * r.opts.squareSize + sq := chess.NewSquare(file, rank) + r.renderSquare(x, y, sq) + r.renderMark(x, y, sq) + r.renderPiece(x, y, sq, boardMap[sq]) + if !r.opts.hideCoordinates { + r.renderCoordinates(x, y, i, j, lastRank, sq) + } } } + r.write("\n") } -// Perspective is designed to be used as an optional argument -// to the SVG function. It draws the board from the perspective -// of the given color. White is the default. -func Perspective(c chess.Color) func(*encoder) { - return func(e *encoder) { - e.perspective = c +func (r *svgRenderer) renderSquare(x, y int, sq chess.Square) { + squareClass := "light" + fill := r.opts.lightSquare + if isDarkSquare(sq) { + squareClass = "dark" + fill = r.opts.darkSquare } + r.writef(``+"\n", + squareClass, sq.String(), x, y, r.opts.squareSize, r.opts.squareSize, colorToHex(fill)) } -// A Encoder encodes chess boards into images. -type encoder struct { - w io.Writer - light color.Color - dark color.Color - marks map[chess.Square]color.Color - perspective chess.Color +func (r *svgRenderer) renderMark(x, y int, sq chess.Square) { + markColor, ok := r.opts.marks[sq] + if !ok { + return + } + r.writef(``+"\n", + sq.String(), x, y, r.opts.squareSize, r.opts.squareSize, colorToHex(markColor), markOpacity) } -// newEncoder returns an encoder that writes to the given writer. -// newEncoder also takes options which can customize the image -// output. -func newEncoder(w io.Writer, options []func(*encoder)) *encoder { - e := &encoder{ - w: w, - light: color.RGBA{235, 209, 166, 1}, - dark: color.RGBA{165, 117, 81, 1}, - perspective: chess.White, - marks: map[chess.Square]color.Color{}, +func (r *svgRenderer) renderPiece(x, y int, sq chess.Square, p chess.Piece) { + if p == chess.NoPiece { + return + } + inner, ok := pieceSVGs[pieceFileName(p)] + if !ok { + return } - for _, op := range options { - op(e) + r.writef(``+"\n", + pieceColorClass(p.Color()), pieceTypeClass(p.Type()), sq.String(), x, y, scaleString(r.opts.squareSize)) + r.write(inner) + if !strings.HasSuffix(inner, "\n") { + r.write("\n") } - return e + r.write("\n") } -const ( - sqWidth = 45 - sqHeight = 45 - boardWidth = 8 * sqWidth - boardHeight = 8 * sqHeight -) +func (r *svgRenderer) renderCoordinates(x, y, rankIndex, fileIndex, lastRank int, sq chess.Square) { + textColor := r.opts.darkSquare + if isDarkSquare(sq) { + textColor = r.opts.lightSquare + } + fontSize := r.opts.squareSize * 11 / defaultSquareSize + if fontSize < 1 { + fontSize = 1 + } + if fileIndex == 0 { + r.writef(`%s`+"\n", + x+r.opts.squareSize/20, y+r.opts.squareSize*5/20, fontSize, colorToHex(textColor), sq.Rank().String()) + } + if rankIndex == lastRank { + r.writef(`%s`+"\n", + x+r.opts.squareSize*19/20, y+r.opts.squareSize-r.opts.squareSize/15, fontSize, colorToHex(textColor), sq.File().String()) + } +} -var ( - orderOfRanks = []chess.Rank{chess.Rank8, chess.Rank7, chess.Rank6, chess.Rank5, chess.Rank4, chess.Rank3, chess.Rank2, chess.Rank1} - orderOfRanksBlack = []chess.Rank{chess.Rank1, chess.Rank2, chess.Rank3, chess.Rank4, chess.Rank5, chess.Rank6, chess.Rank7, chess.Rank8} - orderOfFiles = []chess.File{chess.FileA, chess.FileB, chess.FileC, chess.FileD, chess.FileE, chess.FileF, chess.FileG, chess.FileH} - orderOfFilesBlack = []chess.File{chess.FileH, chess.FileG, chess.FileF, chess.FileE, chess.FileD, chess.FileC, chess.FileB, chess.FileA} -) +func (r *svgRenderer) write(s string) { + if r.err != nil { + return + } + _, r.err = io.WriteString(r.w, s) +} -// EncodeSVG writes the board SVG representation into -// the Encoder's writer. An error is returned if there -// is there is an error writing data. -func (e *encoder) EncodeSVG(b *chess.Board) error { - const lastRank = 7 - boardMap := b.SquareMap() - canvas := svg.New(e.w) - canvas.Start(boardWidth, boardHeight) - canvas.Rect(0, 0, boardWidth, boardHeight) - - ranks := orderOfRanks - files := orderOfFiles - if e.perspective == chess.Black { - ranks = orderOfRanksBlack - files = orderOfFilesBlack +func (r *svgRenderer) writef(format string, args ...any) { + if r.err != nil { + return } - for i, rank := range ranks { - for j, file := range files { - x := j * sqHeight - y := i * sqHeight - sq := chess.NewSquare(file, rank) - c := e.colorForSquare(sq) - canvas.Rect(x, y, sqWidth, sqHeight, "fill: "+colorToHex(c)) - markColor, ok := e.marks[sq] - if ok { - canvas.Rect(x, y, sqWidth, sqHeight, "fill-opacity:0.2;fill: "+colorToHex(markColor)) - } - // draw piece - p := boardMap[sq] - if p != chess.NoPiece { - xml := pieceXML(x, y, p) - if _, err := io.WriteString(canvas.Writer, xml); err != nil { - return err - } - } - // draw rank text on file A - txtColor := e.colorForText(sq) - if j == 0 { - style := "font-size:11px;fill: " + colorToHex(txtColor) - canvas.Text(x+(sqWidth*1/20), y+(sqHeight*5/20), sq.Rank().String(), style) //nolint:mnd // this is a magic number - } - // draw file text on rank 1 - if i == lastRank { - style := "text-anchor:end;font-size:11px;fill: " + colorToHex(txtColor) - canvas.Text(x+(sqWidth*19/20), y+sqHeight-(sqHeight*1/15), sq.File().String(), style) //nolint:mnd // this is a magic number - } + _, r.err = fmt.Fprintf(r.w, format, args...) +} + +func normalizeSVGOptions(opts *SVGOptions) svgOptions { + o := svgOptions{ + lightSquare: defaultLightSquare, + darkSquare: defaultDarkSquare, + marks: map[chess.Square]color.Color{}, + perspective: chess.White, + squareSize: defaultSquareSize, + } + if opts == nil { + return o + } + if opts.LightSquare != nil { + o.lightSquare = opts.LightSquare + } + if opts.DarkSquare != nil { + o.darkSquare = opts.DarkSquare + } + if opts.Perspective == chess.Black { + o.perspective = chess.Black + } + if opts.SquareSize > 0 { + o.squareSize = opts.SquareSize + } + o.hideCoordinates = opts.HideCoordinates + for sq, c := range opts.Marks { + if isValidSquare(sq) && c != nil { + o.marks[sq] = c } } - canvas.End() - return nil + return o } -func (e *encoder) colorForSquare(sq chess.Square) color.Color { - sqSum := int(sq.File()) + int(sq.Rank()) - if sqSum%2 == 0 { - return e.dark +func rankAndFileOrder(perspective chess.Color) ([]chess.Rank, []chess.File) { + if perspective == chess.Black { + return orderOfRanksBlack, orderOfFilesBlack } - return e.light + return orderOfRanks, orderOfFiles } -func (e *encoder) colorForText(sq chess.Square) color.Color { - sqSum := int(sq.File()) + int(sq.Rank()) - if sqSum%2 == 0 { - return e.light - } - return e.dark +func isValidSquare(sq chess.Square) bool { + return sq >= chess.A1 && sq <= chess.H8 +} + +func isDarkSquare(sq chess.Square) bool { + return (int(sq.File())+int(sq.Rank()))%2 == 0 } func colorToHex(c color.Color) string { @@ -164,12 +225,76 @@ func colorToHex(c color.Color) string { return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) } -func pieceXML(x, y int, p chess.Piece) string { - fileName := fmt.Sprintf("pieces/%s%s.svg", p.Color().String(), pieceTypeMap[p.Type()]) - svgStr := internal.MustSVG(fileName) - old := `` - sprintf := fmt.Sprintf(``, -1*x, -1*y) - return strings.Replace(svgStr, old, sprintf, 1) +func loadPieceSVGs() map[string]string { + pieceFiles := []string{ + "internal/pieces/bB.svg", + "internal/pieces/bK.svg", + "internal/pieces/bN.svg", + "internal/pieces/bP.svg", + "internal/pieces/bQ.svg", + "internal/pieces/bR.svg", + "internal/pieces/wB.svg", + "internal/pieces/wK.svg", + "internal/pieces/wN.svg", + "internal/pieces/wP.svg", + "internal/pieces/wQ.svg", + "internal/pieces/wR.svg", + } + svgs := make(map[string]string, len(pieceFiles)) + for _, f := range pieceFiles { + b, err := piecesFS.ReadFile(f) + if err != nil { + panic(fmt.Sprintf("image: failed to read embedded asset %s: %v", f, err)) + } + svgs[f] = innerSVG(f, string(b)) + } + return svgs +} + +func innerSVG(name, svg string) string { + svg = strings.TrimSpace(svg) + if !strings.HasPrefix(svg, "") + end := strings.LastIndex(svg, "") + if start == -1 || end == -1 || end <= start { + panic(fmt.Sprintf("image: malformed embedded asset %s", name)) + } + return strings.TrimSpace(svg[start+1 : end]) +} + +func pieceFileName(p chess.Piece) string { + return fmt.Sprintf("internal/pieces/%s%s.svg", p.Color().String(), pieceTypeMap[p.Type()]) +} + +func pieceColorClass(c chess.Color) string { + if c == chess.Black { + return "black" + } + return "white" +} + +func pieceTypeClass(t chess.PieceType) string { + switch t { + case chess.King: + return "king" + case chess.Queen: + return "queen" + case chess.Rook: + return "rook" + case chess.Bishop: + return "bishop" + case chess.Knight: + return "knight" + case chess.Pawn: + return "pawn" + } + return "piece" +} + +func scaleString(squareSize int) string { + return fmt.Sprintf("%.6g", float64(squareSize)/pieceBaseSize) } var pieceTypeMap = map[chess.PieceType]string{ diff --git a/image/image_test.go b/image/image_test.go old mode 100755 new mode 100644 index 9324b2bf..9135f42c --- a/image/image_test.go +++ b/image/image_test.go @@ -1,83 +1,186 @@ -package image_test +package image import ( "bytes" - "crypto/md5" - "fmt" + "encoding/xml" + "errors" "image/color" "io" - "os" "strings" "testing" - "github.com/corentings/chess/v2" - "github.com/corentings/chess/v2/image" + "github.com/corentings/chess/v3" ) -const ( - expectedMD5 = "08aaa6fcfde3bb900fc54bdfef3d5c81" - expectedMD5Black = "badac5ca5cfbdea9b98a1f9988ba54bc" -) +func TestSVGDefaultRender(t *testing.T) { + svg := renderSVG(t, chess.StartingPosition(), nil) -func TestSVG(t *testing.T) { - // create buffer of actual svg - buf := bytes.NewBuffer([]byte{}) - fenStr := "rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1" - pos := &chess.Position{} - if err := pos.UnmarshalText([]byte(fenStr)); err != nil { - t.Error(err) + if !strings.HasPrefix(svg, "\n - - - - - - - - - - - - - - - -1 -a - - - - - - - - - -b - - - - - - - - - - - -c - - - - - - - - - - - - - - -d - - - - - - - - - - - - -e - - - - - - - - - - - -f - - - - - - - - - -g - - - - - - - - - - - - -h - - - - -2 - - - - - - - - - - - - - - - - - - - - - - - - - - - -3 - - - - - - - - -4 - - - - - - - - - - - - -5 - - - - - - - - -6 - - - - - - - - - - - -7 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -8 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lexer.go b/lexer.go index 9eab1a09..eda478dc 100644 --- a/lexer.go +++ b/lexer.go @@ -61,6 +61,7 @@ const ( CommandParam // Command parameter CommandEnd // ] DeambiguationSquare // Full square disambiguation (e.g., e8 in Qe8f7) + NullMove // A null move: Z0, Z1, --, or @@ ) func (t TokenType) String() string { @@ -96,6 +97,8 @@ func (t TokenType) String() string { "CommandName", "CommandParam", "CommandEnd", + "DeambiguationSquare", + "NullMove", } if t < 0 || int(t) >= len(types) { @@ -207,19 +210,18 @@ func (l *Lexer) readCommandParam() Token { } func (l *Lexer) readNAG() Token { - // Handle cases where NAG starts with '!' or '?' - // This shouldn't happen from my understanding of the PGN spec but lichess pgn files have it. - // TODO: Better NAG handling of different formats + // Handle cases where NAG starts with '!' or '?' (symbolic move-quality + // spellings such as ! ? !! ?? !? ?!). This isn't part of the strict PGN + // spec, but Lichess and other sources emit them, so they are accepted on + // import. The lexer reads the maximal run of '!' and '?' characters as a + // single token; normalisation to the canonical "$N" form happens at the + // storage boundary (see ParseNAG). if l.ch == '!' || l.ch == '?' { - value := string(l.ch) - l.readChar() // Read the next character - - // Check if the next character is also '!' or '?' - if l.ch == '!' || l.ch == '?' { - value += string(l.ch) // Append the second character - l.readChar() // Move to the next character + position := l.position + for l.ch == '!' || l.ch == '?' { + l.readChar() } - + value := l.input[position:l.position] return Token{Type: NAG, Value: value} } l.readChar() // skip the $ symbol @@ -264,10 +266,15 @@ func (l *Lexer) readComment() Token { // Look for command start sequence for l.ch != '}' && l.ch != 0 { + if l.ch == '\\' && l.peekChar() == '}' { + l.readChar() + l.readChar() + continue + } if l.ch == '[' && l.peekChar() == '%' { if position != l.position { // Return accumulated comment text before the command - return Token{Type: COMMENT, Value: strings.TrimSpace(l.input[position:l.position])} + return Token{Type: COMMENT, Value: cleanCommentText(l.input[position:l.position])} } // Start command processing l.readChar() // skip [ @@ -296,12 +303,17 @@ func (l *Lexer) readComment() Token { // Return remaining comment text if any if position != l.position { - return Token{Type: COMMENT, Value: strings.TrimSpace(l.input[position:l.position])} + return Token{Type: COMMENT, Value: cleanCommentText(l.input[position:l.position])} } return Token{Type: CommentEnd, Value: "}"} } +func cleanCommentText(text string) string { + text = strings.TrimSpace(text) + return strings.ReplaceAll(text, `\}`, "}") +} + // Update readPieceMove to handle piece moves. func (l *Lexer) readPieceMove() Token { // Capture just the piece @@ -468,10 +480,68 @@ func (l *Lexer) readCastling() (Token, bool) { if l.ch == '-' && l.peekChar() == 'O' { l.readChar() // skip - l.readChar() // skip O - return Token{Type: QueensideCastle, Value: "O-O-O"}, true + return Token{Type: QueensideCastle, Value: castleQS}, true } - return Token{Type: KingsideCastle, Value: "O-O"}, true + return Token{Type: KingsideCastle, Value: castleKS}, true +} + +// readNullMove consumes a null-move token at the current position and returns +// it. Recognised spellings: "Z0", "Z1", "--", "@@", "0000". Returns (Token{}, false) +// if the current character does not start a null-move token. Safe to call from +// NextToken before the main dispatch because it never clashes with valid move +// squares, piece letters, or result tokens. +func (l *Lexer) readNullMove() (Token, bool) { + switch l.ch { + case 'Z': + next := l.peekChar() + if next != '0' && next != '1' { + return Token{}, false + } + value := string([]byte{l.ch, next}) + l.readChar() + l.readChar() + return Token{Type: NullMove, Value: value}, true + + case '-': + if l.peekChar() != '-' { + return Token{}, false + } + // Standalone "--" — readResult already consumed any result token + // beginning with a digit ("1-0", "0-1", "1/2-1/2"), and readCastling + // requires an uppercase O, so we cannot be inside those. + l.readChar() + l.readChar() + return Token{Type: NullMove, Value: "--"}, true + + case '@': + if l.peekChar() != '@' { + return Token{}, false + } + l.readChar() + l.readChar() + return Token{Type: NullMove, Value: "@@"}, true + + case '0': + // "0000" — must be four zeros with nothing digit/dash-adjacent to + // avoid clashing with result/castle tokens. readCastling consumes + // "O-O" / "O-O-O" and readResult consumes "1-0" / "0-1" before we + // reach here, so standalone "0000" can safely be claimed. + if l.position+3 >= len(l.input) { + return Token{}, false + } + if l.input[l.position+1] != '0' || + l.input[l.position+2] != '0' || + l.input[l.position+3] != '0' { + return Token{}, false + } + l.readChar() + l.readChar() + l.readChar() + l.readChar() + return Token{Type: NullMove, Value: "0000"}, true + } + return Token{}, false } // NextToken reads the next token from the input stream. @@ -527,6 +597,12 @@ func (l *Lexer) NextToken() Token { return l.readTagKey() } + // Null move tokens: "Z0", "Z1" (ChessBase / Scid), "--" (pgn-extract, + // Scid), "@@" (some exporters), and "0000" (UCI convention). + if tok, ok := l.readNullMove(); ok { + return tok + } + switch l.ch { case '(': l.readChar() @@ -611,6 +687,15 @@ func (l *Lexer) NextToken() Token { l.ch = l.input[position] return l.readResult() default: + // "1/2-1/2" draw result token, e.g. "1. e4 e5 1/2-1/2". The digit + // run stopped at '/'; if the literal pattern starts at the run + // start, reset and let readResult consume and validate it. + if position+7 <= len(l.input) && l.input[position:position+7] == string(Draw) { + l.position = position + l.readPosition = position + 1 + l.ch = l.input[position] + return l.readResult() + } // Reset position and try again as a regular number l.position = position l.readPosition = position + 1 diff --git a/lexer_test.go b/lexer_test.go index 629aa75d..8322af5e 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -1,11 +1,13 @@ -package chess +package chess_test import ( "os" "testing" + + "github.com/corentings/chess/v3" ) -func TestLexer(t *testing.T) { +func TestLexer_TokenizesFullGame(t *testing.T) { input := `[Event "Example"] [Site "Internet"] [Date "2023.12.06"] @@ -16,60 +18,60 @@ func TestLexer(t *testing.T) { 1. e4 e5 2. Nf3 Nc6 3. Bb5 {This is the Ruy Lopez.} 3... a6 1-0` - lexer := NewLexer(input) + lexer := chess.NewLexer(input) expectedTokens := []struct { - typ TokenType + typ chess.TokenType value string }{ - {TagStart, "["}, - {TagKey, "Event"}, - {TagValue, "Example"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Site"}, - {TagValue, "Internet"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Date"}, - {TagValue, "2023.12.06"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Round"}, - {TagValue, "1"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "White"}, - {TagValue, "Player1"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Black"}, - {TagValue, "Player2"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Result"}, - {TagValue, "1-0"}, - {TagEnd, "]"}, - {MoveNumber, "1"}, - {DOT, "."}, - {SQUARE, "e4"}, - {SQUARE, "e5"}, - {MoveNumber, "2"}, - {DOT, "."}, - {PIECE, "N"}, - {SQUARE, "f3"}, - {PIECE, "N"}, - {SQUARE, "c6"}, - {MoveNumber, "3"}, - {DOT, "."}, - {PIECE, "B"}, - {SQUARE, "b5"}, - {CommentStart, "{"}, - {COMMENT, "This is the Ruy Lopez."}, - {CommentEnd, "}"}, - {MoveNumber, "3"}, - {ELLIPSIS, "..."}, - {SQUARE, "a6"}, - {RESULT, "1-0"}, + {chess.TagStart, "["}, + {chess.TagKey, "Event"}, + {chess.TagValue, "Example"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Site"}, + {chess.TagValue, "Internet"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Date"}, + {chess.TagValue, "2023.12.06"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Round"}, + {chess.TagValue, "1"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "White"}, + {chess.TagValue, "Player1"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Black"}, + {chess.TagValue, "Player2"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Result"}, + {chess.TagValue, "1-0"}, + {chess.TagEnd, "]"}, + {chess.MoveNumber, "1"}, + {chess.DOT, "."}, + {chess.SQUARE, "e4"}, + {chess.SQUARE, "e5"}, + {chess.MoveNumber, "2"}, + {chess.DOT, "."}, + {chess.PIECE, "N"}, + {chess.SQUARE, "f3"}, + {chess.PIECE, "N"}, + {chess.SQUARE, "c6"}, + {chess.MoveNumber, "3"}, + {chess.DOT, "."}, + {chess.PIECE, "B"}, + {chess.SQUARE, "b5"}, + {chess.CommentStart, "{"}, + {chess.COMMENT, "This is the Ruy Lopez."}, + {chess.CommentEnd, "}"}, + {chess.MoveNumber, "3"}, + {chess.ELLIPSIS, "..."}, + {chess.SQUARE, "a6"}, + {chess.RESULT, "1-0"}, } for i, expected := range expectedTokens { @@ -82,23 +84,23 @@ func TestLexer(t *testing.T) { // Test EOF token := lexer.NextToken() - if token.Type != EOF { + if token.Type != chess.EOF { t.Errorf("Expected EOF token, got %v", token.Type) } } -func Test_TagKey(t *testing.T) { +func TestLexer_ParsesTagValueWithApostrophe(t *testing.T) { input := "[Opening \"King's Indian Attack, General\"]" - lexer := NewLexer(input) + lexer := chess.NewLexer(input) expectedTokens := []struct { - typ TokenType + typ chess.TokenType value string }{ - {TagStart, "["}, - {TagKey, "Opening"}, - {TagValue, "King's Indian Attack, General"}, - {TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Opening"}, + {chess.TagValue, "King's Indian Attack, General"}, + {chess.TagEnd, "]"}, } for i, expected := range expectedTokens { @@ -110,102 +112,33 @@ func Test_TagKey(t *testing.T) { } } -func TestCheck(t *testing.T) { +func TestLexer_CheckAndCheckmateSuffixes(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Check", input: "e5+", - expected: []Token{ - {Type: SQUARE, Value: "e5"}, - {Type: CHECK, Value: "+"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.CHECK, Value: "+"}, }, }, { name: "Checkmate", input: "e5#", - expected: []Token{ - {Type: SQUARE, Value: "e5"}, - {Type: CHECKMATE, Value: "#"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) - - for i, expected := range tt.expected { - token := lexer.NextToken() - if token.Type != expected.Type || token.Value != expected.Value { - t.Errorf("Token %d - Expected {%v, %q}, got {%v, %q}", - i, expected.Type, expected.Value, token.Type, token.Value) - } - } - - // Verify we get EOF after all tokens - token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) - } - }) - } -} - -func TestDisambiguation(t *testing.T) { - tests := []struct { - name string - input string - expected []Token - }{ - { - name: "Disambiguation by file", - input: "Nbd7", - expected: []Token{ - {Type: PIECE, Value: "N"}, - {Type: FILE, Value: "b"}, - {Type: SQUARE, Value: "d7"}, - }, - }, - { - name: "Disambiguation by rank", - input: "N4d7", - expected: []Token{ - {Type: PIECE, Value: "N"}, - {Type: RANK, Value: "4"}, - {Type: SQUARE, Value: "d7"}, - }, - }, - - { - name: "Disambiguation in game", - input: "1. e4 e5 2. Nf3 Nc6 3. Nbd7", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: SQUARE, Value: "e5"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c6"}, - {Type: MoveNumber, Value: "3"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: FILE, Value: "b"}, - {Type: SQUARE, Value: "d7"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.CHECKMATE, Value: "#"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -217,54 +150,54 @@ func TestDisambiguation(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestPromotion(t *testing.T) { +func TestLexer_Promotion(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Promotion", input: "e8=Q", - expected: []Token{ - {Type: SQUARE, Value: "e8"}, - {Type: PROMOTION, Value: "="}, - {Type: PromotionPiece, Value: "Q"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e8"}, + {Type: chess.PROMOTION, Value: "="}, + {Type: chess.PromotionPiece, Value: "Q"}, }, }, { name: "Promotion in game", input: "1. e8=Q e1=N 2. exd8=R", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e8"}, - {Type: PROMOTION, Value: "="}, - {Type: PromotionPiece, Value: "Q"}, - {Type: SQUARE, Value: "e1"}, - {Type: PROMOTION, Value: "="}, - {Type: PromotionPiece, Value: "N"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: FILE, Value: "e"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "d8"}, - {Type: PROMOTION, Value: "="}, - {Type: PromotionPiece, Value: "R"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e8"}, + {Type: chess.PROMOTION, Value: "="}, + {Type: chess.PromotionPiece, Value: "Q"}, + {Type: chess.SQUARE, Value: "e1"}, + {Type: chess.PROMOTION, Value: "="}, + {Type: chess.PromotionPiece, Value: "N"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.FILE, Value: "e"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "d8"}, + {Type: chess.PROMOTION, Value: "="}, + {Type: chess.PromotionPiece, Value: "R"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -276,95 +209,95 @@ func TestPromotion(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestNAG(t *testing.T) { +func TestLexer_NAG(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "NAG", input: "e5 $1", - expected: []Token{ - {Type: SQUARE, Value: "e5"}, - {Type: NAG, Value: "$1"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.NAG, Value: "$1"}, }, }, { name: "NAG in game", input: "1. e5 $1 e6 $2 2. Nf3 $3", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e5"}, - {Type: NAG, Value: "$1"}, - {Type: SQUARE, Value: "e6"}, - {Type: NAG, Value: "$2"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: NAG, Value: "$3"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.NAG, Value: "$1"}, + {Type: chess.SQUARE, Value: "e6"}, + {Type: chess.NAG, Value: "$2"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + {Type: chess.NAG, Value: "$3"}, }, }, { name: "NAG and comment after move", input: "e4 $1 {Good move}", - expected: []Token{ - {Type: SQUARE, Value: "e4"}, - {Type: NAG, Value: "$1"}, - {Type: CommentStart, Value: "{"}, - {Type: COMMENT, Value: "Good move"}, - {Type: CommentEnd, Value: "}"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "$1"}, + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.COMMENT, Value: "Good move"}, + {Type: chess.CommentEnd, Value: "}"}, }, }, { name: "Comment and NAG after move", input: "e4 {Good move} $1", - expected: []Token{ - {Type: SQUARE, Value: "e4"}, - {Type: CommentStart, Value: "{"}, - {Type: COMMENT, Value: "Good move"}, - {Type: CommentEnd, Value: "}"}, - {Type: NAG, Value: "$1"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.COMMENT, Value: "Good move"}, + {Type: chess.CommentEnd, Value: "}"}, + {Type: chess.NAG, Value: "$1"}, }, }, { name: "Move with multiple NAGs and comment", input: "e4 $1 $2 {Excellent move}", - expected: []Token{ - {Type: SQUARE, Value: "e4"}, - {Type: NAG, Value: "$1"}, - {Type: NAG, Value: "$2"}, - {Type: CommentStart, Value: "{"}, - {Type: COMMENT, Value: "Excellent move"}, - {Type: CommentEnd, Value: "}"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "$1"}, + {Type: chess.NAG, Value: "$2"}, + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.COMMENT, Value: "Excellent move"}, + {Type: chess.CommentEnd, Value: "}"}, }, }, { name: "Move with comment and multiple NAGs", input: "e4 {Excellent move} $1 $2", - expected: []Token{ - {Type: SQUARE, Value: "e4"}, - {Type: CommentStart, Value: "{"}, - {Type: COMMENT, Value: "Excellent move"}, - {Type: CommentEnd, Value: "}"}, - {Type: NAG, Value: "$1"}, - {Type: NAG, Value: "$2"}, + expected: []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.COMMENT, Value: "Excellent move"}, + {Type: chess.CommentEnd, Value: "}"}, + {Type: chess.NAG, Value: "$1"}, + {Type: chess.NAG, Value: "$2"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -376,84 +309,84 @@ func TestNAG(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestCaptures(t *testing.T) { +func TestLexer_Captures(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Pawn capture", input: "exf3", - expected: []Token{ - {Type: FILE, Value: "e"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "f3"}, + expected: []chess.Token{ + {Type: chess.FILE, Value: "e"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "f3"}, }, }, { name: "Piece capture", input: "Nxc6", - expected: []Token{ - {Type: PIECE, Value: "N"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "c6"}, + expected: []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "c6"}, }, }, { name: "Piece capture with file disambiguation", input: "Nbxc6", - expected: []Token{ - {Type: PIECE, Value: "N"}, - {Type: FILE, Value: "b"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "c6"}, + expected: []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.FILE, Value: "b"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "c6"}, }, }, { name: "Piece capture with rank disambiguation", input: "N4xd5", - expected: []Token{ - {Type: PIECE, Value: "N"}, - {Type: RANK, Value: "4"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "d5"}, + expected: []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.RANK, Value: "4"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "d5"}, }, }, { name: "Complex position with captures", input: "1. e4 d5 2. Nf3 Nc6 3. Nbxd5", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: SQUARE, Value: "d5"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c6"}, - {Type: MoveNumber, Value: "3"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: FILE, Value: "b"}, - {Type: CAPTURE, Value: "x"}, - {Type: SQUARE, Value: "d5"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.SQUARE, Value: "d5"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "c6"}, + {Type: chess.MoveNumber, Value: "3"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.FILE, Value: "b"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "d5"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -465,44 +398,44 @@ func TestCaptures(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } // Test captures in context -func TestCapturesInGame(t *testing.T) { +func TestLexer_CapturesInGameContext(t *testing.T) { input := "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Bxc6" expectedTokens := []struct { - typ TokenType + typ chess.TokenType value string }{ - {MoveNumber, "1"}, - {DOT, "."}, - {SQUARE, "e4"}, - {SQUARE, "e5"}, - {MoveNumber, "2"}, - {DOT, "."}, - {PIECE, "N"}, - {SQUARE, "f3"}, - {PIECE, "N"}, - {SQUARE, "c6"}, - {MoveNumber, "3"}, - {DOT, "."}, - {PIECE, "B"}, - {SQUARE, "b5"}, - {SQUARE, "a6"}, - {MoveNumber, "4"}, - {DOT, "."}, - {PIECE, "B"}, - {CAPTURE, "x"}, - {SQUARE, "c6"}, + {chess.MoveNumber, "1"}, + {chess.DOT, "."}, + {chess.SQUARE, "e4"}, + {chess.SQUARE, "e5"}, + {chess.MoveNumber, "2"}, + {chess.DOT, "."}, + {chess.PIECE, "N"}, + {chess.SQUARE, "f3"}, + {chess.PIECE, "N"}, + {chess.SQUARE, "c6"}, + {chess.MoveNumber, "3"}, + {chess.DOT, "."}, + {chess.PIECE, "B"}, + {chess.SQUARE, "b5"}, + {chess.SQUARE, "a6"}, + {chess.MoveNumber, "4"}, + {chess.DOT, "."}, + {chess.PIECE, "B"}, + {chess.CAPTURE, "x"}, + {chess.SQUARE, "c6"}, } - lexer := NewLexer(input) + lexer := chess.NewLexer(input) for i, expected := range expectedTokens { token := lexer.NextToken() @@ -513,76 +446,76 @@ func TestCapturesInGame(t *testing.T) { } } -func TestCommands(t *testing.T) { +func TestLexer_Commands(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Command", input: "{[%clk 12:34:56]}", - expected: []Token{ - {Type: CommentStart, Value: "{"}, - {Type: CommandStart, Value: "[%"}, - {Type: CommandName, Value: "clk"}, - {Type: CommandParam, Value: "12:34:56"}, - {Type: CommandEnd, Value: "]"}, - {Type: CommentEnd, Value: "}"}, + expected: []chess.Token{ + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.CommandStart, Value: "[%"}, + {Type: chess.CommandName, Value: "clk"}, + {Type: chess.CommandParam, Value: "12:34:56"}, + {Type: chess.CommandEnd, Value: "]"}, + {Type: chess.CommentEnd, Value: "}"}, }, }, { name: "Command in game", input: "1. e4 {[%clk 12:34:56]} e5", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: CommentStart, Value: "{"}, - {Type: CommandStart, Value: "[%"}, - {Type: CommandName, Value: "clk"}, - {Type: CommandParam, Value: "12:34:56"}, - {Type: CommandEnd, Value: "]"}, - {Type: CommentEnd, Value: "}"}, - {Type: SQUARE, Value: "e5"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.CommandStart, Value: "[%"}, + {Type: chess.CommandName, Value: "clk"}, + {Type: chess.CommandParam, Value: "12:34:56"}, + {Type: chess.CommandEnd, Value: "]"}, + {Type: chess.CommentEnd, Value: "}"}, + {Type: chess.SQUARE, Value: "e5"}, }, }, { // Test multiple commands name: "Multiple commands", input: "{[%clk 0:00:07][%eval -6.05] White is toast}", - expected: []Token{ - {Type: CommentStart, Value: "{"}, - {Type: CommandStart, Value: "[%"}, - {Type: CommandName, Value: "clk"}, - {Type: CommandParam, Value: "0:00:07"}, - {Type: CommandEnd, Value: "]"}, - {Type: CommandStart, Value: "[%"}, - {Type: CommandName, Value: "eval"}, - {Type: CommandParam, Value: "-6.05"}, - {Type: CommandEnd, Value: "]"}, - {Type: COMMENT, Value: "White is toast"}, - {Type: CommentEnd, Value: "}"}, + expected: []chess.Token{ + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.CommandStart, Value: "[%"}, + {Type: chess.CommandName, Value: "clk"}, + {Type: chess.CommandParam, Value: "0:00:07"}, + {Type: chess.CommandEnd, Value: "]"}, + {Type: chess.CommandStart, Value: "[%"}, + {Type: chess.CommandName, Value: "eval"}, + {Type: chess.CommandParam, Value: "-6.05"}, + {Type: chess.CommandEnd, Value: "]"}, + {Type: chess.COMMENT, Value: "White is toast"}, + {Type: chess.CommentEnd, Value: "}"}, }, }, { name: "Command with multiple parameters", input: "{[%command 1:45:12,Nf6,\"very interesting, but wrong\"]}", - expected: []Token{ - {Type: CommentStart, Value: "{"}, - {Type: CommandStart, Value: "[%"}, - {Type: CommandName, Value: "command"}, - {Type: CommandParam, Value: "1:45:12"}, - {Type: CommandParam, Value: "Nf6"}, - {Type: CommandParam, Value: "very interesting, but wrong"}, - {Type: CommandEnd, Value: "]"}, - {Type: CommentEnd, Value: "}"}, + expected: []chess.Token{ + {Type: chess.CommentStart, Value: "{"}, + {Type: chess.CommandStart, Value: "[%"}, + {Type: chess.CommandName, Value: "command"}, + {Type: chess.CommandParam, Value: "1:45:12"}, + {Type: chess.CommandParam, Value: "Nf6"}, + {Type: chess.CommandParam, Value: "very interesting, but wrong"}, + {Type: chess.CommandEnd, Value: "]"}, + {Type: chess.CommentEnd, Value: "}"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -594,112 +527,112 @@ func TestCommands(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestVariations(t *testing.T) { +func TestLexer_Variations(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Variation start", input: "1. e4 (1. d4) e5", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "d4"}, - {Type: VariationEnd, Value: ")"}, - {Type: SQUARE, Value: "e5"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "d4"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.SQUARE, Value: "e5"}, }, }, { name: "Variation in game", input: "1. e4 (1. d4) e5 2. Nf3 (2. Nc3) Nc6", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "d4"}, - {Type: VariationEnd, Value: ")"}, - {Type: SQUARE, Value: "e5"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c3"}, - {Type: VariationEnd, Value: ")"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c6"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "d4"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "c3"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "c6"}, }, }, { name: "Nested variations", input: "1. e4 (1. d4 (1. c4)) 1... e5", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "d4"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "c4"}, - {Type: VariationEnd, Value: ")"}, - {Type: VariationEnd, Value: ")"}, - {Type: MoveNumber, Value: "1"}, - {Type: ELLIPSIS, Value: "..."}, - {Type: SQUARE, Value: "e5"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "d4"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "c4"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.ELLIPSIS, Value: "..."}, + {Type: chess.SQUARE, Value: "e5"}, }, }, { name: "Another variation", input: "1. e4 e5 (1... e6 2. d4 d5) 2. Nf3", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: SQUARE, Value: "e5"}, - {Type: VariationStart, Value: "("}, - {Type: MoveNumber, Value: "1"}, - {Type: ELLIPSIS, Value: "..."}, - {Type: SQUARE, Value: "e6"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "d4"}, - {Type: SQUARE, Value: "d5"}, - {Type: VariationEnd, Value: ")"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.VariationStart, Value: "("}, + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.ELLIPSIS, Value: "..."}, + {Type: chess.SQUARE, Value: "e6"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "d4"}, + {Type: chess.SQUARE, Value: "d5"}, + {Type: chess.VariationEnd, Value: ")"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -711,87 +644,87 @@ func TestVariations(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestCaslting(t *testing.T) { +func TestLexer_Castling(t *testing.T) { tests := []struct { name string input string - expected []Token + expected []chess.Token }{ { name: "Short castle", input: "O-O", - expected: []Token{ - {Type: KingsideCastle, Value: "O-O"}, + expected: []chess.Token{ + {Type: chess.KingsideCastle, Value: "O-O"}, }, }, { name: "Long castle", input: "O-O-O", - expected: []Token{ - {Type: QueensideCastle, Value: "O-O-O"}, + expected: []chess.Token{ + {Type: chess.QueensideCastle, Value: "O-O-O"}, }, }, { name: "Short castle in game", input: "1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6 4. O-O", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: SQUARE, Value: "e5"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c6"}, - {Type: MoveNumber, Value: "3"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "B"}, - {Type: SQUARE, Value: "c4"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f6"}, - {Type: MoveNumber, Value: "4"}, - {Type: DOT, Value: "."}, - {Type: KingsideCastle, Value: "O-O"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "c6"}, + {Type: chess.MoveNumber, Value: "3"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "B"}, + {Type: chess.SQUARE, Value: "c4"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f6"}, + {Type: chess.MoveNumber, Value: "4"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.KingsideCastle, Value: "O-O"}, }, }, { name: "Long castle in game", input: "1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6 4. O-O-O", - expected: []Token{ - {Type: MoveNumber, Value: "1"}, - {Type: DOT, Value: "."}, - {Type: SQUARE, Value: "e4"}, - {Type: SQUARE, Value: "e5"}, - {Type: MoveNumber, Value: "2"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f3"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "c6"}, - {Type: MoveNumber, Value: "3"}, - {Type: DOT, Value: "."}, - {Type: PIECE, Value: "B"}, - {Type: SQUARE, Value: "c4"}, - {Type: PIECE, Value: "N"}, - {Type: SQUARE, Value: "f6"}, - {Type: MoveNumber, Value: "4"}, - {Type: DOT, Value: "."}, - {Type: QueensideCastle, Value: "O-O-O"}, + expected: []chess.Token{ + {Type: chess.MoveNumber, Value: "1"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.SQUARE, Value: "e5"}, + {Type: chess.MoveNumber, Value: "2"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "c6"}, + {Type: chess.MoveNumber, Value: "3"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.PIECE, Value: "B"}, + {Type: chess.SQUARE, Value: "c4"}, + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f6"}, + {Type: chess.MoveNumber, Value: "4"}, + {Type: chess.DOT, Value: "."}, + {Type: chess.QueensideCastle, Value: "O-O-O"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lexer := NewLexer(tt.input) + lexer := chess.NewLexer(tt.input) for i, expected := range tt.expected { token := lexer.NextToken() @@ -803,100 +736,36 @@ func TestCaslting(t *testing.T) { // Verify we get EOF after all tokens token := lexer.NextToken() - if token.Type != EOF { - t.Errorf("Expected EOF token after capture, got %v", token.Type) + if token.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got %v", token.Type) } }) } } -func TestFuzzRepro_b41648629adb0a5d_y(t *testing.T) { - input := "y" - lexer := NewLexer(input) - - var tokens []Token - for { - token := lexer.NextToken() - tokens = append(tokens, token) - - if token.Type == EOF { - break - } - - if len(tokens) > len(input)*2 { // Arbitrary limit based on input length - t.Errorf("Too many tokens generated for input length") - break - } - } -} - -func TestFuzzRepro_ff9f899cf2252ff1_a(t *testing.T) { - input := "a" - lexer := NewLexer(input) - - var tokens []Token - defer func() { - if r := recover(); r != nil { - t.Errorf("Lexer panicked on input %q: %v", input, r) - } - }() - for { - token := lexer.NextToken() - tokens = append(tokens, token) - - if token.Type == EOF { - break - } - - if len(tokens) > len(input)*2 { // Arbitrary limit based on input length - t.Errorf("Too many tokens generated for input length") - break - } - } -} - -func TestFuzzRepro_167803a88524c396(t *testing.T) { - input := "{[%0" - lexer := NewLexer(input) - - var tokens []Token - for { - token := lexer.NextToken() - tokens = append(tokens, token) - - if token.Type == EOF { - break - } - - if len(tokens) > len(input)*2 { // Arbitrary limit based on input length - t.Errorf("Too many tokens generated for input length") - break - } - } -} - -func TestFuzzRepro_b68c42fa4236bdd7(t *testing.T) { - input := "{[%,\"" - lexer := NewLexer(input) - - var tokens []Token - defer func() { - if r := recover(); r != nil { - t.Errorf("Lexer panicked on input %q: %v", input, r) - } - }() - for { - token := lexer.NextToken() - tokens = append(tokens, token) - - if token.Type == EOF { - break - } - - if len(tokens) > len(input)*2 { // Arbitrary limit based on input length - t.Errorf("Too many tokens generated for input length") - break - } +func TestFuzzRegressions(t *testing.T) { + seeds := []string{"y", "a", "{[%0", "{[%,\""} + for _, input := range seeds { + t.Run(input, func(t *testing.T) { + lexer := chess.NewLexer(input) + defer func() { + if r := recover(); r != nil { + t.Errorf("Lexer panicked on input %q: %v", input, r) + } + }() + count := 0 + for { + token := lexer.NextToken() + count++ + if token.Type == chess.EOF { + break + } + if count > len(input)*3 { + t.Errorf("Too many tokens generated for input %q", input) + break + } + } + }) } } @@ -964,8 +833,8 @@ func FuzzLexer(f *testing.F) { } }() - lexer := NewLexer(input) - var tokens []Token + lexer := chess.NewLexer(input) + var tokens []chess.Token t.Log("Input:", input) @@ -975,7 +844,7 @@ func FuzzLexer(f *testing.F) { tokens = append(tokens, token) // Stop at EOF - if token.Type == EOF { + if token.Type == chess.EOF { break } @@ -992,132 +861,132 @@ func FuzzLexer(f *testing.F) { }) } -func validateTokens(t *testing.T, tokens []Token) { +func validateTokens(t *testing.T, tokens []chess.Token) { for i, token := range tokens { // Validate specific token values switch token.Type { - case FILE: + case chess.FILE: if len(token.Value) != 1 || token.Value[0] < 'a' || token.Value[0] > 'h' { t.Errorf("Invalid file at token %d: %v", i, token.Value) } - case RANK: - if len(token.Value) != 1 || (!isRank(token.Value[0]) && token.Error == nil) { + case chess.RANK: + if len(token.Value) != 1 || (!(token.Value[0] >= '1' && token.Value[0] <= '8') && token.Error == nil) { t.Errorf("Invalid rank at token %d: %v", i, token.Value) } - case PIECE: - if len(token.Value) != 1 || (!isPiece(token.Value[0]) && token.Error == nil) { + case chess.PIECE: + if len(token.Value) != 1 || (!(token.Value[0] == 'N' || token.Value[0] == 'B' || token.Value[0] == 'R' || token.Value[0] == 'Q' || token.Value[0] == 'K') && token.Error == nil) { t.Errorf("Invalid piece at token %d: %v", i, token.Value) } - case PromotionPiece: - if len(token.Value) != 1 || (!isPiece(token.Value[0]) && token.Error == nil) { + case chess.PromotionPiece: + if len(token.Value) != 1 || (!(token.Value[0] == 'N' || token.Value[0] == 'B' || token.Value[0] == 'R' || token.Value[0] == 'Q' || token.Value[0] == 'K') && token.Error == nil) { t.Errorf("Invalid promotion piece at token %d: %v", i, token.Value) } } } } -func TestSingleFromPosPGN(t *testing.T) { +func TestLexer_FromPositionFixture(t *testing.T) { // Read fixture data, err := os.ReadFile("fixtures/pgns/single_frompos.pgn") if err != nil { t.Fatalf("Failed to read fixture: %v", err) } - lexer := NewLexer(string(data)) + lexer := chess.NewLexer(string(data)) expected := []struct { - typ TokenType + typ chess.TokenType 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, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Event"}, + {chess.TagValue, "Slav Defense: Chebanenko Variation, 5. cxd5"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Site"}, + {chess.TagValue, ""}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Date"}, + {chess.TagValue, "2025.07.12"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Round"}, + {chess.TagValue, "1"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "White"}, + {chess.TagValue, ""}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Black"}, + {chess.TagValue, ""}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Result"}, + {chess.TagValue, "*"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "UTCDate"}, + {chess.TagValue, "2025.07.12"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "UTCTime"}, + {chess.TagValue, "15:51:01"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "Variant"}, + {chess.TagValue, "Standard"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "ECO"}, + {chess.TagValue, "D15"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "FEN"}, + {chess.TagValue, "rnbqkb1r/1p2pppp/p1p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 0 5"}, + {chess.TagEnd, "]"}, + {chess.TagStart, "["}, + {chess.TagKey, "SetUp"}, + {chess.TagValue, "1"}, + {chess.TagEnd, "]"}, // Moves - {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"}, - {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, "}"}, - {RESULT, "*"}, + {chess.MoveNumber, "5"}, + {chess.DOT, "."}, + {chess.FILE, "c"}, + {chess.CAPTURE, "x"}, + {chess.SQUARE, "d5"}, + {chess.VariationStart, "("}, + {chess.MoveNumber, "5"}, + {chess.DOT, "."}, + {chess.SQUARE, "e3"}, + {chess.SQUARE, "e6"}, + {chess.MoveNumber, "6"}, + {chess.DOT, "."}, + {chess.FILE, "c"}, + {chess.CAPTURE, "x"}, + {chess.SQUARE, "d5"}, + {chess.FILE, "c"}, + {chess.CAPTURE, "x"}, + {chess.SQUARE, "d5"}, + {chess.VariationEnd, ")"}, + {chess.MoveNumber, "5"}, + {chess.ELLIPSIS, "..."}, + {chess.FILE, "c"}, + {chess.CAPTURE, "x"}, + {chess.SQUARE, "d5"}, + {chess.MoveNumber, "6"}, + {chess.DOT, "."}, + {chess.SQUARE, "e3"}, + {chess.SQUARE, "e6"}, + {chess.CommentStart, "{"}, + {chess.CommandStart, "[%"}, + {chess.CommandName, "eval"}, + {chess.CommandParam, "0.11"}, + {chess.CommandEnd, "]"}, + {chess.CommentEnd, "}"}, + {chess.RESULT, "*"}, } for i, exp := range expected { @@ -1128,7 +997,179 @@ func TestSingleFromPosPGN(t *testing.T) { } } // final EOF - if tok := lexer.NextToken(); tok.Type != EOF { + if tok := lexer.NextToken(); tok.Type != chess.EOF { t.Errorf("Expected EOF, got %v", tok.Type) } } + +func assertTokenSequence(t *testing.T, l *chess.Lexer, expected []chess.Token) { + t.Helper() + for i, exp := range expected { + tok := l.NextToken() + if tok.Type != exp.Type || tok.Value != exp.Value { + t.Errorf("Token %d: expected {%v, %q}, got {%v, %q}", + i, exp.Type, exp.Value, tok.Type, tok.Value) + } + if exp.Error != nil && tok.Error == nil { + t.Errorf("Token %d: expected error %q, got nil", i, exp.Error) + } + } + if tok := l.NextToken(); tok.Type != chess.EOF { + t.Errorf("expected EOF after sequence, got {%v, %q}", tok.Type, tok.Value) + } +} + +func TestLexer_EmptyInput(t *testing.T) { + for _, in := range []string{"", " ", " \t\n "} { + l := chess.NewLexer(in) + tok := l.NextToken() + if tok.Type != chess.EOF { + t.Errorf("NewLexer(%q).NextToken() = %v, want EOF", in, tok.Type) + } + } +} + +func TestLexer_UndefinedToken(t *testing.T) { + for _, in := range []string{"@", ";", "~"} { + l := chess.NewLexer(in) + tok := l.NextToken() + if tok.Type != chess.Undefined || tok.Value != in { + t.Errorf("NewLexer(%q).NextToken() = {%v, %q}, want {Undefined, %q}", in, tok.Type, tok.Value, in) + } + if eof := l.NextToken(); eof.Type != chess.EOF { + t.Errorf("expected EOF after undefined, got %v", eof.Type) + } + } +} + +func TestLexer_DeambiguationSquare(t *testing.T) { + tests := []struct { + name string + input string + want []chess.Token + }{ + {"full_square", "Qe8f7", []chess.Token{ + {Type: chess.PIECE, Value: "Q"}, + {Type: chess.DeambiguationSquare, Value: "e8"}, + {Type: chess.SQUARE, Value: "f7"}, + }}, + {"full_square_capture", "Qg8xg7", []chess.Token{ + {Type: chess.PIECE, Value: "Q"}, + {Type: chess.DeambiguationSquare, Value: "g8"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "g7"}, + }}, + {"file_only", "Nbd7", []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.FILE, Value: "b"}, + {Type: chess.SQUARE, Value: "d7"}, + }}, + {"rank_only", "N4d7", []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.RANK, Value: "4"}, + {Type: chess.SQUARE, Value: "d7"}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertTokenSequence(t, chess.NewLexer(tt.input), tt.want) + }) + } +} + +func TestLexer_ResultSpellings(t *testing.T) { + tests := []struct { + name string + input string + want []chess.Token + }{ + {"white_wins", "1-0", []chess.Token{{Type: chess.RESULT, Value: "1-0"}}}, + {"black_wins", "0-1", []chess.Token{{Type: chess.RESULT, Value: "0-1"}}}, + {"ongoing", "*", []chess.Token{{Type: chess.RESULT, Value: "*"}}}, + {"draw", "1/2-1/2", []chess.Token{ + {Type: chess.RESULT, Value: "1/2-1/2"}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertTokenSequence(t, chess.NewLexer(tt.input), tt.want) + }) + } +} + +func TestLexer_NAGSymbols(t *testing.T) { + tests := []struct { + name string + input string + want []chess.Token + }{ + {"double_bang", "e4!!", []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "!!"}, + }}, + {"bang", "e4!", []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "!"}, + }}, + {"question", "e4?", []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "?"}, + }}, + {"dubious", "e4?!", []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "?!"}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertTokenSequence(t, chess.NewLexer(tt.input), tt.want) + }) + } +} + +func TestLexer_NAGMaximalRun(t *testing.T) { + // A run of more than two '!'/'?' characters must be a single NAG token, + // not split into a valid token plus a trailing symbol (regression for the + // "!!!" bug that produced two NAG tokens). + assertTokenSequence(t, chess.NewLexer("e4!!!"), []chess.Token{ + {Type: chess.SQUARE, Value: "e4"}, + {Type: chess.NAG, Value: "!!!"}, + }) +} + +func TestLexer_ErrorPaths(t *testing.T) { + tests := []struct { + name string + input string + wantMsg string + }{ + {"unterminated_comment", "{unterm", "unterminated comment"}, + {"unterminated_quote", `{[%clk "12:34]}`, "unterminated quote"}, + {"invalid_command", `{[%clk }]`, "invalid command in comment"}, + {"invalid_piece", "Ze4", "invalid piece"}, + {"invalid_square", "z9", "invalid square"}, + {"invalid_rank", "N9", "invalid rank"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := chess.NewLexer(tt.input) + var errToken chess.Token + for { + tok := l.NextToken() + if tok.Error != nil { + errToken = tok + break + } + if tok.Type == chess.EOF { + t.Fatalf("reached EOF without an error token for input %q", tt.input) + } + } + if errToken.Error == nil { + t.Fatalf("expected error for input %q, got nil", tt.input) + } + if errToken.Error.Error() != tt.wantMsg { + t.Errorf("error = %q, want %q", errToken.Error.Error(), tt.wantMsg) + } + }) + } +} diff --git a/magic.go b/magic.go new file mode 100644 index 00000000..715b109b --- /dev/null +++ b/magic.go @@ -0,0 +1,243 @@ +package chess + +import ( + "math/bits" +) + +// diaAttack returns a bitboard representing possible diagonal moves for a +// sliding piece, considering occupied squares as blocking further movement. +// +// Implementation: index a deterministic magic-bitboard table generated at +// package init from checked-in magic constants. +func diaAttack(occupied bitboard, sq Square) bitboard { + return bishopMagicAttacks[sq][((occupied&bishopMagicMasks[sq])*bishopMagics[sq])>>bishopMagicShifts[sq]] +} + +func slowDiaAttack(occupied bitboard, sq Square) bitboard { + f := int(sq) & 7 + r := int(sq) >> 3 + occ := uint64(occupied) + var attacks uint64 + // NE: rank+1, file+1 (NE-SW diagonal = bbDiagonals[sq]). + for d := 1; f+d < 8 && r+d < 8; d++ { + bit := uint64(1) << (63 - ((r + d) << 3) - (f + d)) + attacks |= bit + if occ&bit != 0 { + break + } + } + // SW: rank-1, file-1 (NE-SW diagonal, opposite side). + for d := 1; f-d >= 0 && r-d >= 0; d++ { + bit := uint64(1) << (63 - ((r - d) << 3) - (f - d)) + attacks |= bit + if occ&bit != 0 { + break + } + } + // NW: rank+1, file-1 (NW-SE diagonal = bbAntiDiagonals[sq]). + for d := 1; f-d >= 0 && r+d < 8; d++ { + bit := uint64(1) << (63 - ((r + d) << 3) - (f - d)) + attacks |= bit + if occ&bit != 0 { + break + } + } + // SE: rank-1, file+1 (NW-SE diagonal, opposite side). + for d := 1; f+d < 8 && r-d >= 0; d++ { + bit := uint64(1) << (63 - ((r - d) << 3) - (f + d)) + attacks |= bit + if occ&bit != 0 { + break + } + } + return bitboard(attacks) +} + +// hvAttack returns a bitboard representing possible horizontal and vertical +// moves for a sliding piece, considering occupied squares as blocking +// further movement. +// +// Implementation: index a deterministic magic-bitboard table generated at +// package init from checked-in magic constants. +func hvAttack(occupied bitboard, sq Square) bitboard { + return rookMagicAttacks[sq][((occupied&rookMagicMasks[sq])*rookMagics[sq])>>rookMagicShifts[sq]] +} + +func slowHVAttack(occupied bitboard, sq Square) bitboard { + f := int(sq) & 7 + r := int(sq) >> 3 + occ := uint64(occupied) + var attacks uint64 + // E: file+1. + for df := 1; f+df < 8; df++ { + bit := uint64(1) << (63 - (r << 3) - (f + df)) + attacks |= bit + if occ&bit != 0 { + break + } + } + // W: file-1. + for df := 1; f-df >= 0; df++ { + bit := uint64(1) << (63 - (r << 3) - (f - df)) + attacks |= bit + if occ&bit != 0 { + break + } + } + // N: rank+1. + for dr := 1; r+dr < 8; dr++ { + bit := uint64(1) << (63 - ((r + dr) << 3) - f) + attacks |= bit + if occ&bit != 0 { + break + } + } + // S: rank-1. + for dr := 1; r-dr >= 0; dr++ { + bit := uint64(1) << (63 - ((r - dr) << 3) - f) + attacks |= bit + if occ&bit != 0 { + break + } + } + return bitboard(attacks) +} + +var ( + rookMagicMasks = [64]bitboard{} + bishopMagicMasks = [64]bitboard{} + rookMagicShifts = [64]uint{} + bishopMagicShifts = [64]uint{} + rookMagicAttacks = [64][4096]bitboard{} + bishopMagicAttacks = [64][512]bitboard{} +) + +// Deterministically generated for this package's A1=MSB square numbering. +var rookMagics = [64]bitboard{ //nolint:gochecknoglobals // lookup constants + 0x000009284d008402, 0x0602004108040082, 0x5262005024880102, 0x071200141020086a, + 0x4003008620500009, 0x0000220010088042, 0x4001004000208015, 0x3620120880410022, + 0x02804d0280542200, 0x80c0081001020400, 0x2058044010200801, 0x4108004200040040, + 0x00a0080050008180, 0x0818220080401200, 0x8000220045028600, 0x40800020014000c0, + 0x4080009408420005, 0x0000080210040001, 0x88c1002400090002, 0x0809001008010004, + 0x00100a0010420020, 0x102c100020008080, 0x003000200040c008, 0x4080004020014000, + 0x010010984200011c, 0x1800010204001008, 0x101200540a000830, 0x0020800800800400, + 0x0100100023000900, 0x0022002042001084, 0x0060200042401008, 0x4000400020800080, + 0x0028012200004084, 0x3900611400d01802, 0x0202000404001020, 0x0002002200100408, + 0x3080080080100080, 0x8000200080801000, 0x0000200280400084, 0x0450288080004000, + 0x000202000c205181, 0x00222c000e100108, 0x8031080110400420, 0x0048008008800400, + 0x0002020020081041, 0x1020018061801000, 0x0010024000200040, 0x2080004000402000, + 0x9182000102046484, 0x0000800200010080, 0x0083000300080400, 0x082a002030048a00, + 0x2181001000090020, 0x0401801000200180, 0x3200404000201000, 0x0000802080004006, + 0x0200048114002042, 0x0200430406002088, 0x2200211028020024, 0x4100080010020500, + 0x0a80080004100082, 0x2700200031000840, 0x0040001000200041, 0x0080006015804008, +} + +var bishopMagics = [64]bitboard{ //nolint:gochecknoglobals // lookup constants + 0x0002100142040242, 0x0000400841140080, 0x2030e04064084220, 0x000120813092020a, + 0x0510c0040020a800, 0x0000810104010400, 0x0400078084100260, 0x0480808808020200, + 0x1284040842042000, 0x0221a00400808021, 0x4000068850010010, 0x0a10001020222022, + 0x6802124842020062, 0x8050002084100106, 0x6222092108020020, 0x4a2c012188600001, + 0x4408880100400020, 0x10421e0204004212, 0x006210b000804100, 0x1410401011010210, + 0x0800044200908800, 0x0000104828007000, 0x1004210108041001, 0x60009004200210c0, + 0x10a408a482082400, 0x0008081120009082, 0x0802208104020040, 0x0084200200082080, + 0x0100020080080080, 0x4142045101900100, 0x0022022023900100, 0x4002034080200812, + 0x04a2048008540081, 0x4004088530421004, 0x8268080800808400, 0x0000848014002000, + 0x800c080100220040, 0x0000208224080080, 0x0008208004091244, 0x0428400420024a81, + 0x0081020a00420204, 0x5002011080b0880a, 0x10f2001040422080, 0x0044004480a02000, + 0x0008000420401100, 0x0784026088001240, 0x0202040810210200, 0x2021120420420220, + 0xe000422508088400, 0x000001008820880a, 0x4006011048040004, 0x0008020210008000, + 0x8000040408800821, 0x8016048104090400, 0x002e080829042120, 0x1040a00210011104, + 0x0020240948141002, 0xe025880d48200108, 0x0041010840020620, 0x0002121040400450, + 0x0148248900004010, 0x2008180460811001, 0x0010308080808600, 0x0804101208011014, +} + +func initMagicAttackTables() { + for sq := range numOfSquaresInBoard { + square := Square(sq) + rookMagicMasks[sq] = rookMagicMask(square) + rookMagicShifts[sq] = uint(numOfSquaresInBoard - bits.OnesCount64(uint64(rookMagicMasks[sq]))) + initMagicAttack(rookMagicAttacks[sq][:], rookMagicMasks[sq], rookMagics[sq], rookMagicShifts[sq], square, slowHVAttack) + + bishopMagicMasks[sq] = bishopMagicMask(square) + bishopMagicShifts[sq] = uint(numOfSquaresInBoard - bits.OnesCount64(uint64(bishopMagicMasks[sq]))) + initMagicAttack( + bishopMagicAttacks[sq][:], + bishopMagicMasks[sq], + bishopMagics[sq], + bishopMagicShifts[sq], + square, + slowDiaAttack, + ) + } +} + +func initMagicAttack( + attacks []bitboard, + mask bitboard, + magic bitboard, + shift uint, + sq Square, + attack func(bitboard, Square) bitboard, +) { + for _, occupied := range magicOccupancies(mask) { + idx := ((occupied & mask) * magic) >> shift + attacks[idx] = attack(occupied, sq) + } +} + +func magicOccupancies(mask bitboard) []bitboard { + bitCount := bits.OnesCount64(uint64(mask)) + occupancies := make([]bitboard, 1<= 1; r-- { + mask |= bbForSquare(NewSquare(File(file), Rank(r))) + } + for f := file + 1; f <= 6; f++ { + mask |= bbForSquare(NewSquare(File(f), Rank(rank))) + } + for f := file - 1; f >= 1; f-- { + mask |= bbForSquare(NewSquare(File(f), Rank(rank))) + } + return mask +} + +func bishopMagicMask(sq Square) bitboard { + file := int(sq.File()) + rank := int(sq.Rank()) + var mask bitboard + for f, r := file+1, rank+1; f <= 6 && r <= 6; f, r = f+1, r+1 { + mask |= bbForSquare(NewSquare(File(f), Rank(r))) + } + for f, r := file-1, rank-1; f >= 1 && r >= 1; f, r = f-1, r-1 { + mask |= bbForSquare(NewSquare(File(f), Rank(r))) + } + for f, r := file-1, rank+1; f >= 1 && r <= 6; f, r = f-1, r+1 { + mask |= bbForSquare(NewSquare(File(f), Rank(r))) + } + for f, r := file+1, rank-1; f <= 6 && r >= 1; f, r = f+1, r-1 { + mask |= bbForSquare(NewSquare(File(f), Rank(r))) + } + return mask +} diff --git a/magic_attack_test.go b/magic_attack_test.go new file mode 100644 index 00000000..487ffeef --- /dev/null +++ b/magic_attack_test.go @@ -0,0 +1,29 @@ +package chess + +import "testing" + +func TestMagicHVAttackMatchesSlowAttack(t *testing.T) { + for sq := range numOfSquaresInBoard { + square := Square(sq) + for _, occupied := range magicOccupancies(rookMagicMasks[sq]) { + got := hvAttack(occupied, square) + want := slowHVAttack(occupied, square) + if got != want { + t.Fatalf("hvAttack(%s, %064b): got %064b, want %064b", square, occupied, got, want) + } + } + } +} + +func TestMagicDiaAttackMatchesSlowAttack(t *testing.T) { + for sq := range numOfSquaresInBoard { + square := Square(sq) + for _, occupied := range magicOccupancies(bishopMagicMasks[sq]) { + got := diaAttack(occupied, square) + want := slowDiaAttack(occupied, square) + if got != want { + t.Fatalf("diaAttack(%s, %064b): got %064b, want %064b", square, occupied, got, want) + } + } + } +} diff --git a/move.go b/move.go index 72873641..c26bc6c4 100644 --- a/move.go +++ b/move.go @@ -42,60 +42,87 @@ const ( // inCheck indicates that the move puts the moving player in check and // is therefore invalid. inCheck + // Null indicates that the move flips the side to move without moving + // any piece. Null moves are encoded as "Z0" in PGN and "0000" in + // UCI. They are never generated by ValidMoves and must be inserted + // explicitly via Game.NullMove or Game.UnsafeMove. + Null MoveTag = 1 << 6 ) +// NewNullMove returns a Move that flips the side to move without moving any +// piece. The returned move carries the Null MoveTag and has no origin, +// destination, or promotion. Null moves must be inserted via Game.NullMove +// or Game.UnsafeMove; they are rejected by Game.Move because they are never +// part of a position's legal moves. +func NewNullMove() Move { + return Move{tags: Null} +} + // A Move is the movement of a piece from one square to another. type Move struct { - parent *Move - position *Position // Position after the move - nag string - comments string - command map[string]string // Store commands as key-value pairs - commentBlocks []CommentBlock - structuredComments bool - children []*Move // Main line and variations - number uint - tags MoveTag - s1 Square - s2 Square - promo PieceType -} - -// String returns a string useful for debugging. String doesn't return + tags MoveTag + s1 Square + s2 Square + promo PieceType +} + +// String returns a string useful for debugging. String doesn't return // algebraic notation. -func (m *Move) String() string { +func (m Move) String() string { return m.s1.String() + m.s2.String() + m.promo.String() } // S1 returns the origin square of the move. -func (m *Move) S1() Square { +func (m Move) S1() Square { return m.s1 } // S2 returns the destination square of the move. -func (m *Move) S2() Square { +func (m Move) S2() Square { return m.s2 } // Promo returns promotion piece type of the move. -func (m *Move) Promo() PieceType { +func (m Move) Promo() PieceType { return m.promo } // HasTag returns true if the move contains the MoveTag given. -func (m *Move) HasTag(tag MoveTag) bool { +func (m Move) HasTag(tag MoveTag) bool { return (tag & m.tags) > 0 } -// AddTag adds the given MoveTag to the move's tags using a bitwise OR operation. -// Multiple tags can be combined by calling AddTag multiple times. -func (m *Move) AddTag(tag MoveTag) { +// WithTag returns a copy of the move with the given MoveTag added. +func (m Move) WithTag(tag MoveTag) Move { m.tags |= tag + return m +} + +// MoveNode is one occurrence of a move in a game's move tree. +type MoveNode struct { + move Move + parent *MoveNode + position *Position + children []*MoveNode + number uint + nags []string + commentBlocks []CommentBlock +} + +// Move returns the move value for this move node. +func (n *MoveNode) Move() Move { + if n == nil { + return Move{} + } + return n.move } -func (m *Move) GetCommand(key string) (string, bool) { - for i := len(m.commentBlocks) - 1; i >= 0; i-- { - block := m.commentBlocks[i] +func (n *MoveNode) GetCommand(key string) (string, bool) { + if n == nil { + return "", false + } + for i := len(n.commentBlocks) - 1; i >= 0; i-- { + block := n.commentBlocks[i] for j := len(block.Items) - 1; j >= 0; j-- { item := block.Items[j] if item.Kind == CommentCommand && item.Key == key { @@ -103,243 +130,197 @@ func (m *Move) GetCommand(key string) (string, bool) { } } } - - if m.command == nil { - m.command = make(map[string]string) - return "", false - } - value, ok := m.command[key] - return value, ok + return "", false } -func (m *Move) SetCommand(key, value string) { - if m.command == nil { - m.command = make(map[string]string) - } - m.command[key] = value - - if !m.structuredComments { - m.rebuildLegacyCommentBlock() +func (n *MoveNode) SetCommand(key, value string) { + if n == nil { return } - - m.ensureCommentBlocksFromLegacy() - for blockIdx := len(m.commentBlocks) - 1; blockIdx >= 0; blockIdx-- { - for itemIdx := len(m.commentBlocks[blockIdx].Items) - 1; itemIdx >= 0; itemIdx-- { - item := &m.commentBlocks[blockIdx].Items[itemIdx] + for blockIdx := len(n.commentBlocks) - 1; blockIdx >= 0; blockIdx-- { + for itemIdx := len(n.commentBlocks[blockIdx].Items) - 1; itemIdx >= 0; itemIdx-- { + item := &n.commentBlocks[blockIdx].Items[itemIdx] if item.Kind == CommentCommand && item.Key == key { item.Value = value return } } } - - if len(m.commentBlocks) == 0 { - m.commentBlocks = append(m.commentBlocks, CommentBlock{}) + if len(n.commentBlocks) == 0 { + n.commentBlocks = append(n.commentBlocks, CommentBlock{}) } - last := len(m.commentBlocks) - 1 - m.commentBlocks[last].Items = append(m.commentBlocks[last].Items, CommentItem{ + last := len(n.commentBlocks) - 1 + n.commentBlocks[last].Items = append(n.commentBlocks[last].Items, CommentItem{ Kind: CommentCommand, Key: key, Value: value, }) } -func (m *Move) SetComment(comment string) { - commands := m.command - m.comments = comment - m.commentBlocks = nil - m.structuredComments = false - if comment != "" || len(commands) > 0 { - m.rebuildLegacyCommentBlockWithCommands(commands) +func (n *MoveNode) SetComment(comment string) { + if n == nil { + return } + if comment == "" { + n.commentBlocks = nil + return + } + n.commentBlocks = []CommentBlock{{Items: []CommentItem{{Kind: CommentText, Text: comment}}}} } -func (m *Move) AddComment(comment string) { - if !m.structuredComments { - m.comments += comment - m.rebuildLegacyCommentBlock() +func (n *MoveNode) AddComment(comment string) { + if n == nil || comment == "" { return } - - m.ensureCommentBlocksFromLegacy() - if len(m.commentBlocks) == 0 { - m.commentBlocks = []CommentBlock{{Items: []CommentItem{{Kind: CommentText, Text: comment}}}} - m.syncLegacyAnnotationsFromBlocks() + if len(n.commentBlocks) == 0 { + n.commentBlocks = []CommentBlock{{Items: []CommentItem{{Kind: CommentText, Text: comment}}}} return } - - lastBlock := len(m.commentBlocks) - 1 - for i := len(m.commentBlocks[lastBlock].Items) - 1; i >= 0; i-- { - item := &m.commentBlocks[lastBlock].Items[i] + lastBlock := len(n.commentBlocks) - 1 + for i := len(n.commentBlocks[lastBlock].Items) - 1; i >= 0; i-- { + item := &n.commentBlocks[lastBlock].Items[i] if item.Kind == CommentText { item.Text += comment - m.syncLegacyAnnotationsFromBlocks() return } } - m.commentBlocks[lastBlock].Items = append(m.commentBlocks[lastBlock].Items, CommentItem{Kind: CommentText, Text: comment}) - m.syncLegacyAnnotationsFromBlocks() + n.commentBlocks[lastBlock].Items = append(n.commentBlocks[lastBlock].Items, CommentItem{Kind: CommentText, Text: comment}) } -func (m *Move) Comments() string { - if len(m.commentBlocks) > 0 { - return flattenCommentText(m.commentBlocks) +func (n *MoveNode) Comments() string { + if n == nil { + return "" } - return m.comments + return flattenCommentText(n.commentBlocks) } -// CommentBlocks returns a defensive copy of the move's structured PGN comment blocks. -func (m *Move) CommentBlocks() []CommentBlock { - m.ensureCommentBlocksFromLegacy() - blocks := make([]CommentBlock, len(m.commentBlocks)) - for i, block := range m.commentBlocks { - blocks[i].Items = append([]CommentItem(nil), block.Items...) +// CommentBlocks returns a defensive copy of the move node's structured PGN comment blocks. +func (n *MoveNode) CommentBlocks() []CommentBlock { + if n == nil { + return nil } - return blocks + return copyCommentBlocks(n.commentBlocks) } -func (m *Move) NAG() string { - return m.nag -} - -func (m *Move) SetNAG(nag string) { - m.nag = nag +// NAGs returns a defensive copy of the move node's numeric annotation glyphs in +// their canonical "$N" form, in the order they were added. +func (n *MoveNode) NAGs() []string { + if n == nil { + return nil + } + return append([]string(nil), n.nags...) } -func (m *Move) Parent() *Move { - return m.parent +// SetNAGs replaces all of the move node's NAGs. Each value is normalised via +// ParseNAG. The replacement is all-or-nothing: if any value is malformed the +// node is left unchanged and the error is returned. +func (n *MoveNode) SetNAGs(nags []string) error { + if n == nil { + return nil + } + normalised := make([]string, 0, len(nags)) + for _, nag := range nags { + parsed, err := ParseNAG(nag) + if err != nil { + return err + } + normalised = append(normalised, parsed) + } + n.nags = normalised + return nil } -func (m *Move) Position() *Position { - return m.position +// AddNAG normalises and appends a single NAG to the move node. Order is +// preserved. A malformed value leaves the node unchanged. +func (n *MoveNode) AddNAG(nag string) error { + if n == nil { + return nil + } + parsed, err := ParseNAG(nag) + if err != nil { + return err + } + n.nags = append(n.nags, parsed) + return nil } -func (m *Move) Children() []*Move { - return m.children +func (n *MoveNode) Parent() *MoveNode { + if n == nil { + return nil + } + return n.parent } -func (m *Move) Number() int { - ret := int(m.number) - if ret == 0 { // 0 indicates the 'dummy' rootMove - ret = 1 +func (n *MoveNode) Position() *Position { + if n == nil || n.position == nil { + return nil } - - return ret + return n.position.copy() } -// FullMoveNumber returns the full move number (increments after Black's move). -func (m *Move) FullMoveNumber() int { - return m.Number() +func (n *MoveNode) Children() []*MoveNode { + if n == nil { + return nil + } + return append([]*MoveNode{}, n.children...) } -// Ply returns the half-move number (increments every move). -func (m *Move) Ply() int { - if m == nil { - return 0 - } - if m.position == nil { +func (n *MoveNode) Number() int { + if n == nil { return 0 } - moveNumber := int(m.number) - // we reverse the color because the position is after the move has been played - if m.position.turn == Black { - // After the move, it's White's turn, so the move was by Black - return (moveNumber-1)*2 + 1 - } - // After the move, it's Black's turn, so the move was by White - return (moveNumber)*2 + 0 -} - -// Clone returns a deep copy of a move. -// -// Per-field exceptions: -// -// parent: not copied; the clone'd move has no parent -// children: not copied; the clone'd move has no children -func (m *Move) Clone() *Move { - ret := &Move{} - ret.parent = nil - ret.position = m.position.copy() - ret.nag = m.nag - ret.comments = m.comments - ret.commentBlocks = copyCommentBlocks(m.commentBlocks) - ret.structuredComments = m.structuredComments - ret.children = make([]*Move, 0) - ret.number = m.number - ret.tags = m.tags - ret.s1 = m.s1 - ret.s2 = m.s2 - ret.promo = m.promo - - ret.command = make(map[string]string) - for k, v := range m.command { - ret.command[k] = v + ret := int(n.number) + if ret == 0 { + ret = 1 } - return ret } -func (m *Move) addCommentBlock(block CommentBlock) { - if len(block.Items) == 0 { - return - } - m.commentBlocks = append(m.commentBlocks, block) - m.structuredComments = true - m.syncLegacyAnnotationsFromBlocks() -} - -func (m *Move) hasAnnotations() bool { - return len(m.commentBlocks) > 0 || m.comments != "" || len(m.command) > 0 +// FullMoveNumber returns the full move number (increments after Black's move). +func (n *MoveNode) FullMoveNumber() int { + return n.Number() } -func (m *Move) syncLegacyAnnotationsFromBlocks() { - m.comments = flattenCommentText(m.commentBlocks) - m.command = make(map[string]string) - for _, block := range m.commentBlocks { - for _, item := range block.Items { - if item.Kind == CommentCommand { - m.command[item.Key] = item.Value - } - } +// Ply returns the half-move number (increments every move). +func (n *MoveNode) Ply() int { + if n == nil || n.position == nil { + return 0 } - if len(m.command) == 0 { - m.command = nil + moveNumber := int(n.number) + if n.position.turn == Black { + return (moveNumber-1)*2 + 1 } + return moveNumber * 2 } -func (m *Move) ensureCommentBlocksFromLegacy() { - if len(m.commentBlocks) > 0 || (m.comments == "" && len(m.command) == 0) { +func (n *MoveNode) addCommentBlock(block CommentBlock) { + if n == nil || len(block.Items) == 0 { return } - - block := CommentBlock{} - if m.comments != "" { - block.Items = append(block.Items, CommentItem{Kind: CommentText, Text: m.comments}) - } - for _, key := range sortedCommandKeys(m.command) { - block.Items = append(block.Items, CommentItem{Kind: CommentCommand, Key: key, Value: m.command[key]}) - } - m.commentBlocks = []CommentBlock{block} + n.commentBlocks = append(n.commentBlocks, block) } -func (m *Move) rebuildLegacyCommentBlock() { - m.rebuildLegacyCommentBlockWithCommands(m.command) +func (n *MoveNode) hasAnnotations() bool { + return n != nil && (len(n.commentBlocks) > 0 || len(n.nags) > 0) } -func (m *Move) rebuildLegacyCommentBlockWithCommands(commands map[string]string) { - block := CommentBlock{} - if m.comments != "" { - block.Items = append(block.Items, CommentItem{Kind: CommentText, Text: m.comments}) +func (n *MoveNode) clone() *MoveNode { + if n == nil { + return nil } - for _, key := range sortedCommandKeys(commands) { - block.Items = append(block.Items, CommentItem{Kind: CommentCommand, Key: key, Value: commands[key]}) + ret := &MoveNode{ + move: n.move, + position: n.position.copy(), + number: n.number, + nags: append([]string(nil), n.nags...), + commentBlocks: copyCommentBlocks(n.commentBlocks), } - if len(block.Items) == 0 { - m.commentBlocks = nil - return + for _, child := range n.children { + childClone := child.clone() + childClone.parent = ret + ret.children = append(ret.children, childClone) } - m.commentBlocks = []CommentBlock{block} + return ret } func flattenCommentText(blocks []CommentBlock) string { @@ -365,16 +346,3 @@ func copyCommentBlocks(src []CommentBlock) []CommentBlock { } return blocks } - -func (m *Move) cloneChildren(srcChildren []*Move) { - if len(srcChildren) == 0 { - return - } - - for _, srcMv := range srcChildren { - dstMv := srcMv.Clone() - dstMv.parent = m - dstMv.cloneChildren(srcMv.children) - m.children = append(m.children, dstMv) - } -} diff --git a/move_applier.go b/move_applier.go new file mode 100644 index 00000000..3e71d00e --- /dev/null +++ b/move_applier.go @@ -0,0 +1,147 @@ +package chess + +// This file is the single source of truth for "what changes about a Position +// when a non-null Move is played": the incremental bookkeeping rule +// (moveCount, halfMoveClock, castleRights, enPassant, board, turn, inCheck) and +// the primitives it shares. The copy-on-write applier (Position.Update) and the +// in-place applier (Position.makeMove) both delegate here to applyMove, so the +// rule cannot drift between them. +// +// Null moves are a separate concern (nullUpdate) and intentionally do not share +// the applyMove body: a null move never touches the board, castling rights, or +// pieces, so folding it in would force applyMove to skip a board copy it can +// otherwise avoid. They share only the moveCount rule via nextMoveCount. +// +// The Zobrist hash is deliberately NOT part of the core: Update computes it +// incrementally, while makeMove skips it entirely. Perft never inspects +// intermediate hashes; unmakeMove restores the original from the positionUndo +// record. See docs/adr/0001-single-move-application-core.md. + +// nullUpdate returns a new position that is identical to the receiver except +// for the side to move, the half-move clock, the full-move clock, and the +// en-passant square. The half-move clock is incremented as for a quiet move, +// the full-move clock advances when Black passed, and the en-passant capture +// right is cleared. The board, castling rights, and pieces are unchanged. +// The Zobrist hash is recomputed incrementally. +func (pos *Position) nullUpdate() *Position { + newPos := &Position{ + board: pos.board, + turn: pos.turn.Other(), + castleRights: pos.castleRights, + enPassantSquare: NoSquare, + halfMoveClock: pos.halfMoveClock + 1, + moveCount: pos.nextMoveCount(), + } + + // Recompute inCheck for the new side to move. The board is unchanged, + // so the new side may now be in check if a piece attacks their king. + newPos.inCheck = isInCheck(newPos) + newPos.hash = pos.nullUpdateHash(newPos.enPassantSquare) + return newPos +} + +// nextMoveCount returns the full-move number after the side to move has moved. +// The number advances when Black is to move. This is the single source for the +// moveCount rule; it was previously inlined in Update, makeMove, and nullUpdate. +func (pos *Position) nextMoveCount() int { + if pos.turn == Black { + return pos.moveCount + 1 + } + return pos.moveCount +} + +// applyMove applies the non-null bookkeeping rule to pos in place. It updates +// the board, side to move, castling rights, en-passant square, half-move clock, +// full-move number, the in-check flag, and invalidates the cached legal-move +// and status values. +// +// The hash is intentionally left untouched: callers own the hash. Update +// computes it from the pre-move state via updateHash; makeMove leaves it stale +// and restores the original on unmakeMove. +// +// Pre-move-derived values (castle rights, en passant, half-move clock) are +// computed before board.update mutates the board, since updateCastleRights and +// updateEnPassantSquare both inspect the origin square on the pre-move board. +// isInCheck is computed after, once the new side to move and board are in place. +func (pos *Position) applyMove(m Move) { + moveCount := pos.nextMoveCount() + ncr := pos.updateCastleRights(m) + p := pos.board.Piece(m.s1) + halfMove := 0 + if p.Type() != Pawn && !m.HasTag(Capture) { + halfMove = pos.halfMoveClock + 1 + } + ep := pos.updateEnPassantSquare(m) + + pos.board.update(m) + pos.turn = pos.turn.Other() + pos.castleRights = ncr + pos.enPassantSquare = ep + pos.halfMoveClock = halfMove + pos.moveCount = moveCount + pos.validMoves = nil + pos.statusCached = false + if m.HasTag(Check) { + pos.inCheck = true + } else { + pos.inCheck = isInCheck(pos) + } +} + +// updateCastleRights returns the castling rights after m is played. It inspects +// the origin square on the pre-move board, so it must be called before +// board.update. +func (pos *Position) updateCastleRights(m Move) CastleRights { + removeK := false + removeQ := false + removek := false + removeq := false + p := pos.board.Piece(m.s1) + if p == WhiteKing || m.s1 == H1 || m.s2 == H1 { + removeK = true + } + if p == WhiteKing || m.s1 == A1 || m.s2 == A1 { + removeQ = true + } + if p == BlackKing || m.s1 == H8 || m.s2 == H8 { + removek = true + } + if p == BlackKing || m.s1 == A8 || m.s2 == A8 { + removeq = true + } + var buf [4]byte + n := 0 + for i := range pos.castleRights { + c := pos.castleRights[i] + if (c == 'K' && removeK) || (c == 'Q' && removeQ) || (c == 'k' && removek) || (c == 'q' && removeq) || c == '-' { + continue + } + buf[n] = c + n++ + } + if n == 0 { + return "-" + } + return CastleRights(string(buf[:n])) +} + +// updateEnPassantSquare returns the en-passant target square created by m, or +// NoSquare. It inspects the pre-move board and side to move, so it must be +// called before board.update and the side-to-move flip. +func (pos *Position) updateEnPassantSquare(m Move) Square { + const squaresPerRank = 8 + p := pos.board.Piece(m.s1) + if p.Type() != Pawn { + return NoSquare + } + if pos.turn == White && + (bbForSquare(m.s1)&bbRank2) != 0 && + (bbForSquare(m.s2)&bbRank4) != 0 { + return m.s2 - squaresPerRank + } else if pos.turn == Black && + (bbForSquare(m.s1)&bbRank7) != 0 && + (bbForSquare(m.s2)&bbRank5) != 0 { + return m.s2 + squaresPerRank + } + return NoSquare +} diff --git a/move_applier_test.go b/move_applier_test.go new file mode 100644 index 00000000..28fb3961 --- /dev/null +++ b/move_applier_test.go @@ -0,0 +1,70 @@ +package chess + +import "testing" + +// TestApplyMoveDifferential runs the copy-on-write applier (Position.Update) and +// the in-place applier (Position.makeMove) in lockstep over the canonical perft +// positions and asserts they reach byte-identical FENs after every move. +// +// FEN excludes two fields that the bookkeeping rule also owns: the Zobrist hash +// (Update computes it incrementally, makeMove leaves it stale — by design) and +// the in-check flag (not part of FEN; verified transitively because both +// wrappers go through applyMove, which sets it identically). The hash +// exclusion is the one sanctioned difference between the paths; inCheck is +// covered by code structure rather than by this test. +// +// What the test does cover: perft node counts are blind to the half-move clock +// and full-move number, so a drift in those fields would not change node counts +// (the existing perft suite would stay green) yet would corrupt the 50/75-move +// draw rules. Position.String emits the full FEN, which includes both counters, +// so this comparison is the guard against that drift class. +func TestApplyMoveDifferential(t *testing.T) { + const depth = 3 + fens := []struct{ name, fen string }{ + {"startpos", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"}, + {"kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"}, + {"pos3", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"}, + {"pos4", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1"}, + {"pos5", "r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1"}, + {"pos6", "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8"}, + } + + for _, c := range fens { + t.Run(c.name, func(t *testing.T) { + opt, err := FEN(c.fen) + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + // Two independent positions with their own *Board: cow advances + // immutably via Update (which copies the board each call), while + // inplace mutates and restores via make/unmakeMove. + cow := NewGame(opt).Position() + opt2, err := FEN(c.fen) + if err != nil { + t.Fatalf("FEN decode (inplace): %v", err) + } + inplace := NewGame(opt2).Position() + walkLockstep(t, cow, inplace, depth) + }) + } +} + +func walkLockstep(t *testing.T, cow, inplace *Position, depth int) { + visitLegalMoves(cow, generateLegalOnly, func(m Move) bool { + nextCow := cow.Update(m) + undo := inplace.makeMove(m) + + if nextCow.String() != inplace.String() { + t.Errorf("FEN drift after %s at remaining depth %d:\n Update : %s\n makeMove: %s", + m, depth, nextCow.String(), inplace.String()) + inplace.unmakeMove(undo) + return true // stop iterating this node + } + + if depth > 1 { + walkLockstep(t, nextCow, inplace, depth-1) + } + inplace.unmakeMove(undo) + return false + }) +} diff --git a/move_generation_test.go b/move_generation_test.go new file mode 100644 index 00000000..ad177072 --- /dev/null +++ b/move_generation_test.go @@ -0,0 +1,324 @@ +package chess_test + +import ( + "sort" + "testing" + + "github.com/corentings/chess/v3" +) + +func positionFromFEN(t *testing.T, fen string) *chess.Position { + t.Helper() + opt, err := chess.FEN(fen) + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + return chess.NewGame(opt).Position() +} + +func findMoveByString(moves []chess.Move, want string) (chess.Move, bool) { + for _, move := range moves { + if move.String() == want { + return move, true + } + } + return chess.Move{}, false +} + +func requireMove(t *testing.T, moves []chess.Move, want string) chess.Move { + t.Helper() + move, ok := findMoveByString(moves, want) + if !ok { + t.Fatalf("expected move %s", want) + } + return move +} + +func TestPositionStatus(t *testing.T) { + tests := []struct { + name string + fen string + want chess.Method + }{ + {"starting_position", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", chess.NoMethod}, + {"stalemate", "7k/5K2/6Q1/8/8/8/8/8 b - - 0 1", chess.Stalemate}, + {"checkmate", "7k/5K2/7Q/8/8/8/8/8 b - - 0 1", chess.Checkmate}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := positionFromFEN(t, tt.fen) + if got := pos.Status(); got != tt.want { + t.Fatalf("Status() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestPositionStatusIsStableAcrossRepeatedCallsAndUpdates(t *testing.T) { + checkmate := positionFromFEN(t, "7k/5K2/7Q/8/8/8/8/8 b - - 0 1") + if got := checkmate.Status(); got != chess.Checkmate { + t.Fatalf("first Status() = %s, want %s", got, chess.Checkmate) + } + if got := checkmate.Status(); got != chess.Checkmate { + t.Fatalf("second Status() = %s, want %s", got, chess.Checkmate) + } + + start := chess.NewGame().Position() + move := requireMove(t, start.ValidMoves(), "e2e4") + next := start.Update(move) + if got := start.Status(); got != chess.NoMethod { + t.Fatalf("original Status() after Update = %s, want %s", got, chess.NoMethod) + } + if got := next.Status(); got != chess.NoMethod { + t.Fatalf("updated Status() = %s, want %s", got, chess.NoMethod) + } +} + +func TestMoveGenerationTags(t *testing.T) { + tests := []struct { + name string + fen string + move string + wantTags []chess.MoveTag + noTags []chess.MoveTag + }{ + { + name: "queen_side_castle", + fen: "r3kb1r/p2nqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/R3K2R b KQkq - 1 12", + move: "e8c8", + wantTags: []chess.MoveTag{chess.QueenSideCastle}, + }, + { + name: "king_side_castle_with_check", + fen: "r4b1r/ppp3pp/8/4p3/2Pq4/3P4/PP2QPPP/2k1K2R w K - 0 18", + move: "e1g1", + wantTags: []chess.MoveTag{chess.KingSideCastle, chess.Check}, + }, + { + name: "capture", + fen: "8/7p/3k2p1/8/2p2P2/R5bP/6K1/4r3 w - - 0 44", + move: "g2g3", + wantTags: []chess.MoveTag{chess.Capture}, + }, + { + name: "en_passant_with_check", + fen: "r3k2r/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRKR2 b kq f3 0 1", + move: "e4f3", + wantTags: []chess.MoveTag{chess.EnPassant, chess.Check}, + }, + { + name: "quiet_move", + fen: "8/7p/3k2p1/8/2p2P2/R5KP/8/4r3 b - - 0 44", + move: "d6d5", + noTags: []chess.MoveTag{chess.Capture, chess.EnPassant, chess.Check, chess.KingSideCastle, chess.QueenSideCastle}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := positionFromFEN(t, tt.fen) + move := requireMove(t, pos.ValidMoves(), tt.move) + for _, tag := range tt.wantTags { + if !move.HasTag(tag) { + t.Errorf("%s missing tag %v", tt.move, tag) + } + } + for _, tag := range tt.noTags { + if move.HasTag(tag) { + t.Errorf("%s unexpectedly has tag %v", tt.move, tag) + } + } + }) + } +} + +func TestPromotionCheckTags(t *testing.T) { + pos := positionFromFEN(t, "k7/4P3/8/8/8/8/8/K7 w - - 0 1") + moves := pos.ValidMoves() + + tests := []struct { + move string + wantCheck bool + }{ + {"e7e8q", true}, + {"e7e8r", true}, + {"e7e8b", false}, + {"e7e8n", false}, + } + for _, tt := range tests { + t.Run(tt.move, func(t *testing.T) { + move := requireMove(t, moves, tt.move) + if got := move.HasTag(chess.Check); got != tt.wantCheck { + t.Fatalf("%s Check = %v, want %v", tt.move, got, tt.wantCheck) + } + }) + } +} + +func TestUnsafeMoves(t *testing.T) { + tests := []struct { + name string + fen string + want []string + count int + }{ + { + name: "starting_position", + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + count: 0, + }, + { + name: "pinned_knight", + fen: "4k3/8/8/8/1b6/2N5/8/4K3 w - - 0 1", + count: 8, + }, + { + name: "king_walks_into_check", + fen: "4r3/8/8/8/8/8/8/4K3 w - - 0 1", + want: []string{"e1e2"}, + count: 1, + }, + { + name: "king_adjacent_rook_attacks", + fen: "8/8/8/8/8/3r4/8/4K3 w - - 0 1", + want: []string{"e1d1", "e1d2"}, + count: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := positionFromFEN(t, tt.fen) + moves := pos.UnsafeMoves() + if len(moves) != tt.count { + t.Fatalf("UnsafeMoves() returned %d moves, want %d", len(moves), tt.count) + } + for _, want := range tt.want { + requireMove(t, moves, want) + } + }) + } +} + +func TestLegalMovesInCheckScenarios(t *testing.T) { + t.Run("pinned_rook_stays_on_pin_ray", func(t *testing.T) { + pos := positionFromFEN(t, "k3r3/8/8/8/8/8/4R3/4K3 w - - 0 1") + got := moveStringsFrom(pos.ValidMoves(), chess.E2) + want := []string{"e2e3", "e2e4", "e2e5", "e2e6", "e2e7", "e2e8"} + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("pinned rook moves = %v, want %v", got, want) + } + }) + + t.Run("single_check_allows_capture_block_or_king_move", func(t *testing.T) { + pos := positionFromFEN(t, "k3r3/8/8/8/8/8/R7/4K3 w - - 0 1") + got := moveStrings(pos.ValidMoves()) + want := []string{"a2e2", "e1d1", "e1d2", "e1f1", "e1f2"} + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("single-check legal moves = %v, want %v", got, want) + } + }) + + t.Run("double_check_only_king_moves", func(t *testing.T) { + pos := positionFromFEN(t, "rnb1kbnr/pppp2pp/5N2/6pQ/8/8/PPPPPPPP/RNB1KB1R b KQkq - 0 1") + for _, move := range pos.ValidMoves() { + if move.S1() != chess.E8 { + t.Fatalf("double check generated non-king move %s", move) + } + } + }) + + t.Run("king_move_escapes_check", func(t *testing.T) { + pos := positionFromFEN(t, "4k3/8/8/8/8/8/8/4R3 b - - 0 1") + requireMove(t, pos.ValidMoves(), "e8d8") + }) + + t.Run("discovered_check_tag", func(t *testing.T) { + pos := positionFromFEN(t, "4k3/8/8/8/4N3/8/8/4R1K1 w - - 0 1") + for _, move := range pos.ValidMoves() { + if move.S1() == chess.E4 && move.HasTag(chess.Check) { + return + } + } + t.Fatal("expected at least one knight move from e4 with a discovered check tag") + }) + + t.Run("en_passant_discovered_check", func(t *testing.T) { + pos := positionFromFEN(t, "4r3/8/8/8/4p3/8/3P4/4K1k1 w - - 0 1") + d2d4 := requireMove(t, pos.ValidMoves(), "d2d4") + next := pos.Update(d2d4) + for _, move := range next.ValidMoves() { + if move.HasTag(chess.EnPassant) && move.HasTag(chess.Check) { + return + } + } + t.Fatal("expected en passant move with discovered check tag") + }) + + t.Run("en_passant_cannot_expose_king_to_rook", func(t *testing.T) { + pos := positionFromFEN(t, "4k3/8/8/r2pP2K/8/8/8/8 w - d6 0 1") + if _, ok := findMoveByString(pos.ValidMoves(), "e5d6"); ok { + t.Fatal("en passant exposing the king to a rook must not be legal") + } + if _, ok := findMoveByString(pos.UnsafeMoves(), "e5d6"); !ok { + t.Fatal("UnsafeMoves should report the illegal en passant candidate") + } + }) +} + +func TestSlidingPieceLegalMovesWithBlockers(t *testing.T) { + tests := []struct { + name string + fen string + from chess.Square + want []string + }{ + { + name: "rook_rank_and_file_blockers", + fen: "4k3/8/3p4/8/1P1R1p2/8/3P4/4K3 w - - 0 1", + from: chess.D4, + want: []string{"d4c4", "d4e4", "d4f4", "d4d3", "d4d5", "d4d6"}, + }, + { + name: "bishop_diagonal_blockers", + fen: "4k3/8/1P3p2/8/3B4/8/1p3P2/4K3 w - - 0 1", + from: chess.D4, + want: []string{"d4e5", "d4f6", "d4c5", "d4e3", "d4c3", "d4b2"}, + }, + { + name: "queen_combined_blockers", + fen: "4k3/8/1P1p1p2/8/1P1Q1p2/8/1p1P1P2/4K3 w - - 0 1", + from: chess.D4, + want: []string{ + "d4c4", "d4e4", "d4f4", "d4d3", "d4d5", "d4d6", + "d4e5", "d4f6", "d4c5", "d4e3", "d4c3", "d4b2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := positionFromFEN(t, tt.fen) + got := moveStringsFrom(pos.ValidMoves(), tt.from) + want := append([]string(nil), tt.want...) + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("moves from %s = %v, want %v", tt.from, got, want) + } + }) + } +} + +func moveStringsFrom(moves []chess.Move, from chess.Square) []string { + out := make([]string, 0, len(moves)) + for _, move := range moves { + if move.S1() == from { + out = append(out, move.String()) + } + } + sort.Strings(out) + return out +} diff --git a/move_test.go b/move_test.go index 73e5f043..1e19af81 100644 --- a/move_test.go +++ b/move_test.go @@ -2,6 +2,7 @@ package chess import ( "log" + "reflect" "testing" ) @@ -231,7 +232,7 @@ func TestPositionUpdates(t *testing.T) { t.Fatalf("expected move %s %v to be valid", mt.m, mt.m.tags) } - postPos := mt.pos.Update(mt.m) + postPos := mt.pos.Update(*mt.m) if postPos.String() != mt.postPos.String() { t.Fatalf("starting from board \n%s%s\n after move %s\n expected board to be %s\n%s\n but was %s\n%s\n", mt.pos.String(), @@ -246,82 +247,8 @@ func TestPositionUpdates(t *testing.T) { } } -type perfTest struct { - pos *Position - nodesPerDepth []int -} - -/* https://www.chessprogramming.org/Perft_Results */ -var perfResults = []perfTest{ - {pos: unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), nodesPerDepth: []int{ - 20, 400, 8902, 197281, - // 4865609, 119060324, 3195901860, 84998978956, 2439530234167, 69352859712417 - }}, - {pos: unsafeFEN("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"), nodesPerDepth: []int{ - 48, 2039, 97862, - // 4085603, 193690690 - }}, - {pos: unsafeFEN("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"), nodesPerDepth: []int{ - 14, 191, 2812, 43238, 674624, - // 11030083, 178633661 - }}, - {pos: unsafeFEN("r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1"), nodesPerDepth: []int{ - 6, 264, 9467, 422333, - // 15833292, 706045033 - }}, - {pos: unsafeFEN("r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1"), nodesPerDepth: []int{ - 6, 264, 9467, 422333, - // 15833292, 706045033 - }}, - {pos: unsafeFEN("rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8"), nodesPerDepth: []int{ - 44, 1486, 62379, - // 2103487, 89941194 - }}, - {pos: unsafeFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10"), nodesPerDepth: []int{ - 46, 2079, 89890, - // 3894594, 164075551, 6923051137, 287188994746, 11923589843526, 490154852788714 - }}, -} - -func TestPerfResults(t *testing.T) { - for _, perf := range perfResults { - countMoves(t, perf.pos, []*Position{perf.pos}, perf.nodesPerDepth, len(perf.nodesPerDepth)) - } -} - -func countMoves(t *testing.T, originalPosition *Position, positions []*Position, nodesPerDepth []int, maxDepth int) { - if len(nodesPerDepth) == 0 { - return - } - depth := maxDepth - len(nodesPerDepth) + 1 - expNodes := nodesPerDepth[0] - newPositions := make([]*Position, 0) - for _, pos := range positions { - for _, move := range pos.ValidMoves() { - newPos := pos.Update(&move) - newPositions = append(newPositions, newPos) - } - } - gotNodes := len(newPositions) - if expNodes != gotNodes { - t.Errorf("Depth: %d Expected: %d Got: %d", depth, expNodes, gotNodes) - t.Log("##############################") - t.Log("# Original position info") - t.Log("###") - t.Log(originalPosition.String()) - t.Log(originalPosition.board.Draw()) - t.Log("##############################") - t.Log("# Details in JSONL (http://jsonlines.org)") - t.Log("###") - for _, pos := range positions { - t.Logf(`{"position": "%s", "moves": %d}`, pos.String(), len(pos.ValidMoves())) - } - } - countMoves(t, originalPosition, newPositions, nodesPerDepth[1:], maxDepth) -} - func Test_SetCommentUpdatesComment(t *testing.T) { - move := &Move{} + move := &MoveNode{} move.SetComment("Initial comment") expected := "Initial comment" if move.Comments() != expected { @@ -330,7 +257,8 @@ func Test_SetCommentUpdatesComment(t *testing.T) { } func Test_SetCommentOverwritesExistingComment(t *testing.T) { - move := &Move{comments: "Old comment"} + move := &MoveNode{} + move.SetComment("Old comment") move.SetComment("New comment") expected := "New comment" if move.Comments() != expected { @@ -339,7 +267,8 @@ func Test_SetCommentOverwritesExistingComment(t *testing.T) { } func Test_SetCommentWithEmptyString(t *testing.T) { - move := &Move{comments: "Existing comment"} + move := &MoveNode{} + move.SetComment("Existing comment") move.SetComment("") expected := "" if move.Comments() != expected { @@ -349,7 +278,8 @@ func Test_SetCommentWithEmptyString(t *testing.T) { func TestAddComment(t *testing.T) { t.Run("AddCommentAppendsToExistingComment", func(t *testing.T) { - move := &Move{comments: "Initial comment. "} + move := &MoveNode{} + move.SetComment("Initial comment. ") move.AddComment("Additional comment.") expected := "Initial comment. Additional comment." if move.Comments() != expected { @@ -358,7 +288,7 @@ func TestAddComment(t *testing.T) { }) t.Run("AddCommentToEmptyComment", func(t *testing.T) { - move := &Move{} + move := &MoveNode{} move.AddComment("First comment.") expected := "First comment." if move.Comments() != expected { @@ -369,7 +299,7 @@ func TestAddComment(t *testing.T) { func TestAddCommentToStructuredComments(t *testing.T) { t.Run("AddsFirstTextBlock", func(t *testing.T) { - move := &Move{structuredComments: true} + move := &MoveNode{} move.AddComment("First comment.") blocks := move.CommentBlocks() @@ -380,7 +310,7 @@ func TestAddCommentToStructuredComments(t *testing.T) { }) t.Run("AppendsToExistingTextItem", func(t *testing.T) { - move := &Move{} + move := &MoveNode{} move.addCommentBlock(CommentBlock{Items: []CommentItem{{Kind: CommentText, Text: "First "}}}) move.AddComment("second") @@ -392,7 +322,7 @@ func TestAddCommentToStructuredComments(t *testing.T) { }) t.Run("AppendsTextAfterCommandOnlyBlock", func(t *testing.T) { - move := &Move{} + move := &MoveNode{} move.addCommentBlock(CommentBlock{Items: []CommentItem{{Kind: CommentCommand, Key: "clk", Value: "0:05:00"}}}) move.AddComment("after command") @@ -404,30 +334,50 @@ func TestAddCommentToStructuredComments(t *testing.T) { }) } -func TestNAGReturnsCorrectValue(t *testing.T) { - t.Run("NAGReturnsCorrectValue", func(t *testing.T) { - move := &Move{nag: "!!"} - expected := "!!" - if move.NAG() != expected { - t.Fatalf("expected %v but got %v", expected, move.NAG()) +func TestNAGsReturnCanonicalForm(t *testing.T) { + t.Run("NAGsReturnCanonicalForm", func(t *testing.T) { + move := &MoveNode{} + if err := move.AddNAG("!!"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"$3"} + if got := move.NAGs(); !reflect.DeepEqual(got, expected) { + t.Fatalf("expected %v but got %v", expected, got) } }) } -func TestSetNAGUpdatesNAG(t *testing.T) { - t.Run("SetNAGUpdatesNAG", func(t *testing.T) { - move := &Move{} - move.SetNAG("??") - expected := "??" - if move.NAG() != expected { - t.Fatalf("expected %v but got %v", expected, move.NAG()) +func TestSetNAGsNormalisesValues(t *testing.T) { + t.Run("SetNAGsNormalisesValues", func(t *testing.T) { + move := &MoveNode{} + if err := move.SetNAGs([]string{"??", "$1406"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"$4", "$1406"} + if got := move.NAGs(); !reflect.DeepEqual(got, expected) { + t.Fatalf("expected %v but got %v", expected, got) } }) } +func TestSetNAGsRejectsMalformed(t *testing.T) { + move := &MoveNode{} + if err := move.AddNAG("$1"); err != nil { + t.Fatalf("setup failed: %v", err) + } + // All-or-nothing: a malformed element must leave the node unchanged. + if err := move.SetNAGs([]string{"$1", "$", "$x"}); err == nil { + t.Fatalf("expected error for malformed NAGs, got nil") + } + if got := move.NAGs(); !reflect.DeepEqual(got, []string{"$1"}) { + t.Fatalf("expected unchanged [$1] after failed set, got %v", got) + } +} + func TestGetCommand(t *testing.T) { t.Run("GetCommandReturnsValueIfExists", func(t *testing.T) { - move := &Move{command: map[string]string{"key": "value"}} + move := &MoveNode{} + move.SetCommand("key", "value") value, ok := move.GetCommand("key") if !ok || value != "value" { t.Fatalf("expected value to be 'value' and ok to be true, but got value: %v, ok: %v", value, ok) @@ -435,28 +385,29 @@ func TestGetCommand(t *testing.T) { }) t.Run("GetCommandReturnsFalseIfKeyDoesNotExist", func(t *testing.T) { - move := &Move{command: map[string]string{"key": "value"}} + move := &MoveNode{} + move.SetCommand("key", "value") _, ok := move.GetCommand("nonexistent") if ok { t.Fatalf("expected ok to be false, but got true") } }) - t.Run("GetCommandInitializesCommandMapIfNil", func(t *testing.T) { - move := &Move{} + t.Run("GetCommandDoesNotCreateAnnotations", func(t *testing.T) { + move := &MoveNode{} _, ok := move.GetCommand("key") if ok { t.Fatalf("expected ok to be false, but got true") } - if move.command == nil { - t.Fatalf("expected command map to be initialized, but it was nil") + if move.hasAnnotations() { + t.Fatalf("expected missing command lookup not to create annotations") } }) } func TestStructuredCommentCommandBranches(t *testing.T) { t.Run("SetCommandAddsFirstStructuredBlock", func(t *testing.T) { - move := &Move{structuredComments: true} + move := &MoveNode{} move.SetCommand("eval", "0.25") blocks := move.CommentBlocks() @@ -467,37 +418,20 @@ func TestStructuredCommentCommandBranches(t *testing.T) { }) t.Run("EmptyCommentBlockIsIgnored", func(t *testing.T) { - move := &Move{} + move := &MoveNode{} move.addCommentBlock(CommentBlock{}) if move.hasAnnotations() { t.Fatalf("empty comment block should not add annotations") } }) - - t.Run("LegacyCommandsBecomeCommentBlocks", func(t *testing.T) { - move := &Move{command: map[string]string{"eval": "0.25"}} - blocks := move.CommentBlocks() - if len(blocks) != 1 || len(blocks[0].Items) != 1 { - t.Fatalf("expected command-only legacy block, got %#v", blocks) - } - assertCommentItem(t, blocks[0].Items[0], CommentCommand, "", "eval", "0.25") - }) - - t.Run("EmptyLegacyAnnotationsProduceNoBlock", func(t *testing.T) { - move := &Move{} - move.rebuildLegacyCommentBlockWithCommands(nil) - if len(move.commentBlocks) != 0 { - t.Fatalf("expected no comment blocks, got %#v", move.commentBlocks) - } - }) } func TestPlyNilAndMissingPosition(t *testing.T) { - var nilMove *Move + var nilMove *MoveNode if nilMove.Ply() != 0 { t.Fatalf("nil move should have ply 0") } - if (&Move{}).Ply() != 0 { + if (&MoveNode{}).Ply() != 0 { t.Fatalf("move without position should have ply 0") } } @@ -505,7 +439,7 @@ func TestPlyNilAndMissingPosition(t *testing.T) { func BenchmarkValidMoves(b *testing.B) { pos := unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") b.ResetTimer() - for n := 0; n < b.N; n++ { + for range b.N { pos.ValidMoves() pos.validMoves = nil } @@ -525,67 +459,60 @@ func moveIsValid(pos *Position, m *Move, useTags bool) bool { return false } -func assertMovesAreEqual(t *testing.T, m1, m2 *Move) { +func assertMoveNodesAreEqual(t *testing.T, m1, m2 *MoveNode) { if m1.parent != m2.parent { - t.Fatalf("cloned mv %s parent is not the same", m1) + t.Fatalf("cloned mv %v parent is not the same", m1) } if m1.position.String() != m2.position.String() { - t.Fatalf("cloned mv %s position is not the same", m1) + t.Fatalf("cloned mv %v position is not the same", m1) } - if m1.nag != m2.nag { - t.Fatalf("cloned mv %s nag is not the same", m1) + if !reflect.DeepEqual(m1.NAGs(), m2.NAGs()) { + t.Fatalf("cloned mv %v nags is not the same", m1) } - if m1.comments != m2.comments { - t.Fatalf("cloned mv %s comments is not the same", m1) + if m1.Comments() != m2.Comments() { + t.Fatalf("cloned mv %v comments is not the same", m1) } if m1.number != m2.number { - t.Fatalf("cloned mv %s number is not the same", m1) + t.Fatalf("cloned mv %v number is not the same", m1) } - if m1.tags != m2.tags { - t.Fatalf("cloned mv %s tags is not the same", m1) + if m1.move.tags != m2.move.tags { + t.Fatalf("cloned mv %v tags is not the same", m1) } - if m1.s1 != m2.s1 { - t.Fatalf("cloned mv %s s1 is not the same", m1) + if m1.move.s1 != m2.move.s1 { + t.Fatalf("cloned mv %v s1 is not the same", m1) } - if m1.s2 != m2.s2 { - t.Fatalf("cloned mv %s s2 is not the same", m1) + if m1.move.s2 != m2.move.s2 { + t.Fatalf("cloned mv %v s2 is not the same", m1) } - if m1.promo != m2.promo { - t.Fatalf("cloned mv %s s2 is not the same", m1) + if m1.move.promo != m2.move.promo { + t.Fatalf("cloned mv %v s2 is not the same", m1) } - if len(m1.command) != len(m2.command) { - t.Fatalf("cloned mv %s len(command) is not the same", m1) - } else { - for k, v1 := range m1.command { - v2, ok := m2.command[k] - if !ok || v2 != v1 { - t.Fatalf("cloned mv %s command[%v] is not the same", m1, k) - } - } + if len(m1.commentBlocks) != len(m2.commentBlocks) { + t.Fatalf("cloned mv %v len(commentBlocks) is not the same", m1) } if len(m1.children) != len(m2.children) { - t.Fatalf("cloned mv %s len(command) is not the same", m1) + t.Fatalf("cloned mv %v len(command) is not the same", m1) } else { for idx, c1 := range m1.children { c2 := m2.children[idx] - assertMovesAreEqual(t, c1, c2) + assertMoveNodesAreEqual(t, c1, c2) } } } -func TestMoveClone(t *testing.T) { +func TestMoveNodeClone(t *testing.T) { for _, mt := range validMoves { - mt.m.position = mt.pos - clonedM1 := mt.m.Clone() - assertMovesAreEqual(t, mt.m, clonedM1) + node := &MoveNode{move: *mt.m, position: mt.pos, number: 1, nags: []string{"$1"}} + clonedM1 := node.clone() + assertMoveNodesAreEqual(t, node, clonedM1) clonedM1.SetCommand("foo", "bar") - clonedM2 := clonedM1.Clone() - assertMovesAreEqual(t, clonedM1, clonedM2) + clonedM2 := clonedM1.clone() + assertMoveNodesAreEqual(t, clonedM1, clonedM2) clonedM1.SetCommand("foo", "bar modified") fooVal, ok := clonedM2.GetCommand("foo") if !ok || fooVal != "bar" { - t.Fatalf("cloned mv %s is not a deep copy", clonedM2) + t.Fatalf("cloned mv %v is not a deep copy", clonedM2) } } } diff --git a/move_text_codec.go b/move_text_codec.go new file mode 100644 index 00000000..60079482 --- /dev/null +++ b/move_text_codec.go @@ -0,0 +1,375 @@ +package chess + +import ( + "errors" + "fmt" + "strings" +) + +var ( + // ErrInvalidMoveText indicates syntactically invalid or unresolvable move text. + ErrInvalidMoveText = errors.New("chess: invalid move text") + // ErrMoveTextMissingPosition indicates that a codec operation needs a position. + ErrMoveTextMissingPosition = errors.New("chess: move text requires position") + // ErrMoveTextUnsupportedRawDecode indicates that a format cannot be decoded + // without a position. + ErrMoveTextUnsupportedRawDecode = errors.New("chess: move text raw decode unsupported") + // ErrInvalidMoveTextCodec indicates an impossible codec format/policy pair. + ErrInvalidMoveTextCodec = errors.New("chess: invalid move text codec") + // ErrUnsafeMoveTextUnsupported indicates that a codec cannot be used with + // unsafe move-text insertion. + ErrUnsafeMoveTextUnsupported = errors.New("chess: unsafe move text unsupported") +) + +// MoveTextFormat identifies the concrete move text grammar handled by a codec. +type MoveTextFormat uint8 + +const ( + // MoveTextFormatSAN is strict Standard Algebraic notation. + MoveTextFormatSAN MoveTextFormat = iota + // MoveTextFormatLongAlgebraic is coordinate-expanded algebraic notation. + MoveTextFormatLongAlgebraic + // MoveTextFormatUCI is Universal Chess Interface coordinate notation. + MoveTextFormatUCI +) + +// MoveTextPolicy identifies how permissive a codec is while reading move text. +type MoveTextPolicy uint8 + +const ( + // MoveTextPolicyStrict accepts only generated canonical text. + MoveTextPolicyStrict MoveTextPolicy = iota + // MoveTextPolicyPGNImport accepts documented PGN import spellings and + // canonicalises them when writing. + MoveTextPolicyPGNImport +) + +// MoveTextCodec parses, resolves, validates, and formats move text. +// +// The zero value is the strict SAN codec returned by SAN(). +type MoveTextCodec struct { + format MoveTextFormat + policy MoveTextPolicy +} + +// RawMoveText is unclassified move data decoded without a board position. +type RawMoveText struct { + from Square + to Square + promotion PieceType + null bool +} + +// Move returns the raw move as a Move without legality-derived tags. +func (m RawMoveText) Move() Move { + if m.null { + return NewNullMove() + } + return Move{s1: m.from, s2: m.to, promo: m.promotion} +} + +// From returns the raw origin square. +func (m RawMoveText) From() Square { + return m.from +} + +// To returns the raw destination square. +func (m RawMoveText) To() Square { + return m.to +} + +// Promotion returns the raw promotion piece type. +func (m RawMoveText) Promotion() PieceType { + return m.promotion +} + +// Null reports whether the raw move is a null move. +func (m RawMoveText) Null() bool { + return m.null +} + +// SAN returns the strict Standard Algebraic notation codec. +func SAN() MoveTextCodec { + return MoveTextCodec{} +} + +// PGNImportSAN returns the permissive SAN codec used by PGN import. +func PGNImportSAN() MoveTextCodec { + return MoveTextCodec{ + format: MoveTextFormatSAN, + policy: MoveTextPolicyPGNImport, + } +} + +// LongAlgebraic returns the coordinate-expanded algebraic notation codec. +func LongAlgebraic() MoveTextCodec { + return MoveTextCodec{format: MoveTextFormatLongAlgebraic} +} + +// UCI returns the Universal Chess Interface coordinate notation codec. +func UCI() MoveTextCodec { + return MoveTextCodec{format: MoveTextFormatUCI} +} + +// Format returns the codec's move text format. +func (c MoveTextCodec) Format() MoveTextFormat { + return c.format +} + +// Policy returns the codec's parsing policy. +func (c MoveTextCodec) Policy() MoveTextPolicy { + return c.policy +} + +// String implements fmt.Stringer. +func (c MoveTextCodec) String() string { + switch c { + case SAN(): + return "SAN" + case PGNImportSAN(): + return "PGNImportSAN" + case LongAlgebraic(): + return "LongAlgebraic" + case UCI(): + return "UCI" + default: + return "InvalidMoveTextCodec" + } +} + +// Encode formats a move from the supplied position. Tag-dependent suffixes +// (check, capture, disambiguation) are recomputed from the resulting position +// rather than trusted from the input move; Decode returns already-tagged +// moves from the legal-move generator and does not recompute. +func (c MoveTextCodec) Encode(pos *Position, m Move) (string, error) { + if err := c.validate(); err != nil { + return "", err + } + + switch c.format { + case MoveTextFormatSAN: + if m.HasTag(Null) { + return "Z0", nil + } + if pos == nil { + return "", ErrMoveTextMissingPosition + } + m.tags = moveTags(m, pos) + return algebraicNotation{}.Encode(pos, m), nil + case MoveTextFormatLongAlgebraic: + if m.HasTag(Null) { + return "0000", nil + } + if pos == nil { + return "", ErrMoveTextMissingPosition + } + m.tags = moveTags(m, pos) + return longAlgebraicNotation{}.Encode(pos, m), nil + case MoveTextFormatUCI: + return uciNotation{}.Encode(pos, m), nil + default: + return "", ErrInvalidMoveTextCodec + } +} + +// Decode resolves move text against the supplied position. +func (c MoveTextCodec) Decode(pos *Position, s string) (Move, error) { + if err := c.validate(); err != nil { + return Move{}, err + } + if pos == nil { + return Move{}, ErrMoveTextMissingPosition + } + + var ( + move Move + err error + ) + switch c.format { + case MoveTextFormatSAN: + if c.policy == MoveTextPolicyPGNImport { + s = normaliseImportSAN(s) + } + move, err = algebraicNotation{}.Decode(pos, s) + case MoveTextFormatLongAlgebraic: + move, err = longAlgebraicNotation{}.Decode(pos, s) + case MoveTextFormatUCI: + move, err = uciNotation{}.Decode(pos, s) + default: + return Move{}, ErrInvalidMoveTextCodec + } + if err != nil { + return Move{}, fmt.Errorf("%w: %w", ErrInvalidMoveText, err) + } + if !move.HasTag(Null) { + if err = validatePositionMove(pos, move); err != nil { + return Move{}, fmt.Errorf("%w: %w", ErrInvalidMoveText, err) + } + } + return move, nil +} + +// DecodeRaw decodes fully specified move text without a position. +func (c MoveTextCodec) DecodeRaw(s string) (RawMoveText, error) { + if err := c.validate(); err != nil { + return RawMoveText{}, err + } + + switch c.format { + case MoveTextFormatUCI: + return decodeRawUCI(s) + case MoveTextFormatLongAlgebraic: + return decodeRawLongAlgebraic(s) + default: + return RawMoveText{}, ErrMoveTextUnsupportedRawDecode + } +} + +// ValidateSyntax reports whether text matches the codec grammar without +// checking move legality. +func (c MoveTextCodec) ValidateSyntax(s string) error { + if err := c.validate(); err != nil { + return err + } + + switch c.format { + case MoveTextFormatSAN: + if c.policy == MoveTextPolicyPGNImport { + s = normaliseImportSAN(s) + } + if _, err := algebraicNotationParts(s); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidMoveText, err) + } + return nil + case MoveTextFormatLongAlgebraic, MoveTextFormatUCI: + _, err := c.DecodeRaw(s) + return err + default: + return ErrInvalidMoveTextCodec + } +} + +func (c MoveTextCodec) validate() error { + switch c.format { + case MoveTextFormatSAN: + if c.policy == MoveTextPolicyStrict || c.policy == MoveTextPolicyPGNImport { + return nil + } + case MoveTextFormatLongAlgebraic, MoveTextFormatUCI: + if c.policy == MoveTextPolicyStrict { + return nil + } + } + return ErrInvalidMoveTextCodec +} + +func decodeRawUCI(s string) (RawMoveText, error) { + move, err := uciNotation{}.Decode(nil, s) + if err != nil { + return RawMoveText{}, fmt.Errorf("%w: %w", ErrInvalidMoveText, err) + } + if move.HasTag(Null) { + return RawMoveText{null: true}, nil + } + return RawMoveText{ + from: move.S1(), + to: move.S2(), + promotion: move.Promo(), + }, nil +} + +func decodeRawLongAlgebraic(s string) (RawMoveText, error) { + if s == "0000" { + return RawMoveText{null: true}, nil + } + + text := strings.TrimSuffix(strings.TrimSuffix(s, "+"), "#") + if len(text) > 0 && strings.Contains(text, "x") { + text = strings.Replace(text, "x", "", 1) + } + if len(text) > 0 && strings.Contains(text, "=") { + text = strings.Replace(text, "=", "", 1) + } + if len(text) == 5 && isAlgebraicPieceLetter(text[0]) { + text = text[1:] + } + + if len(text) != 4 && len(text) != 5 { + return RawMoveText{}, fmt.Errorf("%w: invalid long algebraic move %q", ErrInvalidMoveText, s) + } + if !isSquareText(text[:2]) || !isSquareText(text[2:4]) { + return RawMoveText{}, fmt.Errorf("%w: invalid long algebraic squares %q", ErrInvalidMoveText, s) + } + + promotion := NoPieceType + if len(text) == 5 { + promotion = algebraicPromotionPiece(text[4]) + if promotion == NoPieceType { + return RawMoveText{}, fmt.Errorf("%w: invalid long algebraic promotion %q", ErrInvalidMoveText, s) + } + } + + return RawMoveText{ + from: squareFromFileRank(text[0], text[1]), + to: squareFromFileRank(text[2], text[3]), + promotion: promotion, + }, nil +} + +func normaliseImportSAN(s string) string { + if len(s) < 3 { + return s + } + + suffix := "" + for len(s) > 0 { + switch { + case strings.HasSuffix(s, "+"): + suffix = "+" + suffix + s = strings.TrimSuffix(s, "+") + case strings.HasSuffix(s, "#"): + suffix = "#" + suffix + s = strings.TrimSuffix(s, "#") + case strings.HasSuffix(s, "!"), strings.HasSuffix(s, "?"): + s = s[:len(s)-1] + case strings.HasSuffix(s, "e.p."): + s = strings.TrimSuffix(s, "e.p.") + default: + goto done + } + } + +done: + if len(s) >= 3 && s[len(s)-2] >= '1' && s[len(s)-2] <= '8' && algebraicPromotionPiece(s[len(s)-1]) != NoPieceType { + s = s[:len(s)-1] + "=" + s[len(s)-1:] + } + return s + suffix +} + +func isSquareText(s string) bool { + return len(s) == 2 && s[0] >= 'a' && s[0] <= 'h' && s[1] >= '1' && s[1] <= '8' +} + +func isAlgebraicPieceLetter(b byte) bool { + switch b { + case 'K', 'Q', 'R', 'B', 'N': + return true + default: + return false + } +} + +func algebraicPromotionPiece(b byte) PieceType { + switch b { + case 'Q': + return Queen + case 'R': + return Rook + case 'B': + return Bishop + case 'N': + return Knight + default: + return NoPieceType + } +} diff --git a/move_text_codec_test.go b/move_text_codec_test.go new file mode 100644 index 00000000..86525358 --- /dev/null +++ b/move_text_codec_test.go @@ -0,0 +1,164 @@ +package chess_test + +import ( + "errors" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestMoveTextCodecZeroValueIsStrictSAN(t *testing.T) { + var zero chess.MoveTextCodec + + if zero != chess.SAN() { + t.Fatalf("zero MoveTextCodec = %#v, want SAN()", zero) + } + if got := zero.Format(); got != chess.MoveTextFormatSAN { + t.Fatalf("zero Format() = %v, want %v", got, chess.MoveTextFormatSAN) + } + if got := zero.Policy(); got != chess.MoveTextPolicyStrict { + t.Fatalf("zero Policy() = %v, want %v", got, chess.MoveTextPolicyStrict) + } + if got := zero.String(); got != "SAN" { + t.Fatalf("zero String() = %q, want %q", got, "SAN") + } +} + +func TestMoveTextCodecConstructors(t *testing.T) { + tests := []struct { + name string + codec chess.MoveTextCodec + wantFormat chess.MoveTextFormat + wantPolicy chess.MoveTextPolicy + wantString string + }{ + { + name: "SAN", + codec: chess.SAN(), + wantFormat: chess.MoveTextFormatSAN, + wantPolicy: chess.MoveTextPolicyStrict, + wantString: "SAN", + }, + { + name: "PGN import SAN", + codec: chess.PGNImportSAN(), + wantFormat: chess.MoveTextFormatSAN, + wantPolicy: chess.MoveTextPolicyPGNImport, + wantString: "PGNImportSAN", + }, + { + name: "long algebraic", + codec: chess.LongAlgebraic(), + wantFormat: chess.MoveTextFormatLongAlgebraic, + wantPolicy: chess.MoveTextPolicyStrict, + wantString: "LongAlgebraic", + }, + { + name: "UCI", + codec: chess.UCI(), + wantFormat: chess.MoveTextFormatUCI, + wantPolicy: chess.MoveTextPolicyStrict, + wantString: "UCI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.codec.Format(); got != tt.wantFormat { + t.Fatalf("Format() = %v, want %v", got, tt.wantFormat) + } + if got := tt.codec.Policy(); got != tt.wantPolicy { + t.Fatalf("Policy() = %v, want %v", got, tt.wantPolicy) + } + if got := tt.codec.String(); got != tt.wantString { + t.Fatalf("String() = %q, want %q", got, tt.wantString) + } + }) + } +} + +func TestMoveTextCodecSANEncodeDecode(t *testing.T) { + pos := chess.StartingPosition() + rawMove, err := chess.UCI().DecodeRaw("e2e4") + if err != nil { + t.Fatalf("DecodeRaw() error = %v", err) + } + staleMove := rawMove.Move() + + encoded, err := chess.SAN().Encode(pos, staleMove) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + if encoded != "e4" { + t.Fatalf("Encode() = %q, want %q", encoded, "e4") + } + + decoded, err := chess.SAN().Decode(pos, encoded) + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + if decoded != staleMove { + t.Fatalf("Decode() = %v, want %v", decoded, staleMove) + } +} + +func TestMoveTextCodecRequiresPositionForResolution(t *testing.T) { + _, err := chess.SAN().Decode(nil, "e4") + if !errors.Is(err, chess.ErrMoveTextMissingPosition) { + t.Fatalf("Decode(nil) error = %v, want ErrMoveTextMissingPosition", err) + } +} + +func TestMoveTextCodecDecodeRejectsIllegalCoordinateMove(t *testing.T) { + _, err := chess.UCI().Decode(chess.StartingPosition(), "e2e5") + if !errors.Is(err, chess.ErrInvalidMoveText) { + t.Fatalf("UCI Decode() error = %v, want ErrInvalidMoveText", err) + } +} + +func TestPGNImportSANCodecAcceptsMissingPromotionEquals(t *testing.T) { + opt, err := chess.FEN("6k1/4P3/8/8/8/8/8/4K3 w - - 0 1") + if err != nil { + t.Fatalf("FEN() error = %v", err) + } + pos := chess.NewGame(opt).Position() + + if _, err := chess.SAN().Decode(pos, "e8Q"); !errors.Is(err, chess.ErrInvalidMoveText) { + t.Fatalf("strict SAN Decode() error = %v, want ErrInvalidMoveText", err) + } + + move, err := chess.PGNImportSAN().Decode(pos, "e8Q") + if err != nil { + t.Fatalf("PGNImportSAN Decode() error = %v", err) + } + encoded, err := chess.SAN().Encode(pos, move) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + if encoded != "e8=Q+" { + t.Fatalf("canonical SAN = %q, want %q", encoded, "e8=Q+") + } +} + +func TestMoveTextCodecDecodeRaw(t *testing.T) { + rawUCI, err := chess.UCI().DecodeRaw("e7e8q") + if err != nil { + t.Fatalf("UCI DecodeRaw() error = %v", err) + } + if rawUCI.From() != chess.E7 || rawUCI.To() != chess.E8 || rawUCI.Promotion() != chess.Queen { + t.Fatalf("UCI DecodeRaw() = %v-%v %v, want e7-e8 Queen", rawUCI.From(), rawUCI.To(), rawUCI.Promotion()) + } + + rawLong, err := chess.LongAlgebraic().DecodeRaw("Ne2xf4") + if err != nil { + t.Fatalf("LongAlgebraic DecodeRaw() error = %v", err) + } + if rawLong.From() != chess.E2 || rawLong.To() != chess.F4 || rawLong.Promotion() != chess.NoPieceType { + t.Fatalf("LongAlgebraic DecodeRaw() = %v-%v %v, want e2-f4 no promotion", rawLong.From(), rawLong.To(), rawLong.Promotion()) + } + + _, err = chess.SAN().DecodeRaw("Nf3") + if !errors.Is(err, chess.ErrMoveTextUnsupportedRawDecode) { + t.Fatalf("SAN DecodeRaw() error = %v, want ErrMoveTextUnsupportedRawDecode", err) + } +} diff --git a/movegen.go b/movegen.go new file mode 100644 index 00000000..f994294e --- /dev/null +++ b/movegen.go @@ -0,0 +1,305 @@ +package chess + +import ( + "math/bits" + "sync" +) + +type moveGenerationMode uint8 + +const ( + generateLegalAnnotated moveGenerationMode = iota + generateLegalOnly + generateUnsafeOnly +) + +// calcMoves returns all legal moves for the given position. If first is true, +// returns after finding the first legal move. This is useful for quick position +// validation. +// +// The moves are generated in the following order: +// 1. Standard piece moves and captures +// 2. Castling moves (if available) +// +// Each move is validated to ensure it doesn't leave the king in check. +func calcMoves(pos *Position, first bool) []Move { + if first { + if hasLegalMove(pos) { + return []Move{{}} + } + return nil + } + + // generate possible moves + moves := legalMovesForMode(pos, generateLegalAnnotated) + return moves +} + +func legalMovesForMode(pos *Position, mode moveGenerationMode) []Move { + moves := standardMoves(pos, false, mode) + // return moves including castles + var castles [2]Move + count := castleMovesInto(pos, &castles, mode) + moves = append(moves, castles[:count]...) + return moves +} + +// unsafeMoves returns all pseudo-legal moves that are illegal because they +// leave the moving side's king in check. +func unsafeMoves(pos *Position) []Move { + return standardMoves(pos, false, generateUnsafeOnly) +} + +// status returns the current position's Method (Checkmate, Stalemate, or +// NoMethod). +// +// The Method is determined by: +// - Whether the side to move is in check +// - Whether any legal moves exist +// +// If the position has cached valid moves in pos.validMoves, those will be +// used. Otherwise, moves will be calculated to determine the Method. +func status(pos *Position) Method { + var hasMove bool + if pos.validMoves != nil { + hasMove = len(pos.validMoves) > 0 + } else { + hasMove = hasLegalMove(pos) + } + if !pos.inCheck && !hasMove { + return Stalemate + } else if pos.inCheck && !hasMove { + return Checkmate + } + return NoMethod +} + +func hasLegalMove(pos *Position) bool { + if hasStandardMove(pos) { + return true + } + return hasCastleMove(pos) +} + +func hasStandardMove(pos *Position) bool { + return visitStandardMoves(pos, generateLegalOnly, func(Move) bool { return true }) +} + +func visitLegalMoves(pos *Position, mode moveGenerationMode, visit func(Move) bool) { + if visitStandardMoves(pos, mode, visit) { + return + } + if mode == generateUnsafeOnly { + return + } + var castles [2]Move + count := castleMovesInto(pos, &castles, mode) + for _, m := range castles[:count] { + if visit(m) { + return + } + } +} + +func visitStandardMoves(pos *Position, mode moveGenerationMode, visit func(Move) bool) bool { + var m Move + ctx := legalMoveContextFor(pos, mode) + + bbAllowed := ^pos.board.whiteSqs + if pos.Turn() == Black { + bbAllowed = ^pos.board.blackSqs + } + + for _, p := range allPieces { + if pos.Turn() != p.Color() { + continue + } + s1BB := pos.board.bbForPiece(p) + if s1BB == 0 { + continue + } + for s1Bits := s1BB; s1Bits != 0; s1Bits &= s1Bits - 1 { + s1 := squareFromBit(s1Bits & -s1Bits) + s2BB := bbForPossibleMoves(pos, p.Type(), s1) & bbAllowed + if ctx.enabled { + s2BB = ctx.filter(pos, p, s1, s2BB) + } + if s2BB == 0 { + continue + } + for s2Bits := s2BB; s2Bits != 0; s2Bits &= s2Bits - 1 { + s2 := squareFromBit(s2Bits & -s2Bits) + + m.s1 = s1 + m.s2 = s2 + kingSafe := mode == generateLegalOnly && ctx.provesOwnKingSafe(p, s2) + + if (p == WhitePawn && s2.Rank() == Rank8) || (p == BlackPawn && s2.Rank() == Rank1) { + for _, pt := range promoPieceTypes { + m.promo = pt + m.tags = moveTagsForPiece(m, pos, mode, p, kingSafe) + if moveMatchesMode(m, mode) && visit(m) { + return true + } + } + } else { + m.promo = 0 + m.tags = moveTagsForPiece(m, pos, mode, p, kingSafe) + if moveMatchesMode(m, mode) && visit(m) { + return true + } + } + } + } + } + + return false +} + +func moveMatchesMode(m Move, mode moveGenerationMode) bool { + if mode == generateUnsafeOnly { + return m.HasTag(inCheck) + } + return !m.HasTag(inCheck) +} + +type legalMoveContext struct { + enabled bool + enPassant Square + checkCount int + checkMask bitboard +} + +func legalMoveContextFor(pos *Position, mode moveGenerationMode) legalMoveContext { + if mode == generateUnsafeOnly { + return legalMoveContext{} + } + kingSq := pos.board.kingSquare(pos.turn) + if kingSq == NoSquare { + return legalMoveContext{} + } + queenBB, rookBB, bishopBB := sliderBitboards(&pos.board, pos.turn.Other()) + if !pos.inCheck && alignedMasks[kingSq]&(queenBB|rookBB|bishopBB) == 0 { + return legalMoveContext{} + } + ctx := legalMoveContext{ + enabled: true, + enPassant: pos.enPassantSquare, + checkMask: ^bitboard(0), + } + if pos.inCheck { + setChecks(&ctx, pos, kingSq) + } + return ctx +} + +func (ctx legalMoveContext) filter(pos *Position, p Piece, s1 Square, moves bitboard) bitboard { + if p.Type() == King { + return moves + } + if ctx.enPassant != NoSquare && p.Type() == Pawn { + return moves + } + if ctx.checkCount > 1 { + return 0 + } + if ctx.checkCount == 1 { + moves &= ctx.checkMask + } + if pinRay := pinnedRayForPiece(pos, s1); pinRay != 0 { + moves &= pinRay + } + return moves +} + +func (ctx legalMoveContext) provesOwnKingSafe(p Piece, _ Square) bool { + if p.Type() == King { + return false + } + if !ctx.enabled { + return false + } + if p.Type() == Pawn && ctx.enPassant != NoSquare { + return false + } + return true +} + +func setChecks(ctx *legalMoveContext, pos *Position, kingSq Square) { + board := pos.board + attacker := pos.turn.Other() + occ := ^board.emptySqs + queenBB, rookBB, bishopBB := sliderBitboards(&board, attacker) + + checkers := (hvAttack(occ, kingSq) & (queenBB | rookBB)) | + (diaAttack(occ, kingSq) & (queenBB | bishopBB)) | + (bbKnightMoves[kingSq] & board.bbForPiece(NewPiece(Knight, attacker))) | + (bbKingMoves[kingSq] & board.bbForPiece(NewPiece(King, attacker))) | + pawnCheckers(&board, kingSq, attacker) + + ctx.checkCount = bits.OnesCount64(uint64(checkers)) + if ctx.checkCount == 1 { + checkerSq := squareFromBit(checkers) + ctx.checkMask = bbForSquare(checkerSq) + if squaresAligned(kingSq, checkerSq) { + ctx.checkMask |= squaresBetween(kingSq, checkerSq) + } + } +} + +// promoPieceTypes is an immutable array of promotion piece types. +// Treat as read-only; do not modify elements. +// +//nolint:gochecknoglobals // Immutable lookup table. +var promoPieceTypes = [4]PieceType{Queen, Rook, Bishop, Knight} + +const maxPossibleMoves = 218 // Maximum possible moves in any chess position + +// movePool is a pool of Move arrays to reduce allocations +// in the standardMoves function. +// +//nolint:gochecknoglobals // this is a sync pool +var movePool = &sync.Pool{ + New: func() any { + return &[maxPossibleMoves]Move{} + }, +} + +// standardMoves generates all standard (non-castling) legal moves for the +// current position. If first is true, returns after finding the first +// legal move. +// +// 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, mode moveGenerationMode) []Move { + moves, ok := movePool.Get().(*[maxPossibleMoves]Move) + if !ok { + // Pool returned an unexpected type; allocate a fresh array rather + // than dereferencing a nil pointer below. + moves = &[maxPossibleMoves]Move{} + } + defer movePool.Put(moves) + count := 0 + + if first { + var result [1]Move + if visitStandardMoves(pos, mode, func(m Move) bool { + result[0] = m + return true + }) { + return result[:] + } + return nil + } + + visitStandardMoves(pos, mode, func(m Move) bool { + moves[count] = m + count++ + return false + }) + + // Need to copy since we're returning array to pool + result := make([]Move, count) + copy(result, moves[:count]) + return result +} diff --git a/movetags.go b/movetags.go new file mode 100644 index 00000000..6f4c323c --- /dev/null +++ b/movetags.go @@ -0,0 +1,113 @@ +package chess + +// moveTags computes all tags for a move from scratch based on the resulting position. +// Tags include: +// - Capture: The move captures an opponent's piece +// - EnPassant: The move is an en passant capture +// - Check: The move puts the opponent in check +// - inCheck: The move leaves the moving side's king in check (illegal) +// - KingSideCastle: The move is a king-side castle +// - QueenSideCastle: The move is a queen-side castle +func moveTags(m Move, pos *Position) MoveTag { + return moveTagsForMode(m, pos, generateLegalAnnotated) +} + +// moveTagsForMode computes the tags required by the selected generation mode. +// Full public move generation needs exported annotations such as Check. Fast +// existence checks only need enough information to reject moves that leave the +// moving side in check, so they skip the opponent-check test. +func moveTagsForMode(m Move, pos *Position, mode moveGenerationMode) MoveTag { + return moveTagsForPiece(m, pos, mode, pos.board.Piece(m.s1), false) +} + +func moveTagsForPiece(m Move, pos *Position, mode moveGenerationMode, p Piece, ownKingSafe bool) MoveTag { + var tags MoveTag + if pos.board.isOccupied(m.s2) { + tags |= Capture + } else if m.s2 == pos.enPassantSquare && p.Type() == Pawn { + tags |= EnPassant + } + // determine if move is castle + if (p == WhiteKing && m.s1 == E1) || (p == BlackKing && m.s1 == E8) { + switch m.s2 { + case C1, C8: + tags |= QueenSideCastle + case G1, G8: + tags |= KingSideCastle + } + } + if mode == generateLegalOnly || mode == generateUnsafeOnly { + if ownKingSafe { + return tags + } + if !pos.inCheck && p.Type() != King && tags&EnPassant == 0 { + if moveFromAlignedWithOwnKing(m, pos) { + if exposesOwnKingToSlider(m, pos) { + tags |= inCheck + } + return tags + } + return tags + } + if !requiresOwnKingCheckSimulation(m, pos, p, tags) { + return tags + } + } + // apply preliminary tags to a local copy so board.update reads them correctly + local := m + local.tags = tags + // determine if in check after move (makes move invalid) + // Simulate the move on a temporary board copy so we can test + // check status without mutating the actual position. + tempBoard := pos.board + tempBoard.update(local) + if !ownKingSafe && tempBoard.kingSquare(pos.turn) != NoSquare { + if isSquareAttackedBy(&tempBoard, tempBoard.kingSquare(pos.turn), pos.turn.Other()) { + tags |= inCheck + } + } + if mode == generateLegalOnly || mode == generateUnsafeOnly { + return tags + } + // determine if opponent in check after move + if tempBoard.kingSquare(pos.turn.Other()) != NoSquare { + if isSquareAttackedBy(&tempBoard, tempBoard.kingSquare(pos.turn.Other()), pos.turn) { + tags |= Check + } + } + return tags +} + +func requiresOwnKingCheckSimulation(m Move, pos *Position, p Piece, tags MoveTag) bool { + if pos.inCheck || p.Type() == King || tags&EnPassant != 0 { + return true + } + return moveFromAlignedWithOwnKing(m, pos) +} + +func moveFromAlignedWithOwnKing(m Move, pos *Position) bool { + kingSq := pos.board.kingSquare(pos.turn) + if kingSq == NoSquare { + return false + } + return alignedMasks[kingSq]&bbForSquare(m.s1) != 0 +} + +func exposesOwnKingToSlider(m Move, pos *Position) bool { + kingSq := pos.board.kingSquare(pos.turn) + if kingSq == NoSquare { + return false + } + occ := (^pos.board.emptySqs &^ bbForSquare(m.s1)) | bbForSquare(m.s2) + attacker := pos.turn.Other() + captured := bbForSquare(m.s2) + queenBB, rookBB, bishopBB := sliderBitboards(&pos.board, attacker) + queenBB &^= captured + orthogonal := kingSq.File() == m.s1.File() || kingSq.Rank() == m.s1.Rank() + if orthogonal { + rookBB &^= captured + return hvAttack(occ, kingSq)&(queenBB|rookBB) != 0 + } + bishopBB &^= captured + return diaAttack(occ, kingSq)&(queenBB|bishopBB) != 0 +} diff --git a/movetree.go b/movetree.go new file mode 100644 index 00000000..0b6ca70f --- /dev/null +++ b/movetree.go @@ -0,0 +1,311 @@ +package chess + +import ( + "cmp" + "errors" + "fmt" +) + +// MoveInsertOptions contains options for inserting a move into a MoveTree. +type MoveInsertOptions struct { + // PromoteToMainLine makes the inserted or selected continuation the main line. + PromoteToMainLine bool +} + +// MoveTree owns the move topology and active cursor for a game. +// +// The root is a synthetic position node, not a move occurrence. +type MoveTree struct { + root *MoveNode + current *MoveNode +} + +func newMoveTree(pos *Position) *MoveTree { + root := &MoveNode{position: pos} + return &MoveTree{root: root, current: root} +} + +// Root returns the root position node. +func (t *MoveTree) Root() *MoveNode { + if t == nil { + return nil + } + return t.root +} + +// Current returns the active cursor node. +func (t *MoveTree) Current() *MoveNode { + if t == nil { + return nil + } + return t.current +} + +// MainLine returns the main-line move nodes, excluding the root position node. +func (t *MoveTree) MainLine() []*MoveNode { + if t == nil || t.root == nil { + return nil + } + + nodes := make([]*MoveNode, 0) + for current := t.MainChild(t.root); current != nil; current = t.MainChild(current) { + nodes = append(nodes, current) + } + return nodes +} + +// MainChild returns the main-line continuation from parent. +func (t *MoveTree) MainChild(parent *MoveNode) *MoveNode { + if parent == nil || len(parent.children) == 0 { + return nil + } + return parent.children[0] +} + +// Continuations returns all continuations from parent. +func (t *MoveTree) Continuations(parent *MoveNode) []*MoveNode { + if parent == nil { + return nil + } + return append([]*MoveNode{}, parent.children...) +} + +// Variations returns all non-main-line continuations from parent. +func (t *MoveTree) Variations(parent *MoveNode) []*MoveNode { + if parent == nil || len(parent.children) <= 1 { + return nil + } + return append([]*MoveNode{}, parent.children[1:]...) +} + +func (t *MoveTree) addMove(move Move, options *MoveInsertOptions) (*MoveNode, error) { + if t == nil || t.current == nil { + return nil, errors.New("chess: move tree has no current position") + } + options = cmp.Or(options, &MoveInsertOptions{}) + + if existing := t.findExistingMove(move); existing != nil { + if options.PromoteToMainLine { + t.promoteToMainLine(existing) + } + t.current = existing + return existing, nil + } + + node := &MoveNode{move: move, parent: t.current} + if t.current.position != nil { + node.position = t.current.position.Update(move) + } + if options.PromoteToMainLine { + t.current.children = append(t.current.children, nil) + copy(t.current.children[1:], t.current.children[:len(t.current.children)-1]) + t.current.children[0] = node + } else { + t.current.children = append(t.current.children, node) + } + t.current = node + return node, nil +} + +// AddVariation validates and appends move as a variation from parent. +func (t *MoveTree) AddVariation(parent *MoveNode, move Move) (*MoveNode, error) { + if t == nil || t.root == nil { + return nil, errors.New("chess: move tree has no root position") + } + if parent == nil { + parent = t.root + } + if parent.position == nil { + return nil, errors.New("chess: variation parent has no position") + } + if err := validatePositionMove(parent.position, move); err != nil { + return nil, err + } + node := t.addVariationUnchecked(parent, move) + return node, nil +} + +func (t *MoveTree) addVariationUnchecked(parent *MoveNode, move Move) *MoveNode { + if parent == nil { + parent = t.root + } + node := &MoveNode{move: move, parent: parent} + if parent != nil && parent.position != nil { + node.position = parent.position.Update(move) + } + parent.children = append(parent.children, node) + return node +} + +// GoBack moves the active cursor to its parent. +func (t *MoveTree) GoBack() bool { + if t == nil || t.current == nil || t.current.parent == nil { + return false + } + t.current = t.current.parent + return true +} + +// GoForward moves the active cursor to the main continuation. +func (t *MoveTree) GoForward() bool { + if t == nil || t.current == nil || len(t.current.children) == 0 { + return false + } + t.current = t.current.children[0] + return true +} + +// NavigateToMainLine moves the active cursor to the first main-line move. +func (t *MoveTree) NavigateToMainLine() { + if t == nil || t.root == nil { + return + } + if len(t.root.children) == 0 { + t.current = t.root + return + } + t.current = t.root.children[0] +} + +// Lines returns every root-to-leaf line in the tree. +func (t *MoveTree) Lines() [][]*MoveNode { + if t == nil || t.root == nil { + return nil + } + var paths [][]*MoveNode + for _, child := range t.root.children { + paths = append(paths, collectPaths(child)...) + } + return paths +} + +// Clone returns a deep copy of the move tree with the cursor preserved. +func (t *MoveTree) Clone() *MoveTree { + if t == nil || t.root == nil { + return nil + } + ret := &MoveTree{root: t.root.clone()} + if t.current == nil { + ret.current = ret.root + } else { + ret.current = findClonedMove(t.root, ret.root, t.current) + if ret.current == nil { + ret.current = ret.root + } + } + return ret +} + +func (t *MoveTree) position() *Position { + if t == nil || t.current == nil { + return nil + } + return t.current.position +} + +func (t *MoveTree) setRootPosition(pos *Position) { + if t == nil || t.root == nil { + return + } + t.root.position = pos + if t.current == nil { + t.current = t.root + } + if t.current == t.root { + t.current.position = pos + } +} + +func (t *MoveTree) setCurrent(node *MoveNode) { + if t != nil { + t.current = node + } +} + +func (t *MoveTree) findExistingMove(move Move) *MoveNode { + if t == nil || t.current == nil { + return nil + } + for _, child := range t.current.children { + if sameMove(child.move, move) { + return child + } + } + return nil +} + +func (t *MoveTree) promoteToMainLine(move *MoveNode) { + if move == nil || move.parent == nil { + return + } + children := move.parent.children + for i, child := range children { + if child == move { + for ; i > 0; i-- { + children[i] = children[i-1] + } + children[0] = move + return + } + } +} + +func sameMove(a, b Move) bool { + return a.s1 == b.s1 && a.s2 == b.s2 && a.promo == b.promo +} + +func validatePositionMove(pos *Position, move Move) error { + if pos == nil { + return errors.New("no current position") + } + if move.HasTag(Null) { + return nil + } + for _, validMove := range pos.ValidMovesUnsafe() { + if sameMove(validMove, move) { + return nil + } + } + return fmt.Errorf("move %s is not valid for the current position", move.String()) +} + +// collectPaths returns all paths from the given move to each leaf node. +// Each path is represented as a slice of *MoveNode, starting with the given node +// and ending with a leaf (a move with no children). +func collectPaths(node *MoveNode) [][]*MoveNode { + if node == nil { + return nil + } + // If leaf, return a single path containing this node + if len(node.children) == 0 { + return [][]*MoveNode{{node}} + } + // Otherwise, collect paths from each child and prepend this node + var paths [][]*MoveNode + for _, c := range node.children { + childPaths := collectPaths(c) + for _, p := range childPaths { + path := append([]*MoveNode{node}, p...) + paths = append(paths, path) + } + } + return paths +} + +func findClonedMove(original, clone, target *MoveNode) *MoveNode { + if original == nil || clone == nil || target == nil { + return nil + } + if original == target { + return clone + } + for i, child := range original.children { + if i >= len(clone.children) { + return nil + } + if found := findClonedMove(child, clone.children[i], target); found != nil { + return found + } + } + return nil +} diff --git a/nag.go b/nag.go new file mode 100644 index 00000000..5a7fff8e --- /dev/null +++ b/nag.go @@ -0,0 +1,56 @@ +package chess + +import ( + "errors" + "fmt" +) + +// nagSymbolToNumeric maps the six move-quality symbolic spellings to their +// canonical numeric NAG codes. These are the only symbolic forms accepted on +// import; the canonical, stored, and written form is always the numeric "$N". +var nagSymbolToNumeric = map[string]string{ + "!": "$1", + "?": "$2", + "!!": "$3", + "??": "$4", + "!?": "$5", + "?!": "$6", +} + +// ParseNAG normalises a NAG spelling to its canonical numeric "$N" form. +// +// It accepts: +// - any numeric NAG written as "$" followed by one or more digits (the six +// standard codes as well as ChessBase-style extended codes such as "$1406"), +// - the six move-quality symbolic spellings (! ? !! ?? !? ?!), which are +// mapped to their canonical numeric codes. +// +// Leading zeros in a numeric NAG are stripped ("$01" becomes "$1"). +// +// It rejects malformed input such as "", "$", "$x", "$ 1", "!!!", or any other +// unrecognised spelling. +// +// The returned value is always the canonical numeric form and never a symbol. +func ParseNAG(s string) (string, error) { + if s == "" { + return "", errors.New("chess: empty NAG") + } + if v, ok := nagSymbolToNumeric[s]; ok { + return v, nil + } + if s[0] != '$' { + return "", fmt.Errorf("chess: invalid NAG %q", s) + } + digits := s[1:] + if digits == "" { + return "", fmt.Errorf("chess: invalid NAG %q: missing digits", s) + } + n := 0 + for _, r := range digits { + if r < '0' || r > '9' { + return "", fmt.Errorf("chess: invalid NAG %q", s) + } + n = n*10 + int(r-'0') + } + return fmt.Sprintf("$%d", n), nil +} diff --git a/nag_test.go b/nag_test.go new file mode 100644 index 00000000..e5a4e2e6 --- /dev/null +++ b/nag_test.go @@ -0,0 +1,49 @@ +package chess + +import "testing" + +func TestParseNAG(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + // Symbolic spellings → canonical numeric. + {"bang", "!", "$1", false}, + {"qmark", "?", "$2", false}, + {"double_bang", "!!", "$3", false}, + {"double_qmark", "??", "$4", false}, + {"bang_qmark", "!?", "$5", false}, + {"qmark_bang", "?!", "$6", false}, + + // Numeric forms, including extended ChessBase codes. + {"numeric_one", "$1", "$1", false}, + {"numeric_zero", "$0", "$0", false}, + {"leading_zeros", "$01", "$1", false}, + {"extended", "$1406", "$1406", false}, + {"double_digit", "$13", "$13", false}, + + // Malformed inputs are rejected. + {"empty", "", "", true}, + {"bare_dollar", "$", "", true}, + {"dollar_letter", "$x", "", true}, + {"dollar_space_digit", "$ 1", "", true}, + {"triple_bang", "!!!", "", true}, + {"unknown_symbol", "@", "", true}, + {"letter", "N", "", true}, + {"dollar_minus", "$-1", "", true}, + {"dollar_plus", "$+1", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseNAG(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseNAG(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseNAG(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/notation.go b/notation.go index c6ed5ba7..7e748277 100644 --- a/notation.go +++ b/notation.go @@ -29,26 +29,23 @@ var emptyComponents = moveComponents{} //nolint:gochecknoglobals // false positive. var pgnRegex = regexp.MustCompile(`^(?:([RNBQKP]?)([abcdefgh]?)(\d?)(x?)([abcdefgh])(\d)(=[QRBN])?|(O-O(?:-O)?))([+#!?]|e\.p\.)*$`) -const piecesPoolCapacity = 4 - // Use string pools for common strings to reduce allocations. var ( //nolint:gochecknoglobals // false positive stringPool = sync.Pool{ - New: func() interface{} { + New: func() any { return new(strings.Builder) }, } +) - // Pre-allocate slices for options to avoid allocations in hot path - //nolint:gochecknoglobals // false positive - pieceOptionsPool = sync.Pool{ - New: func() interface{} { - s := make([]string, 0, piecesPoolCapacity) - return &s // Return pointer to slice - }, +func getStringBuilder() *strings.Builder { + sb, ok := stringPool.Get().(*strings.Builder) + if !ok || sb == nil { + return new(strings.Builder) } -) + return sb +} // Constants for common strings to avoid allocations. const ( @@ -65,61 +62,65 @@ const ( captureStr = "x" ) -// Pre-allocate piece type maps for faster lookups. -var pieceTypeToChar = map[PieceType]string{ - King: kingStr, - Queen: queenStr, - Rook: rookStr, - Bishop: bishopStr, - Knight: knightStr, -} - -// Encoder is the interface implemented by objects that can -// encode a move into a string given the position. It is not -// the encoders responsibility to validate the move. -type Encoder interface { - Encode(pos *Position, m *Move) string -} +// Pre-allocate piece type lookup tables for faster hot-path encoding. +var ( + pieceTypeToChar = [7]string{ + King: kingStr, + Queen: queenStr, + Rook: rookStr, + Bishop: bishopStr, + Knight: knightStr, + Pawn: "", + NoPieceType: "", + } -// Decoder is the interface implemented by objects that can -// decode a string into a move given the position. It is not -// the decoders responsibility to validate the move. An error -// is returned if the string could not be decoded. -type Decoder interface { - Decode(pos *Position, s string) (*Move, error) -} + uciPromoToPieceType = [256]PieceType{ + 'n': Knight, + 'b': Bishop, + 'r': Rook, + 'q': Queen, + } +) -// Notation is the interface implemented by objects that can -// encode and decode moves. -type Notation interface { - Encoder - Decoder +func pieceTypeChar(p PieceType) string { + if p < NoPieceType || int(p) >= len(pieceTypeToChar) { + return "" + } + return pieceTypeToChar[p] } -// UCINotation is a more computer friendly alternative to algebraic +// uciNotation is a more computer friendly alternative to algebraic // notation. This notation uses the same format as the UCI (Universal Chess // Interface). Examples: e2e4, e7e5, e1g1 (white short castling), e7e8q (for promotion). -type UCINotation struct{} +type uciNotation struct{} // String implements the fmt.Stringer interface and returns // the notation's name. -func (UCINotation) String() string { - return "UCI Notation" +func (uciNotation) String() string { + return "UCI notation" } -// Encode implements the Encoder interface. -func (UCINotation) Encode(_ *Position, m *Move) string { +// Encode implements the encoder interface. +func (uciNotation) Encode(_ *Position, m Move) string { const maxLen = 5 + // Null move: encode as "0000" (UCI convention). + if m.HasTag(Null) { + return "0000" + } // Get a string builder from the pool - sb, _ := stringPool.Get().(*strings.Builder) + sb := getStringBuilder() sb.Reset() defer stringPool.Put(sb) // Exact size needed: 4 chars for squares + up to 1 for promotion sb.Grow(maxLen) - sb.Write(m.S1().Bytes()) - sb.Write(m.S2().Bytes()) + s1Bytes := m.S1().Bytes() + s2Bytes := m.S2().Bytes() + sb.WriteByte(s1Bytes[0]) + sb.WriteByte(s1Bytes[1]) + sb.WriteByte(s2Bytes[0]) + sb.WriteByte(s2Bytes[1]) if m.Promo() != NoPieceType { sb.Write(m.Promo().Bytes()) } @@ -127,23 +128,30 @@ func (UCINotation) Encode(_ *Position, m *Move) string { return sb.String() } -// Decode implements the Decoder interface. -func (UCINotation) Decode(pos *Position, s string) (*Move, error) { +// Decode implements the decoder interface. +func (uciNotation) Decode(pos *Position, s string) (Move, error) { + // Null move: "0000" is the UCI convention. + if s == "0000" { + return NewNullMove(), nil + } + const promoLen = 5 l := len(s) if l < 4 || l > 5 { - return nil, fmt.Errorf("chess: invalid UCI notation length %d in %q", l, s) + return Move{}, fmt.Errorf("chess: invalid UCI notation length %d in %q", l, s) } - for idx := 0; idx < 2; idx += 2 { - if s[idx+0] < 'a' || s[idx+0] > 'h' { - return nil, fmt.Errorf("chess: invalid UCI notation sq:%v file:%v", - idx/2, s[0]) - } - if s[idx+1] < '1' || s[idx+1] > '8' { - return nil, fmt.Errorf("chess: invalid UCI notation sq:%v rank:%v", - idx/2, s[0]) - } + if s[0] < 'a' || s[0] > 'h' { + return Move{}, fmt.Errorf("chess: invalid UCI notation sq:0 file:%c", s[0]) + } + if s[1] < '1' || s[1] > '8' { + return Move{}, fmt.Errorf("chess: invalid UCI notation sq:0 rank:%c", s[1]) + } + if s[2] < 'a' || s[2] > 'h' { + return Move{}, fmt.Errorf("chess: invalid UCI notation sq:1 file:%c", s[2]) + } + if s[3] < '1' || s[3] > '8' { + return Move{}, fmt.Errorf("chess: invalid UCI notation sq:1 rank:%c", s[3]) } // Convert directly instead of using map lookups @@ -151,47 +159,46 @@ func (UCINotation) Decode(pos *Position, s string) (*Move, error) { s2 := Square((s[2] - 'a') + (s[3]-'1')*8) if s1 < A1 || s1 > H8 || s2 < A1 || s2 > H8 { - return nil, fmt.Errorf("chess: invalid squares in UCI notation %q", s) + return Move{}, fmt.Errorf("chess: invalid squares in UCI notation %q", s) } m := Move{s1: s1, s2: s2} // Promotion (Use a precomputed lookup) if l == promoLen { - promoMap := [256]PieceType{ - 'n': Knight, 'b': Bishop, 'r': Rook, 'q': Queen, - } - promo := promoMap[s[4]] + promo := uciPromoToPieceType[s[4]] if promo == NoPieceType { - return nil, fmt.Errorf("chess: invalid promotion piece in UCI notation %q", s) + return Move{}, fmt.Errorf("chess: invalid promotion piece in UCI notation %q", s) } m.promo = promo } if pos == nil { - return &m, nil + return m, nil } m.tags = moveTags(m, pos) - m.position = pos.Update(&m) - - return &m, nil + return m, nil } -// AlgebraicNotation (or Standard Algebraic Notation) is the +// algebraicNotation (or Standard Algebraic notation) is the // official chess notation used by FIDE. Examples: e4, e5, // O-O (short castling), e8=Q (promotion). -type AlgebraicNotation struct{} +type algebraicNotation struct{} // String implements the fmt.Stringer interface and returns // the notation's name. -func (AlgebraicNotation) String() string { - return "Algebraic Notation" +func (algebraicNotation) String() string { + return "Algebraic notation" } -// Encode implements the Encoder interface. -func (AlgebraicNotation) Encode(pos *Position, m *Move) string { +// Encode implements the encoder interface. +func (algebraicNotation) Encode(pos *Position, m Move) string { + // Null move: emit "Z0" (ChessBase / Scid convention). + if m.HasTag(Null) { + return "Z0" + } // Handle castling without builder checkChar := getCheckChar(pos, m) if m.HasTag(KingSideCastle) { @@ -202,12 +209,12 @@ func (AlgebraicNotation) Encode(pos *Position, m *Move) string { } // Get a string builder from the pool - sb, _ := stringPool.Get().(*strings.Builder) + sb := getStringBuilder() sb.Reset() defer stringPool.Put(sb) p := pos.Board().Piece(m.S1()) - if pChar := pieceTypeToChar[p.Type()]; pChar != "" { + if pChar := pieceTypeChar(p.Type()); pChar != "" { sb.WriteString(pChar) } @@ -226,7 +233,7 @@ func (AlgebraicNotation) Encode(pos *Position, m *Move) string { if m.promo != NoPieceType { sb.WriteString(equalStr) - sb.WriteString(pieceTypeToChar[m.promo]) + sb.WriteString(pieceTypeChar(m.promo)) } sb.WriteString(getCheckChar(pos, m)) @@ -253,145 +260,81 @@ func algebraicNotationParts(s string) (moveComponents, error) { }, nil } -// cleanMove creates a standardized string from move components. -func (mc moveComponents) clean() string { - // Get a string builder from pool - sb, _ := stringPool.Get().(*strings.Builder) - sb.Reset() - defer stringPool.Put(sb) - - sb.WriteString(mc.piece) - sb.WriteString(mc.originFile) - sb.WriteString(mc.originRank) - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - sb.WriteString(mc.castles) - - return sb.String() -} - -// generateMoveOptions creates possible alternative notations for a move. -func (mc moveComponents) generateOptions() []string { - // Get pre-allocated slice from pool - options := pieceOptionsPool.Get().(*[]string) - *options = (*options)[:0] // Clear but keep capacity - defer pieceOptionsPool.Put(options) // Now passing pointer - - // Build move options using string builder for efficiency - sb, _ := stringPool.Get().(*strings.Builder) - defer stringPool.Put(sb) - - if mc.piece != "" { - // Option 1: no origin coordinates - sb.Reset() - sb.WriteString(mc.piece) - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - sb.WriteString(mc.castles) - *options = append(*options, sb.String()) - - // Option 2: with rank, no file - sb.Reset() - sb.WriteString(mc.piece) - sb.WriteString(mc.originRank) - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - sb.WriteString(mc.castles) - *options = append(*options, sb.String()) - - // Option 3: with file, no rank - sb.Reset() - sb.WriteString(mc.piece) - sb.WriteString(mc.originFile) - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - sb.WriteString(mc.castles) - *options = append(*options, sb.String()) - } else { - if mc.capture != "" { - // Pawn capture without rank - sb.Reset() - sb.WriteString(mc.originFile) - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - *options = append(*options, sb.String()) - } - if mc.originFile != "" && mc.originRank != "" { - // Full coordinates version - sb.Reset() - sb.WriteString(mc.capture) - sb.WriteString(mc.file) - sb.WriteString(mc.rank) - sb.WriteString(mc.promotes) - *options = append(*options, sb.String()) - } +// Decode implements the decoder interface. +func (algebraicNotation) Decode(pos *Position, s string) (Move, error) { + // Null move: accept several common spellings used across tools: + // "Z0", "Z1" - ChessBase / Scid convention + // "--" - pgn-extract, Scid and various editors + // "@@" - some exporters + switch s { + case "Z0", "Z1", "--", "@@": + return NewNullMove(), nil } - return *options -} - -// Decode implements the Decoder interface. -func (AlgebraicNotation) Decode(pos *Position, s string) (*Move, error) { - // Parse move components components, err := algebraicNotationParts(s) if err != nil { - return nil, err + return Move{}, err } - // Get cleaned input move - cleanedInput := components.clean() - - // Try matching against valid moves - for _, m := range pos.ValidMovesUnsafe() { - // Encode current move - moveStr := AlgebraicNotation{}.Encode(pos, &m) - - // Parse and clean encoded move - notationParts, algebraicNotationError := algebraicNotationParts(moveStr) - if algebraicNotationError != nil { - continue // Skip invalid moves - } + dest := NoSquare + if components.file != "" && components.rank != "" { + dest = squareFromFileRank(components.file[0], components.rank[0]) + } - // Compare cleaned versions - if cleanedInput == notationParts.clean() { - return &m, nil - } + return resolveSANMove(pos, sanMoveData{ + castle: components.castles, + piece: algebraicPieceType(components.piece), + originFile: components.originFile, + originRank: components.originRank, + dest: dest, + capture: components.capture != "", + promotion: algebraicPromotion(components.promotes), + canonical: true, + }) +} - // Try alternative notations - for _, opt := range components.generateOptions() { - if opt == notationParts.clean() { - return &m, nil - } - } +func algebraicPieceType(piece string) PieceType { + switch piece { + case kingStr: + return King + case queenStr: + return Queen + case rookStr: + return Rook + case bishopStr: + return Bishop + case knightStr: + return Knight + default: + return Pawn } +} - return nil, fmt.Errorf("chess: move %s is not valid", s) +func algebraicPromotion(promotes string) PieceType { + if len(promotes) != 2 || promotes[0] != '=' { + return NoPieceType + } + return algebraicPieceType(promotes[1:]) } -// LongAlgebraicNotation is a fully expanded version of +// longAlgebraicNotation is a fully expanded version of // algebraic notation in which the starting and ending // squares are specified. // Examples: e2e4, Rd3xd7, O-O (short castling), e7e8=Q (promotion). -type LongAlgebraicNotation struct{} +type longAlgebraicNotation struct{} // String implements the fmt.Stringer interface and returns // the notation's name. -func (LongAlgebraicNotation) String() string { - return "Long Algebraic Notation" +func (longAlgebraicNotation) String() string { + return "Long Algebraic notation" } -// Encode implements the Encoder interface. -func (LongAlgebraicNotation) Encode(pos *Position, m *Move) string { +// Encode implements the encoder interface. +func (longAlgebraicNotation) Encode(pos *Position, m Move) string { + // Null move: emit "0000" (UCI / long-algebraic convention). + if m.HasTag(Null) { + return "0000" + } checkChar := getCheckChar(pos, m) if m.HasTag(KingSideCastle) { return "O-O" + checkChar @@ -399,7 +342,7 @@ func (LongAlgebraicNotation) Encode(pos *Position, m *Move) string { return "O-O-O" + checkChar } p := pos.Board().Piece(m.S1()) - pChar := charFromPieceType(p.Type()) + pChar := pieceTypeChar(p.Type()) s1Str := m.s1.String() capChar := "" if m.HasTag(Capture) || m.HasTag(EnPassant) { @@ -412,80 +355,84 @@ func (LongAlgebraicNotation) Encode(pos *Position, m *Move) string { return pChar + s1Str + capChar + m.s2.String() + promoText + checkChar } -// Decode implements the Decoder interface. -func (LongAlgebraicNotation) Decode(pos *Position, s string) (*Move, error) { - return AlgebraicNotation{}.Decode(pos, s) +// Decode implements the decoder interface. +func (longAlgebraicNotation) Decode(pos *Position, s string) (Move, error) { + // "0000" is the UCI / long-algebraic spelling for a null move; the + // algebraic decoder doesn't recognise it. + if s == "0000" { + return NewNullMove(), nil + } + return algebraicNotation{}.Decode(pos, s) } -func getCheckChar(pos *Position, move *Move) string { +func getCheckChar(pos *Position, move Move) string { if !move.HasTag(Check) { return "" } nextPos := pos.Update(move) if nextPos.Status() == Checkmate { - return "#" + return mateStr } - return "+" + return checkStr } -func formS1(pos *Position, m *Move) string { +func formS1(pos *Position, m Move) string { p := pos.board.Piece(m.s1) if p.Type() == Pawn { return "" } - var req, fileReq, rankReq bool + var ( + disambiguationNeeded bool + otherOnSameFile bool + otherOnSameRank bool + ) // Use a string builder from the pool - sb, _ := stringPool.Get().(*strings.Builder) + sb := getStringBuilder() sb.Reset() defer stringPool.Put(sb) for _, mv := range pos.ValidMovesUnsafe() { - if mv.s1 != m.s1 && mv.s2 == m.s2 && p == pos.board.Piece(mv.s1) { - req = true - - if mv.s1.File() == m.s1.File() { - rankReq = true - } - - if mv.s1.Rank() == m.s1.Rank() { - fileReq = true - } + if mv.s1 == m.s1 || mv.s2 != m.s2 { + continue + } + if p != pos.board.Piece(mv.s1) { + continue + } + disambiguationNeeded = true + if mv.s1.File() == m.s1.File() { + otherOnSameFile = true + } + if mv.s1.Rank() == m.s1.Rank() { + otherOnSameRank = true } } - if fileReq || !rankReq && req { + // SAN disambiguation rules (FIDE): + // * If no other same-type piece can move to s2, no disambiguation needed. + // * If file alone disambiguates, emit the file. + // * Otherwise (file shared with another same-target piece), emit the rank. + // * If three or more pieces share the same file, emit both. + if !disambiguationNeeded { + return "" + } + if !otherOnSameFile { sb.WriteByte(m.s1.File().Byte()) + return sb.String() } - - if rankReq { - sb.WriteByte(m.s1.Rank().Byte()) + if otherOnSameRank { + // Three or more same-file pieces: emit both file and rank. + sb.WriteByte(m.s1.File().Byte()) } - + sb.WriteByte(m.s1.Rank().Byte()) return sb.String() } func charForPromo(p PieceType) string { - c := charFromPieceType(p) + c := pieceTypeChar(p) if c != "" { c = "=" + c } return c } - -func charFromPieceType(p PieceType) string { - switch p { - case King: - return "K" - case Queen: - return "Q" - case Rook: - return "R" - case Bishop: - return "B" - case Knight: - return "N" - } - return "" -} diff --git a/notation_test.go b/notation_test.go index bb0bb97a..29729848 100644 --- a/notation_test.go +++ b/notation_test.go @@ -1,108 +1,281 @@ package chess import ( - "fmt" "strings" "testing" ) -type notationDecodeTest struct { - N Notation - Pos *Position - Text string - PostPos *Position +type decodeTest struct { + Codec MoveTextCodec + Pos *Position + Text string } -var invalidDecodeTests = []notationDecodeTest{ +var invalidDecodeTests = []decodeTest{ { // opening for white - N: AlgebraicNotation{}, - Pos: unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), - Text: "e5", + Codec: SAN(), + Pos: unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), + Text: "e5", }, { // http://en.lichess.org/W91M4jms#14 - N: AlgebraicNotation{}, - Pos: unsafeFEN("rn1qkb1r/pp3ppp/2p1pn2/3p4/2PP4/2NQPN2/PP3PPP/R1B1K2R b KQkq - 0 7"), - Text: "Nd7", + Codec: SAN(), + Pos: unsafeFEN("rn1qkb1r/pp3ppp/2p1pn2/3p4/2PP4/2NQPN2/PP3PPP/R1B1K2R b KQkq - 0 7"), + Text: "Nd7", }, { // http://en.lichess.org/W91M4jms#17 - N: AlgebraicNotation{}, - Pos: unsafeFEN("r2qk2r/pp1n1ppp/2pbpn2/3p4/2PP4/1PNQPN2/P4PPP/R1B1K2R w KQkq - 1 9"), - Text: "O-O-O-O", - PostPos: unsafeFEN("r2qk2r/pp1n1ppp/2pbpn2/3p4/2PP4/1PNQPN2/P4PPP/R1B2RK1 b kq - 2 9"), + Codec: SAN(), + Pos: unsafeFEN("r2qk2r/pp1n1ppp/2pbpn2/3p4/2PP4/1PNQPN2/P4PPP/R1B1K2R w KQkq - 1 9"), + Text: "O-O-O-O", }, { // http://en.lichess.org/W91M4jms#23 - N: AlgebraicNotation{}, - Pos: unsafeFEN("3r1rk1/pp1nqppp/2pbpn2/3p4/2PP4/1PNQPN2/PB3PPP/3RR1K1 b - - 5 12"), - Text: "dx4", + Codec: SAN(), + Pos: unsafeFEN("3r1rk1/pp1nqppp/2pbpn2/3p4/2PP4/1PNQPN2/PB3PPP/3RR1K1 b - - 5 12"), + Text: "dx4", }, { // should not assume pawn for unknown piece type "n" - N: AlgebraicNotation{}, - Pos: unsafeFEN("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2"), - Text: "nf3", + Codec: SAN(), + Pos: unsafeFEN("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2"), + Text: "nf3", }, { // disambiguation should not allow for this since it is not a capture - N: AlgebraicNotation{}, - Pos: unsafeFEN("rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq - 0 2"), - Text: "bf4", + Codec: SAN(), + Pos: unsafeFEN("rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq - 0 2"), + Text: "bf4", }, } func TestInvalidDecoding(t *testing.T) { for _, test := range invalidDecodeTests { - if _, err := test.N.Decode(test.Pos, test.Text); err == nil { + if _, err := test.Codec.Decode(test.Pos, test.Text); err == nil { t.Fatalf("starting from board\n%s\n expected move notation %s to be invalid", test.Pos.board.Draw(), test.Text) } } } +func TestAlgebraicDisambiguation(t *testing.T) { + tests := []struct { + name string + fen string + move Move + want string + }{ + { + // Two white rooks, one on a2 and one on d5, both can move to a5 + // (same destination, different file and rank). Standard SAN + // requires only the file: Raa5 and Rda5. + name: "file disambiguation, no shared file or rank", + fen: "1k6/8/8/3R4/8/8/R7/K7 w - - 0 1", + move: Move{s1: A2, s2: A5}, + want: "Raa5", + }, + { + // Same position, the other rook: should encode Rda5. + name: "file disambiguation, no shared file or rank, other rook", + fen: "1k6/8/8/3R4/8/8/R7/K7 w - - 0 1", + move: Move{s1: D5, s2: A5}, + want: "Rda5", + }, + } + codec := SAN() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := unsafeFEN(tt.fen) + got, err := codec.Encode(pos, tt.move) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + if got != tt.want { + t.Fatalf("Encode = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAlgebraicNotationDecodeRoundTripsLegalMoves(t *testing.T) { + positions := []*Position{ + startPos, + midPos, + complexPos, + unsafeFEN("r3k2r/pppq1ppp/2npbn2/3Np3/2B1P3/2N2Q2/PPP2PPP/R3K2R w KQkq - 0 10"), + } + codec := SAN() + + for _, pos := range positions { + for _, move := range pos.ValidMovesUnsafe() { + encoded, err := codec.Encode(pos, move) + if err != nil { + t.Fatalf("Encode(%s) from %s: %v", move, pos, err) + } + decoded, err := codec.Decode(pos, encoded) + if err != nil { + t.Fatalf("Decode(%q) from %s: %v", encoded, pos, err) + } + if decoded.s1 != move.s1 || decoded.s2 != move.s2 || decoded.promo != move.promo { + t.Fatalf("Decode(%q) = %s, want %s", encoded, decoded, move) + } + } + } +} + func TestEncodeUCINotation(t *testing.T) { - notation := UCINotation{} + codec := UCI() pos := unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") - move := &Move{s1: E2, s2: E4} + move := Move{s1: E2, s2: E4} expected := "e2e4" - result := notation.Encode(pos, move) + result, err := codec.Encode(pos, move) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } if result != expected { t.Fatalf("expected %s, got %s", expected, result) } } func TestEncodeUCINotationWithPromotion(t *testing.T) { - notation := UCINotation{} + codec := UCI() pos := unsafeFEN("8/P7/8/8/8/8/8/8 w - - 0 1") - move := &Move{s1: A7, s2: A8, promo: Queen} + move := Move{s1: A7, s2: A8, promo: Queen} expected := "a7a8q" - result := notation.Encode(pos, move) + result, err := codec.Encode(pos, move) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } if result != expected { t.Fatalf("expected %s, got %s", expected, result) } } func TestEncodeUCINotationWithInvalidMove(t *testing.T) { - notation := UCINotation{} + codec := UCI() pos := unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") - move := &Move{s1: E2, s2: E5} + move := Move{s1: E2, s2: E5} expected := "e2e5" - result := notation.Encode(pos, move) + result, err := codec.Encode(pos, move) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } if result != expected { t.Fatalf("expected %s, got %s", expected, result) } } +func TestNotationHandlesInvalidPooledStringBuilder(t *testing.T) { + tests := []struct { + name string + run func() string + want string + }{ + { + name: "uci encode", + run: func() string { + s, err := UCI().Encode(nil, Move{s1: E2, s2: E4}) + if err != nil { + t.Fatalf("UCI().Encode() error = %v", err) + } + return s + }, + want: "e2e4", + }, + { + name: "algebraic encode", + run: func() string { + pos := unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + s, err := SAN().Encode(pos, Move{s1: E2, s2: E4}) + if err != nil { + t.Fatalf("SAN().Encode() error = %v", err) + } + return s + }, + want: "e4", + }, + { + name: "form source square", + run: func() string { + pos := unsafeFEN("1k6/8/8/3R4/8/8/R7/K7 w - - 0 1") + return formS1(pos, Move{s1: A2, s2: A5}) + }, + want: "a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stringPool.Put("not a string builder") + if got := tt.run(); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestUCINotationGrammar(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "invalid UCI notation length", + input: "e2e", + wantErr: true, + }, + { + name: "invalid squares in UCI notation", + input: "e9e4", + wantErr: true, + }, + { + name: "invalid promotion piece", + input: "a7a8x", + wantErr: true, + }, + { + name: "invalid source file character", + input: "i1e4", + wantErr: true, + }, + { + name: "invalid destination file character", + input: "e1i4", + wantErr: true, + }, + { + name: "invalid destination rank character", + input: "e1e0", + wantErr: true, + }, + { + name: "invalid source rank character", + input: "e0e4", + wantErr: true, + }, + } + + codec := UCI() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := codec.ValidateSyntax(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateSyntax() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestUCINotationDecode(t *testing.T) { - moveWithCheckCapture := &Move{s1: D1, s2: D8, tags: Check} - moveWithCheckCapture.AddTag(Capture) + moveWithCheckCapture := Move{s1: D1, s2: D8, tags: Check}.WithTag(Capture) tests := []struct { name string pos *Position input string - want *Move + want Move wantErr bool expectedPos *Position }{ @@ -110,7 +283,7 @@ func TestUCINotationDecode(t *testing.T) { name: "valid move without promotion", pos: unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), input: "e2e4", - want: &Move{s1: E2, s2: E4}, + want: Move{s1: E2, s2: E4}, expectedPos: unsafeFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"), wantErr: false, }, @@ -118,7 +291,7 @@ func TestUCINotationDecode(t *testing.T) { name: "valid move with promotion", pos: unsafeFEN("8/P7/8/8/8/8/8/8 w - - 0 1"), input: "a7a8q", - want: &Move{s1: A7, s2: A8, promo: Queen}, + want: Move{s1: A7, s2: A8, promo: Queen}, expectedPos: unsafeFEN("Q7/8/8/8/8/8/8/8 b - - 0 1"), wantErr: false, }, @@ -126,7 +299,7 @@ func TestUCINotationDecode(t *testing.T) { name: "valid move with capture", pos: unsafeFEN("rnbqkb1r/ppp2ppp/3p1n2/4P3/4P3/2N5/PPP2PPP/R1BQKBNR b KQkq - 0 4"), input: "d6e5", - want: &Move{s1: D6, s2: E5, tags: Capture}, + want: Move{s1: D6, s2: E5, tags: Capture}, wantErr: false, expectedPos: unsafeFEN("rnbqkb1r/ppp2ppp/5n2/4p3/4P3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 5"), }, @@ -134,7 +307,7 @@ func TestUCINotationDecode(t *testing.T) { name: "valid move with check only", pos: unsafeFEN("rnbqkb1r/ppp2ppp/5n2/4p3/4P3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 5"), input: "f1b5", - want: &Move{s1: F1, s2: B5, tags: Check}, + want: Move{s1: F1, s2: B5, tags: Check}, expectedPos: unsafeFEN("rnbqkb1r/ppp2ppp/5n2/1B2p3/4P3/2N5/PPP2PPP/R1BQK1NR b KQkq - 1 5"), wantErr: false, }, @@ -150,7 +323,7 @@ func TestUCINotationDecode(t *testing.T) { name: "valid move with castle with check", pos: unsafeFEN("r4b1r/ppp3pp/8/4p3/2Pq4/3P1Q2/PP3PPP/1k2K2R w K - 2 19"), input: "e1g1", - want: &Move{s1: E1, s2: G1, tags: Check | KingSideCastle}, + want: Move{s1: E1, s2: G1, tags: Check | KingSideCastle}, wantErr: false, expectedPos: unsafeFEN("r4b1r/ppp3pp/8/4p3/2Pq4/3P1Q2/PP3PPP/1k3RK1 b - - 3 19"), }, @@ -158,45 +331,24 @@ func TestUCINotationDecode(t *testing.T) { name: "valid en passant move with check", pos: unsafeFEN("r3k1r1/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRK1R1 b q f3 0 2"), input: "e4f3", - want: &Move{s1: E4, s2: F3, tags: Check | EnPassant}, + want: Move{s1: E4, s2: F3, tags: Check | EnPassant}, wantErr: false, expectedPos: unsafeFEN("r3k1r1/pbppqpb1/1pn3p1/7p/1N4n1/1PP2p1N/PB1P2PP/2QRK1R1 w q - 0 3"), }, - { - name: "invalid UCI notation length", - pos: nil, - input: "e2e", - want: nil, - wantErr: true, - }, - { - name: "invalid squares in UCI notation", - pos: nil, - input: "e9e4", - want: nil, - wantErr: true, - }, - { - name: "invalid promotion piece", - pos: nil, - input: "a7a8x", - want: nil, - wantErr: true, - }, { name: "valid en passant move", pos: unsafeFEN("rnbqkbnr/ppp2ppp/4p3/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3"), input: "e5d6", - want: &Move{s1: E5, s2: D6, tags: EnPassant}, + want: Move{s1: E5, s2: D6, tags: EnPassant}, wantErr: false, expectedPos: unsafeFEN("rnbqkbnr/ppp2ppp/3Pp3/8/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 3"), }, } + codec := UCI() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - notation := UCINotation{} - got, err := notation.Decode(tt.pos, tt.input) + got, err := codec.Decode(tt.pos, tt.input) if (err != nil) != tt.wantErr { t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) return @@ -204,12 +356,9 @@ func TestUCINotationDecode(t *testing.T) { if !tt.wantErr && (got.String() != tt.want.String() || got.promo != tt.want.promo || got.tags != tt.want.tags) { t.Errorf("Decode() = %v (%d), want %v (%d)", got, got.tags, tt.want, tt.want.tags) } - if !tt.wantErr && tt.want.position != nil && got.position != nil && tt.want.position.String() != got.position.String() { - t.Errorf("Decode() = %v, want %v", got.position, tt.want.position) - } - if !tt.wantErr && tt.expectedPos != nil && got.position.String() != tt.expectedPos.String() { - t.Errorf("Decode() = %v, want %v", got.position.String(), tt.expectedPos) + if !tt.wantErr && tt.expectedPos != nil && tt.pos.Update(got).String() != tt.expectedPos.String() { + t.Errorf("Decode() position = %v, want %v", tt.pos.Update(got).String(), tt.expectedPos) } }) } @@ -227,17 +376,17 @@ var ( // Test moves for each position var ( - startMoves = []*Move{ + startMoves = []Move{ {s1: E2, s2: E4}, // e4 {s1: G1, s2: F3}, // Nf3 {s1: B1, s2: C3}, // Nc3 } - midMoves = []*Move{ + midMoves = []Move{ {s1: E1, s2: G1, tags: KingSideCastle}, // O-O {s1: F3, s2: E5, tags: Capture}, // Nxe5 {s1: C4, s2: F7, tags: Check | Capture}, // d4+ } - complexMoves = []*Move{ + complexMoves = []Move{ {s1: B7, s2: B8, promo: Knight}, // b8=N {s1: B7, s2: A8, promo: Bishop, tags: Capture}, // bxa8=B {s1: B7, s2: C8, promo: Rook, tags: Check}, // bxc8=R+ @@ -246,24 +395,35 @@ var ( } ) -// Benchmarks for UCI Notation +func mustEncode(t testing.TB, codec MoveTextCodec, pos *Position, m Move) string { + t.Helper() + s, err := codec.Encode(pos, m) + if err != nil { + t.Fatalf("Encode(%s) from %s: %v", m, pos, err) + } + return s +} + +// Benchmarks for UCI notation func BenchmarkUCIEncode(b *testing.B) { - notation := UCINotation{} + codec := UCI() positions := []*Position{startPos, midPos, complexPos} - moves := [][]*Move{startMoves, midMoves, complexMoves} + moves := [][]Move{startMoves, midMoves, complexMoves} b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { pos := positions[i%len(positions)] move := moves[i%len(moves)][i%len(moves[i%len(moves)])] - notation.Encode(pos, move) + if _, err := codec.Encode(pos, move); err != nil { + b.Fatalf("Encode error: %v", err) + } } } func BenchmarkUCIDecode(b *testing.B) { - notation := UCINotation{} + codec := UCI() samples := []struct { pos *Position text string @@ -276,33 +436,35 @@ func BenchmarkUCIDecode(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { sample := samples[i%len(samples)] - _, err := notation.Decode(sample.pos, sample.text) + _, err := codec.Decode(sample.pos, sample.text) if err != nil { b.Fatalf("error decoding %s: %s", sample.text, err) } } } -// Benchmarks for Algebraic Notation +// Benchmarks for Algebraic notation func BenchmarkAlgebraicEncode(b *testing.B) { - notation := AlgebraicNotation{} + codec := SAN() positions := []*Position{startPos, midPos, complexPos} - moves := [][]*Move{startMoves, midMoves, complexMoves} + moves := [][]Move{startMoves, midMoves, complexMoves} b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { pos := positions[i%len(positions)] move := moves[i%len(moves)][i%len(moves[i%len(moves)])] - notation.Encode(pos, move) + if _, err := codec.Encode(pos, move); err != nil { + b.Fatalf("Encode error: %v", err) + } } } func BenchmarkAlgebraicDecode(b *testing.B) { - notation := AlgebraicNotation{} + codec := SAN() samples := []struct { pos *Position text string @@ -315,33 +477,35 @@ func BenchmarkAlgebraicDecode(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { sample := samples[i%len(samples)] - _, err := notation.Decode(sample.pos, sample.text) + _, err := codec.Decode(sample.pos, sample.text) if err != nil { b.Fatalf("error decoding %s: %s", sample.text, err) } } } -// Benchmarks for Long Algebraic Notation +// Benchmarks for Long Algebraic notation func BenchmarkLongAlgebraicEncode(b *testing.B) { - notation := LongAlgebraicNotation{} + codec := LongAlgebraic() positions := []*Position{startPos, midPos, complexPos} - moves := [][]*Move{startMoves, midMoves, complexMoves} + moves := [][]Move{startMoves, midMoves, complexMoves} b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { pos := positions[i%len(positions)] move := moves[i%len(moves)][i%len(moves[i%len(moves)])] - notation.Encode(pos, move) + if _, err := codec.Encode(pos, move); err != nil { + b.Fatalf("Encode error: %v", err) + } } } func BenchmarkLongAlgebraicDecode(b *testing.B) { - notation := LongAlgebraicNotation{} + codec := LongAlgebraic() samples := []struct { pos *Position text string @@ -354,9 +518,9 @@ func BenchmarkLongAlgebraicDecode(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { sample := samples[i%len(samples)] - _, err := notation.Decode(sample.pos, sample.text) + _, err := codec.Decode(sample.pos, sample.text) if err != nil { b.Fatalf("error decoding %s: %s", sample.text, err) } @@ -365,7 +529,7 @@ func BenchmarkLongAlgebraicDecode(b *testing.B) { // Benchmark specific scenarios func BenchmarkAlgebraicDecodeComplex(b *testing.B) { - notation := AlgebraicNotation{} + codec := SAN() pos := complexPos moves := []string{ "Nxf7", // Capture with check @@ -376,8 +540,8 @@ func BenchmarkAlgebraicDecodeComplex(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := notation.Decode(pos, moves[i%len(moves)]) + for i := range b.N { + _, err := codec.Decode(pos, moves[i%len(moves)]) if err != nil { b.Fatalf("error decoding %s: %s", moves[i%len(moves)], err) } @@ -386,36 +550,77 @@ func BenchmarkAlgebraicDecodeComplex(b *testing.B) { func TestPromotionWithCheck(t *testing.T) { promoPos := unsafeFEN("8/1P2k3/8/8/8/8/8/8 w - - 0 1") - promoMove := &Move{s1: B7, s2: B8, promo: Queen, tags: Check} + promoMove := Move{s1: B7, s2: B8, promo: Queen, tags: Check} - algebraicNotation := AlgebraicNotation{} - result := algebraicNotation.Encode(promoPos, promoMove) - if result != "b8=Q+" { - t.Fatalf("Expected 'b8=Q+', got '%s'", result) + got := mustEncode(t, SAN(), promoPos, promoMove) + if got != "b8=Q" { + t.Fatalf("SAN: Expected 'b8=Q', got '%s'", got) } - longAlgebraicNotation := LongAlgebraicNotation{} - result = longAlgebraicNotation.Encode(promoPos, promoMove) - if result != "b7b8=Q+" { - t.Fatalf("Expected 'b7b8=Q+', got '%s'", result) + got = mustEncode(t, LongAlgebraic(), promoPos, promoMove) + if got != "b7b8=Q" { + t.Fatalf("LongAlgebraic: Expected 'b7b8=Q', got '%s'", got) } } func TestPromotionWithCheckFromIssue84(t *testing.T) { promoPos := unsafeFEN("8/1P2k3/8/8/8/8/8/8 w - - 0 1") - promoMove := &Move{s1: B7, s2: B8, promo: Queen} - promoMove.AddTag(Check) + promoMove := Move{s1: B7, s2: B8, promo: Queen}.WithTag(Check) - algebraicNotation := AlgebraicNotation{} - result := algebraicNotation.Encode(promoPos, promoMove) - if result != "b8=Q+" { - t.Fatalf("Algebraic Notation: Expected 'b8=Q+', got '%s'", result) + got := mustEncode(t, SAN(), promoPos, promoMove) + if got != "b8=Q" { + t.Fatalf("SAN: Expected 'b8=Q', got '%s'", got) } - longAlgebraicNotation := LongAlgebraicNotation{} - result = longAlgebraicNotation.Encode(promoPos, promoMove) - if result != "b7b8=Q+" { - t.Fatalf("Long Algebraic Notation: Expected 'b7b8=Q+', got '%s'", result) + got = mustEncode(t, LongAlgebraic(), promoPos, promoMove) + if got != "b7b8=Q" { + t.Fatalf("LongAlgebraic: Expected 'b7b8=Q', got '%s'", got) + } +} + +// Issue 84's original failure was an encoded promotion missing its check +// suffix. The codec now recomputes the Check tag from the resulting position +// (not the input), so this test uses a position where the promotion genuinely +// delivers check: a pawn on b7 promoting on b8 attacks the black king on d8 +// along the 8th rank. +func TestPromotionDeliversGenuineCheck(t *testing.T) { + promoPos := unsafeFEN("3k4/1P6/8/8/8/8/8/4K3 w - - 0 1") + promoMove := Move{s1: B7, s2: B8, promo: Queen} + + got := mustEncode(t, SAN(), promoPos, promoMove) + if got != "b8=Q+" { + t.Fatalf("SAN: Expected 'b8=Q+', got '%s'", got) + } + + got = mustEncode(t, LongAlgebraic(), promoPos, promoMove) + if got != "b7b8=Q+" { + t.Fatalf("LongAlgebraic: Expected 'b7b8=Q+', got '%s'", got) + } +} + +func TestNullMoveEncodeDoesNotRequirePosition(t *testing.T) { + got, err := SAN().Encode(nil, NewNullMove()) + if err != nil { + t.Fatalf("SAN().Encode(nil, null) error = %v", err) + } + if got != "Z0" { + t.Fatalf("SAN().Encode(nil, null) = %q, want %q", got, "Z0") + } + + got, err = LongAlgebraic().Encode(nil, NewNullMove()) + if err != nil { + t.Fatalf("LongAlgebraic().Encode(nil, null) error = %v", err) + } + if got != "0000" { + t.Fatalf("LongAlgebraic().Encode(nil, null) = %q, want %q", got, "0000") + } + + got, err = UCI().Encode(nil, NewNullMove()) + if err != nil { + t.Fatalf("UCI().Encode(nil, null) error = %v", err) + } + if got != "0000" { + t.Fatalf("UCI().Encode(nil, null) = %q, want %q", got, "0000") } } @@ -444,7 +649,7 @@ func TestIssue84FullGame(t *testing.T) { t.Fatalf("Failed to parse PGN: %v", err) } game := NewGame(pgnObj) - moves := game.Moves() + moves := game.MoveTree().MainLine() for i, mv := range moves { moveNum := (i / 2) + 1 @@ -453,44 +658,35 @@ func TestIssue84FullGame(t *testing.T) { color = "B" } - _, err := safeEncode(LongAlgebraicNotation{}, mv.Position(), mv) - if err != nil { - t.Fatalf("Error: LongAlgebraicNotation.Encode panic at half-move %d (%d. %s): %v", + if _, err := LongAlgebraic().Encode(mv.Position(), mv.Move()); err != nil { + t.Fatalf("Error: LongAlgebraic().Encode panic at half-move %d (%d. %s): %v", i, moveNum, color, err) } - _, err = safeEncode(AlgebraicNotation{}, mv.Position(), mv) - if err != nil { - t.Fatalf("Error: AlgebraicNotation.Encode panic at half-move %d (%d. %s): %v", + if _, err := SAN().Encode(mv.Position(), mv.Move()); err != nil { + t.Fatalf("Error: SAN().Encode panic at half-move %d (%d. %s): %v", i, moveNum, color, err) } } } -func safeEncode(notation Encoder, pos *Position, mv *Move) (s string, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("%v", r) - } - }() - return notation.Encode(pos, mv), nil -} - // Benchmark promotion scenarios func BenchmarkPromotionEncoding(b *testing.B) { promoPos := unsafeFEN("rnbqkbnr/pPpppppp/8/8/8/8/P1PPPPPP/RNBQKBNR w KQkq - 0 1") - promoMove := &Move{s1: B7, s2: B8, promo: Queen, tags: Check} - notations := []Notation{ - UCINotation{}, - AlgebraicNotation{}, - LongAlgebraicNotation{}, + promoMove := Move{s1: B7, s2: B8, promo: Queen, tags: Check} + codecs := []MoveTextCodec{ + UCI(), + SAN(), + LongAlgebraic(), } b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { - notation := notations[i%len(notations)] - notation.Encode(promoPos, promoMove) + for i := range b.N { + codec := codecs[i%len(codecs)] + if _, err := codec.Encode(promoPos, promoMove); err != nil { + b.Fatalf("Encode error: %v", err) + } } } diff --git a/null_move_test.go b/null_move_test.go new file mode 100644 index 00000000..4f620259 --- /dev/null +++ b/null_move_test.go @@ -0,0 +1,491 @@ +package chess_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +// Null move specification +// +// A null move flips the side to move without moving any piece. It is used in +// engines for null-move pruning and in PGN annotations to indicate a side +// passed. The standard PGN format does not define null moves, so this package +// emits "Z0" (the convention used by ChessBase and Scid) and accepts several +// common spellings when reading. +// +// notation contract: +// - SAN encode : "Z0" +// - Long/UCI encode : "0000" +// - SAN decode : "Z0", "Z1", "--", "@@" +// - UCI decode : "0000" +// +// Validation contract: +// - Null moves are never part of a position's legal moves (ValidMoves). +// - Game.Move() rejects a null move. +// - Game.UnsafeMove(NullMove()) and Game.NullMove() accept it. +// +// State changes after a null move: +// - Turn flips. +// - halfMoveClock increments by 1 (treated like a quiet move). +// - moveCount increments by 1 when Black was the side to move. +// - en-passant square is cleared (the capture right expires). +// - castling rights are unchanged. +// - pieces are unchanged. +// - inCheck is recomputed for the new side to move. +// - Zobrist hash reflects the new turn and cleared en-passant. + +func ExampleGame_NullMove() { + g := chess.NewGame() + if _, err := g.NullMove(); err != nil { + fmt.Println("Error:", err) + return + } + // A null move flips the side to move without moving any piece. + fmt.Println(g.Position().Turn()) + // Output: b +} + +// ------------------------------------------------------------------ +// Constructor +// ------------------------------------------------------------------ + +func TestNullMove_ConstructorHasNullTag(t *testing.T) { + m := chess.NewNullMove() + if !m.HasTag(chess.Null) { + t.Fatalf("NullMove() must carry the Null MoveTag") + } +} + +// ------------------------------------------------------------------ +// notation encoding +// ------------------------------------------------------------------ + +func TestNullMove_SANEncode(t *testing.T) { + pos := chess.StartingPosition() + got, err := chess.SAN().Encode(pos, chess.NewNullMove()) + if err != nil { + t.Fatalf("SAN Encode error: %v", err) + } + if got != "Z0" { + t.Fatalf("SAN Encode = %q, want %q", got, "Z0") + } +} + +func TestNullMove_LongAlgebraicEncode(t *testing.T) { + pos := chess.StartingPosition() + got, err := chess.LongAlgebraic().Encode(pos, chess.NewNullMove()) + if err != nil { + t.Fatalf("LongAlgebraic Encode error: %v", err) + } + if got != "0000" { + t.Fatalf("LongAlgebraic Encode = %q, want %q", got, "0000") + } +} + +func TestNullMove_UCIEncode(t *testing.T) { + pos := chess.StartingPosition() + got, err := chess.UCI().Encode(pos, chess.NewNullMove()) + if err != nil { + t.Fatalf("UCI Encode error: %v", err) + } + if got != "0000" { + t.Fatalf("UCI Encode = %q, want %q", got, "0000") + } +} + +// ------------------------------------------------------------------ +// notation decoding +// ------------------------------------------------------------------ + +func TestNullMove_SANDecode(t *testing.T) { + pos := chess.StartingPosition() + cases := []string{"Z0", "Z1", "--", "@@"} + for _, s := range cases { + t.Run(s, func(t *testing.T) { + m, err := chess.SAN().Decode(pos, s) + if err != nil { + t.Fatalf("SAN Decode(%q) error: %v", s, err) + } + if !m.HasTag(chess.Null) { + t.Fatalf("SAN Decode(%q) returned move without Null tag", s) + } + }) + } +} + +func TestNullMove_UCIDecode(t *testing.T) { + pos := chess.StartingPosition() + m, err := chess.UCI().Decode(pos, "0000") + if err != nil { + t.Fatalf("UCI Decode error: %v", err) + } + if !m.HasTag(chess.Null) { + t.Fatalf("UCI Decode of '0000' must return null move") + } +} + +func TestNullMove_LongAlgebraicDecode(t *testing.T) { + pos := chess.StartingPosition() + m, err := chess.LongAlgebraic().Decode(pos, "0000") + if err != nil { + t.Fatalf("LongAlgebraic Decode error: %v", err) + } + if !m.HasTag(chess.Null) { + t.Fatalf("LongAlgebraic Decode of '0000' must return null move") + } +} + +// ------------------------------------------------------------------ +// Position.Update behaviour +// ------------------------------------------------------------------ + +func TestNullMove_UpdateFlipsTurn(t *testing.T) { + pos := chess.StartingPosition() + if pos.Turn() != chess.White { + t.Fatalf("starting position Turn=%v, want White", pos.Turn()) + } + next := pos.Update(chess.NewNullMove()) + if next.Turn() != chess.Black { + t.Fatalf("after null move, Turn=%v, want Black", next.Turn()) + } + // Original is immutable. + if pos.Turn() != chess.White { + t.Fatalf("Update must not mutate the receiver; Turn=%v", pos.Turn()) + } +} + +func TestNullMove_UpdateFlipsTurnTwice(t *testing.T) { + pos := chess.StartingPosition() + pos = pos.Update(chess.NewNullMove()) + pos = pos.Update(chess.NewNullMove()) + if pos.Turn() != chess.White { + t.Fatalf("after two null moves, Turn=%v, want White", pos.Turn()) + } +} + +func TestNullMove_UpdateIncrementsHalfMoveClock(t *testing.T) { + pos := chess.StartingPosition() + if pos.HalfMoveClock() != 0 { + t.Fatalf("starting halfMoveClock=%d, want 0", pos.HalfMoveClock()) + } + next := pos.Update(chess.NewNullMove()) + if next.HalfMoveClock() != 1 { + t.Fatalf("after null move halfMoveClock=%d, want 1", next.HalfMoveClock()) + } +} + +func TestNullMove_UpdateDoesNotChangeBoard(t *testing.T) { + pos := chess.StartingPosition() + next := pos.Update(chess.NewNullMove()) + if next.Board().Draw() != pos.Board().Draw() { + t.Fatalf("board changed after null move:\nbefore=%s\nafter=%s", + pos.Board().Draw(), next.Board().Draw()) + } +} + +func TestNullMove_UpdatePreservesCastleRights(t *testing.T) { + pos := chess.StartingPosition() + next := pos.Update(chess.NewNullMove()) + if next.CastleRights() != pos.CastleRights() { + t.Fatalf("castle rights changed: before=%q after=%q", + pos.CastleRights(), next.CastleRights()) + } +} + +func TestNullMove_UpdateClearsEnPassant(t *testing.T) { + fen := "rnbqkbnr/ppp2ppp/4p3/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3" + fenOpt, err := chess.FEN(fen) + if err != nil { + t.Fatalf("FEN parse: %v", err) + } + g := chess.NewGame(fenOpt) + pos := g.Position() + if pos.EnPassantSquare() == chess.NoSquare { + t.Fatalf("setup: en passant square should be set, got %v", pos.EnPassantSquare()) + } + next := pos.Update(chess.NewNullMove()) + if next.EnPassantSquare() != chess.NoSquare { + t.Fatalf("after null move, en passant square = %v, want NoSquare", + next.EnPassantSquare()) + } +} + +func TestNullMove_UpdateKeepsEnPassantCleared(t *testing.T) { + pos := chess.StartingPosition() + if pos.EnPassantSquare() != chess.NoSquare { + t.Fatalf("starting position should have no en passant square") + } + next := pos.Update(chess.NewNullMove()) + if next.EnPassantSquare() != chess.NoSquare { + t.Fatalf("after null move, en passant square should remain NoSquare") + } +} + +func TestNullMove_UpdateChangesHash(t *testing.T) { + pos := chess.StartingPosition() + next := pos.Update(chess.NewNullMove()) + if pos.ZobristHash() == next.ZobristHash() { + t.Fatal("Zobrist hash must change when side to move flips") + } + // Hash must be deterministic: another null move from the new position + // produces the same hash. + next2 := next.Update(chess.NewNullMove()) + if next2.ZobristHash() != pos.ZobristHash() { + t.Fatal("two successive null moves must return to the original hash") + } +} + +func TestNullMove_UpdateIncrementsMoveCountAfterBlack(t *testing.T) { + // Starting with FEN where it's Black to move at move 5. + fen := "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" + fenOpt, err := chess.FEN(fen) + if err != nil { + t.Fatalf("FEN parse: %v", err) + } + g := chess.NewGame(fenOpt) + pos := g.Position() + if pos.Turn() != chess.Black { + t.Fatalf("setup: Turn=%v, want Black", pos.Turn()) + } + // moveCount is not exported; verify via FEN round trip. + beforeFen := pos.String() + next := pos.Update(chess.NewNullMove()) + afterFen := next.String() + if beforeFen == afterFen { + t.Fatalf("FEN must change after null move\nbefore=%s\nafter=%s", + beforeFen, afterFen) + } + // The "fullmove" component (last token in FEN) must increment by 1 + // because Black's null move ends a full move. + beforeFull := fenField(beforeFen, 5) + afterFull := fenField(afterFen, 5) + if afterFull != beforeFull+1 { + t.Fatalf("fullmove before=%d after=%d, want +1", beforeFull, afterFull) + } +} + +// ------------------------------------------------------------------ +// Game insertion API +// ------------------------------------------------------------------ + +func TestNullMove_GameMoveRejectsNull(t *testing.T) { + g := chess.NewGame() + if _, err := g.Move(chess.NewNullMove(), nil); err == nil { + t.Fatal("Game.Move must reject a null move") + } +} + +func TestNullMove_UnsafeMoveAcceptsNull(t *testing.T) { + g := chess.NewGame() + if _, err := g.UnsafeMove(chess.NewNullMove(), nil); err != nil { + t.Fatalf("Game.UnsafeMove(NullMove()) error: %v", err) + } + if g.Position().Turn() != chess.Black { + t.Fatalf("after null move Turn=%v, want Black", g.Position().Turn()) + } +} + +func TestNullMove_GameNullMoveMethod(t *testing.T) { + g := chess.NewGame() + node, err := g.NullMove() + if err != nil { + t.Fatalf("Game.NullMove error: %v", err) + } + if node == nil { + t.Fatal("Game.NullMove returned nil node") + } + if !node.Move().HasTag(chess.Null) { + t.Fatal("node move must carry the Null tag") + } + if g.Position().Turn() != chess.Black { + t.Fatalf("after Game.NullMove Turn=%v, want Black", g.Position().Turn()) + } +} + +func TestNullMove_AppearsInMoveHistory(t *testing.T) { + g := chess.NewGame() + if _, err := g.NullMove(); err != nil { + t.Fatalf("Game.NullMove: %v", err) + } + history := g.MoveHistory() + if len(history) != 1 { + t.Fatalf("len(MoveHistory)=%d, want 1", len(history)) + } + if !history[0].Move.HasTag(chess.Null) { + t.Fatal("MoveHistory entry must carry Null tag") + } +} + +// ------------------------------------------------------------------ +// ValidMoves contract: a null move is not legal. +// ------------------------------------------------------------------ + +func TestNullMove_NeverInValidMoves(t *testing.T) { + g := chess.NewGame() + for _, m := range g.ValidMoves() { + if m.HasTag(chess.Null) { + t.Fatal("ValidMoves must never return a null move") + } + } +} + +// ------------------------------------------------------------------ +// PGN read: token recognition +// ------------------------------------------------------------------ + +func TestNullMove_PGNRead_Z0(t *testing.T) { + // After "1. e4" it's Black's turn; Black plays Z0 (pass), then it's + // White's turn at move 2; White plays Nf3. + pgn := withMinimalTags("1. e4 Z0 2. Nf3 *") + g := mustParseSingleGame(t, pgn) + moves := g.Moves() + if len(moves) != 3 { + t.Fatalf("len(Moves)=%d, want 3 (e4, Z0, Nf3)", len(moves)) + } + if !moves[1].HasTag(chess.Null) { + t.Fatal("second move must be a null move") + } + if g.Position().Turn() != chess.Black { + t.Fatalf("after 1.e4 Z0 2.Nf3, Turn=%v, want Black", g.Position().Turn()) + } +} + +func TestNullMove_PGNRead_DoubleDash(t *testing.T) { + // '--' must be parsed as a null move. + pgn := withMinimalTags("1. e4 -- 2. Nf3 *") + g := mustParseSingleGame(t, pgn) + moves := g.Moves() + if len(moves) != 3 { + t.Fatalf("len(Moves)=%d, want 3", len(moves)) + } + if !moves[1].HasTag(chess.Null) { + t.Fatal("'--' must be parsed as a null move") + } +} + +func TestNullMove_PGNRead_AtStart(t *testing.T) { + pgn := withMinimalTags("1. Z0 e5 *") + g := mustParseSingleGame(t, pgn) + moves := g.Moves() + if len(moves) != 2 { + t.Fatalf("len(Moves)=%d, want 2", len(moves)) + } + if !moves[0].HasTag(chess.Null) { + t.Fatal("first move must be a null move") + } +} + +// TestNullMove_PGNRead_AliasedSpellings covers the PGN-layer spellings not +// covered by TestNullMove_PGNRead_Z0 / DoubleDash: "Z1", "@@", "0000". The +// lexer's readNullMove accepts all five spellings; Z0 and "--" are tested +// individually above, this pins the remaining three at the PGN layer. +func TestNullMove_PGNRead_AliasedSpellings(t *testing.T) { + for _, spelling := range []string{"Z1", "@@", "0000"} { + t.Run(spelling, func(t *testing.T) { + pgn := withMinimalTags("1. e4 " + spelling + " 2. Nf3 *") + g := mustParseSingleGame(t, pgn) + moves := g.Moves() + if len(moves) != 3 { + t.Fatalf("len(Moves)=%d, want 3 (e4, %s, Nf3)", len(moves), spelling) + } + if !moves[1].HasTag(chess.Null) { + t.Fatalf("%q must be parsed as a null move", spelling) + } + }) + } +} + +func TestNullMove_PGNRead_TwoConsecutiveNulls(t *testing.T) { + // White passes, Black passes: side to move is back to White at move 2. + pgn := withMinimalTags("1. Z0 Z0 *") + g := mustParseSingleGame(t, pgn) + moves := g.Moves() + if len(moves) != 2 { + t.Fatalf("len(Moves)=%d, want 2", len(moves)) + } + for i, m := range moves { + if !m.HasTag(chess.Null) { + t.Fatalf("move %d must be null", i) + } + } + if g.Position().Turn() != chess.White { + t.Fatalf("after 1.Z0 Z0, Turn=%v, want White", g.Position().Turn()) + } +} + +// ------------------------------------------------------------------ +// PGN write + read round trip +// ------------------------------------------------------------------ + +func TestNullMove_PGNWrite(t *testing.T) { + g := chess.NewGame() + if _, err := g.NullMove(); err != nil { + t.Fatalf("NullMove: %v", err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatalf("PushMove e5: %v", err) + } + rendered := g.String() + if !strings.Contains(rendered, " Z0 ") { + t.Fatalf("rendered PGN should contain Z0, got:\n%s", rendered) + } +} + +func TestNullMove_PGNWriteReadRoundTrip(t *testing.T) { + g := chess.NewGame() + if _, err := g.NullMove(); err != nil { + t.Fatalf("NullMove: %v", err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatalf("PushMove e5: %v", err) + } + if _, err := g.PushMove("Nf3", nil); err != nil { + t.Fatalf("PushMove Nf3: %v", err) + } + if _, err := g.NullMove(); err != nil { + t.Fatalf("NullMove (tail): %v", err) + } + rendered := g.String() + + loaded, err := chess.ParsePGN(strings.NewReader(rendered)) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + moves := loaded.Moves() + if len(moves) != 4 { + t.Fatalf("len(Moves)=%d, want 4", len(moves)) + } + if !moves[0].HasTag(chess.Null) { + t.Fatal("round-tripped first move must be null") + } + if !moves[3].HasTag(chess.Null) { + t.Fatal("round-tripped last move must be null") + } + // Z0 e5 Nf3 Z0: White pass, Black e5, White Nf3, Black pass -> White to move. + if loaded.Position().Turn() != chess.White { + t.Fatalf("after round-trip, Turn=%v, want White", loaded.Position().Turn()) + } +} + +// ------------------------------------------------------------------ +// helpers +// ------------------------------------------------------------------ + +// fenField splits a FEN string and returns the 0-indexed field as an int. +func fenField(fen string, idx int) int { + parts := strings.Split(fen, " ") + if idx >= len(parts) { + return 0 + } + n := 0 + for _, c := range parts[idx] { + if c < '0' || c > '9' { + return 0 + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/opening/README.md b/opening/README.md index 31961d86..bfaa399a 100644 --- a/opening/README.md +++ b/opening/README.md @@ -22,8 +22,8 @@ import ( func main(){ g := chess.NewGame() - g.PushNotationMove("e4", chess.AlgebraicNotation{}, nil) - g.PushNotationMove("e6", chess.AlgebraicNotation{}, nil) + g.PushMoveText("e4", chess.SAN(), nil) + g.PushMoveText("e6", chess.SAN(), nil) // print French Defense book := opening.NewBookECO() diff --git a/opening/eco.go b/opening/eco.go index 92c15256..847796a0 100644 --- a/opening/eco.go +++ b/opening/eco.go @@ -3,28 +3,28 @@ package opening import ( "bytes" "encoding/csv" + "errors" "fmt" "io" "strings" - "sync" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) -var ( - defaultBook *BookECO - defaultBookErr error - defaultBookOnce sync.Once -) +var defaultBook = mustBook(bytes.NewReader(ecoData)) // DefaultBook returns the standard ECO opening book. -// The book is parsed lazily on first call and cached for subsequent calls. // It is safe for concurrent use. func DefaultBook() (*BookECO, error) { - defaultBookOnce.Do(func() { - defaultBook, defaultBookErr = NewBook(bytes.NewReader(ecoData)) - }) - return defaultBook, defaultBookErr + return defaultBook, nil +} + +func mustBook(r io.Reader) *BookECO { + book, err := NewBook(r) + if err != nil { + panic(fmt.Errorf("opening: invalid embedded ECO data: %w", err)) + } + return book } // BookECO represents the Encyclopedia of Chess Openings https://en.wikipedia.org/wiki/Encyclopaedia_of_Chess_Openings @@ -36,47 +36,46 @@ type BookECO struct { // NewBook creates a new opening book from an ECO TSV reader. // Use this for custom opening data or when you need isolation from the default book. +// NewBook validates the input during construction so malformed books fail +// before use. Opening.Game returns a caller-owned clone of the cached game. func NewBook(r io.Reader) (*BookECO, error) { b := &BookECO{ root: &node{ - children: map[string]*node{}, + children: map[uint32]*node{}, pos: chess.NewGame().Position(), }, startingPosition: chess.NewGame().Position(), } csvReader := csv.NewReader(r) csvReader.Comma = '\t' + csvReader.FieldsPerRecord = -1 records, err := csvReader.ReadAll() if err != nil { return nil, fmt.Errorf("opening: failed to parse ECO data: %w", err) } for i, row := range records { + rowNum := i + 1 if i == 0 { continue // skip header } if len(row) < 4 { - continue // skip malformed rows + return nil, fmt.Errorf("opening: ECO row %d: expected at least 4 columns, got %d", rowNum, len(row)) } - o := newOpening(row[0], row[1], row[3]) - if err := b.insert(o); err != nil { - return nil, fmt.Errorf("opening: failed to insert opening %s: %w", o.code, err) + moveList := parseMoveList(row[3]) + o, err := newOpening(row[0], row[1], row[3], moveList) + if err != nil { + return nil, fmt.Errorf("opening: ECO row %d (%s %s): %w", rowNum, row[0], row[1], err) + } + if err = b.insertOpening(o); err != nil { + return nil, fmt.Errorf("opening: ECO row %d (%s %s): %w", rowNum, row[0], row[1], err) } } return b, nil } -// NewBookECO returns a new BookECO using the default embedded ECO data. -// Deprecated: Use DefaultBook() for the standard book or NewBook() for custom data. -func NewBookECO() *BookECO { - b, err := DefaultBook() - if err != nil { - panic(err) - } - return b -} - // Find implements the Book interface. -func (b *BookECO) Find(moves []*chess.Move) *Opening { +// Use Find for performance-sensitive opening detection paths. +func (b *BookECO) Find(moves []chess.Move) *Opening { for n := b.followPath(b.root, moves); n != nil; n = n.parent { if n.opening != nil { return n.opening @@ -86,94 +85,103 @@ func (b *BookECO) Find(moves []*chess.Move) *Opening { } // Possible implements the Book interface. -func (b *BookECO) Possible(moves []*chess.Move) []*Opening { - n := b.followPath(b.root, moves) +// Use Possible for performance-sensitive opening exploration paths. +func (b *BookECO) Possible(moves []chess.Move) []*Opening { + root := b.followPath(b.root, moves) var openings []*Opening - for _, n := range b.nodeList(n) { - if n.opening != nil { - openings = append(openings, n.opening) - } - } + b.collectOpenings(root, &openings) return openings } -func (b *BookECO) followPath(n *node, moves []*chess.Move) *node { +// collectOpenings walks the subtree rooted at n and appends each node's +// opening (if any) directly into the result slice, avoiding the intermediate +// []*node allocation that nodeList/collectNodes used to require. +func (b *BookECO) collectOpenings(n *node, result *[]*Opening) { + if n.opening != nil { + *result = append(*result, n.opening) + } + for _, c := range n.children { + b.collectOpenings(c, result) + } +} + +func (b *BookECO) followPath(n *node, moves []chess.Move) *node { if len(moves) == 0 { return n } - c, ok := n.children[moves[0].String()] + c, ok := n.children[moveKey(moves[0])] if !ok { return n } return b.followPath(c, moves[1:]) } -func (b *BookECO) insert(o *Opening) error { - posList := []*chess.Position{b.startingPosition} - var moves []*chess.Move - for _, s := range parseMoveList(o.pgn) { - pos := posList[len(posList)-1] - m, err := chess.UCINotation{}.Decode(pos, s) - if err != nil { - return fmt.Errorf("error decoding move %s: %w", s, err) - } - moves = append(moves, m) - posList = append(posList, pos.Update(m)) +func (b *BookECO) insertOpening(o *Opening) error { + if len(o.moveList) == 0 { + return errors.New("opening has no moves") } + n := b.root - b.ins(n, o, posList[1:], moves) - return nil -} + for _, moveStr := range o.moveList { + m, err := chess.UCI().Decode(n.pos, moveStr) + if err != nil { + return fmt.Errorf("decode move %s: %w", moveStr, err) + } + key := moveKey(m) + if child, ok := n.children[key]; ok { + n = child + continue + } -func (b *BookECO) ins(n *node, o *Opening, posList []*chess.Position, moves []*chess.Move) { - pos := posList[0] - move := moves[0] - moveStr := move.String() - var child *node - for mv, c := range n.children { - if mv == moveStr { - child = c - break + if !isLegalMove(n.pos, m) { + return fmt.Errorf("apply move %s: move is not valid for the current position", moveStr) } - } - if child == nil { - child = &node{ + + child := &node{ parent: n, - children: map[string]*node{}, - pos: pos, + children: map[uint32]*node{}, + pos: n.pos.Update(m), } - n.children[moveStr] = child + n.children[key] = child + n = child } - if len(posList) == 1 { - child.opening = o - return + n.opening = o + return nil +} + +func isLegalMove(pos *chess.Position, move chess.Move) bool { + for _, validMove := range pos.ValidMovesUnsafe() { + if validMove.S1() == move.S1() && validMove.S2() == move.S2() && validMove.Promo() == move.Promo() { + return true + } } - b.ins(child, o, posList[1:], moves[1:]) + return false +} + +// Bit layout for moveKey: a Square fits in 6 bits (0..63), a PieceType fits +// in the low 3 bits of the second 6-bit field. The packed key is therefore +// S1 | S2<<6 | Promo<<12 (18 bits total, fits in a uint32). +const ( + moveKeySquareBits = 6 + moveKeyPromoShift = moveKeySquareBits * 2 +) + +func moveKey(move chess.Move) uint32 { + return uint32(move.S1()) | + uint32(move.S2())<= 5 { + outcome = Draw + method = FivefoldRepetition + } + + if !rules.ignoreSeventyFiveMove && pos.halfMoveClock >= 150 { + outcome = Draw + method = SeventyFiveMoveRule + } + + if !rules.ignoreInsufficient && !pos.board.hasSufficientMaterial() { + outcome = Draw + method = InsufficientMaterial + } + + return outcome, method +} + +// fullOutcomeRules builds the Full ruleset from a Game's ignore flags. It is +// the translation layer between the public functional-option API on Game and +// the pure classifyOutcome policy. +func fullOutcomeRules(g *Game) outcomeRules { + return outcomeRules{ + includeAutoDraws: true, + ignoreFivefold: g.ignoreFivefoldRepetitionDraw, + ignoreSeventyFiveMove: g.ignoreSeventyFiveMoveRuleDraw, + ignoreInsufficient: g.ignoreInsufficientMaterialDraw, + } +} diff --git a/outcome_internal_test.go b/outcome_internal_test.go new file mode 100644 index 00000000..a6aeb54d --- /dev/null +++ b/outcome_internal_test.go @@ -0,0 +1,75 @@ +package chess + +import "testing" + +func TestClassifyOutcome(t *testing.T) { + mateBlackMated := "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4" // Qxf7#, black mated -> WhiteWon + mateWhiteMated := "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3" // fool's mate, white mated -> BlackWon + stalemate := "5k2/5P2/5K2/8/8/8/8/8 b - - 0 1" + kvk0 := "8/8/8/3k4/3K4/8/8/8 w - - 0 60" // insufficient material, isolated (clock 0) + kvk150 := "8/8/8/3k4/3K4/8/8/8 w - - 150 60" // insufficient + clock 150 (precedence) + rook := "4k3/8/8/8/8/8/8/R3K3 w - - 10 60" // sufficient material, no mate + rook75 := "4k3/8/8/8/8/8/8/R3K3 w - - 150 60" // sufficient material, clock 150 + + full := func(overrides ...func(*outcomeRules)) outcomeRules { + r := outcomeRules{includeAutoDraws: true} + for _, o := range overrides { + o(&r) + } + return r + } + withIgnoreFivefold := func(r *outcomeRules) { r.ignoreFivefold = true } + withIgnore75 := func(r *outcomeRules) { r.ignoreSeventyFiveMove = true } + withIgnoreInsufficient := func(r *outcomeRules) { r.ignoreInsufficient = true } + + tests := []struct { + name string + fen string + repetitions int + rules outcomeRules + wantOutcome Outcome + wantMethod Method + }{ + // Terminal outcomes (present in both Full and Terminal-only). + {"checkmate white wins", mateBlackMated, 0, outcomeRules{}, WhiteWon, Checkmate}, + {"checkmate black wins", mateWhiteMated, 0, outcomeRules{}, BlackWon, Checkmate}, + {"checkmate white wins full", mateBlackMated, 5, full(), WhiteWon, Checkmate}, // mate short-circuits, draws ignored + {"stalemate", stalemate, 0, outcomeRules{}, Draw, Stalemate}, + + // Automatic draws (Full only). + {"fivefold", rook, 5, full(), Draw, FivefoldRepetition}, + {"fivefold ignored", rook, 5, full(withIgnoreFivefold), NoOutcome, NoMethod}, + {"fivefold below threshold", rook, 4, full(), NoOutcome, NoMethod}, + {"seventyfive move", rook75, 0, full(), Draw, SeventyFiveMoveRule}, + {"seventyfive move ignored", rook75, 0, full(withIgnore75), NoOutcome, NoMethod}, + {"insufficient material", kvk0, 0, full(), Draw, InsufficientMaterial}, + {"insufficient material ignored", kvk0, 0, full(withIgnoreInsufficient), NoOutcome, NoMethod}, + + // Terminal-only policy never emits automatic draws. + {"terminal only skips all draws", kvk150, 5, outcomeRules{}, NoOutcome, NoMethod}, + + // Precedence: when fivefold, 75-move and insufficient all apply, + // InsufficientMaterial wins (it is evaluated last). + {"precedence insufficient wins", kvk150, 5, full(), Draw, InsufficientMaterial}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos, err := decodeFEN(tt.fen) + if err != nil { + t.Fatalf("decodeFEN(%q): %v", tt.fen, err) + } + if pos == nil { + t.Fatalf("decodeFEN(%q) returned nil position", tt.fen) + } + // decodeFEN does not populate inCheck; production callers always + // have it set (FEN option / move application), so mirror that here + // so Status() can detect checkmate. + pos.inCheck = isInCheck(pos) + gotOutcome, gotMethod := classifyOutcome(pos, tt.repetitions, tt.rules) + if gotOutcome != tt.wantOutcome || gotMethod != tt.wantMethod { + t.Errorf("classifyOutcome = (%s, %s), want (%s, %s)", gotOutcome, gotMethod, tt.wantOutcome, tt.wantMethod) + } + }) + } +} diff --git a/outcome_types.go b/outcome_types.go new file mode 100644 index 00000000..90e210f7 --- /dev/null +++ b/outcome_types.go @@ -0,0 +1,52 @@ +package chess + +// A Outcome is the result of a game. +type Outcome string + +const ( + UnknownOutcome Outcome = "" + // NoOutcome indicates that a game is in progress or ended without a result. + NoOutcome Outcome = "*" + // WhiteWon indicates that white won the game. + WhiteWon Outcome = "1-0" + // BlackWon indicates that black won the game. + BlackWon Outcome = "0-1" + // Draw indicates that game was a draw. + Draw Outcome = "1/2-1/2" +) + +// String implements the fmt.Stringer interface. +func (o Outcome) String() string { + return string(o) +} + +// A Method is the method that generated the outcome. +type Method uint8 + +const ( + // NoMethod indicates that an outcome hasn't occurred or that the method can't be determined. + NoMethod Method = iota + // Checkmate indicates that the game was won checkmate. + Checkmate + // Resignation indicates that the game was won by resignation. + Resignation + // DrawOffer indicates that the game was drawn by a draw offer. + DrawOffer + // Stalemate indicates that the game was drawn by stalemate. + Stalemate + // ThreefoldRepetition indicates that the game was drawn when the game + // state was repeated three times and a player requested a draw. + ThreefoldRepetition + // FivefoldRepetition indicates that the game was automatically drawn + // by the game state being repeated five times. + FivefoldRepetition + // FiftyMoveRule indicates that the game was drawn by the half + // move clock being one hundred or greater when a player requested a draw. + FiftyMoveRule + // SeventyFiveMoveRule indicates that the game was automatically drawn + // when the half move clock was one hundred and fifty or greater. + SeventyFiveMoveRule + // InsufficientMaterial indicates that the game was automatically drawn + // because there was insufficient material for checkmate. + InsufficientMaterial +) diff --git a/engine.go b/patterns.go similarity index 51% rename from engine.go rename to patterns.go index 35f8bdba..68e75385 100644 --- a/engine.go +++ b/patterns.go @@ -1,314 +1,102 @@ -/* -Package chess implements a chess game engine that manages move generation, -position analysis, and game state validation. -The engine uses bitboard operations and lookup tables for efficient move -generation and position analysis. Move generation includes standard piece -moves, captures, castling, en passant, and pawn promotions. -Example usage: - - // Create a position - pos := NewPosition() - - // Calculate legal moves for current position - eng := engine{} - moves := eng.CalcMoves(pos, false) - - // Check game status - status := eng.Status(pos) - if status == Checkmate { - fmt.Println("Game Over - Checkmate") - } -*/ package chess -import "sync" +import ( + "math/bits" +) -// engine implements chess move generation and position analysis. -type engine struct{} +const ( + bbFileA bitboard = 9259542123273814144 + bbFileB bitboard = 4629771061636907072 + bbFileC bitboard = 2314885530818453536 + bbFileD bitboard = 1157442765409226768 + bbFileE bitboard = 578721382704613384 + bbFileF bitboard = 289360691352306692 + bbFileG bitboard = 144680345676153346 + bbFileH bitboard = 72340172838076673 -// CalcMoves returns all legal moves for the given position. If first is true, -// returns after finding the first legal move. This is useful for quick position -// validation. -// -// The moves are generated in the following order: -// 1. Standard piece moves and captures -// 2. Castling moves (if available) -// -// 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, false) - // return moves including castles - return append(moves, castleMoves(pos)...) -} + bbRank1 bitboard = 18374686479671623680 + bbRank2 bitboard = 71776119061217280 + bbRank3 bitboard = 280375465082880 + bbRank4 bitboard = 1095216660480 + bbRank5 bitboard = 4278190080 + bbRank6 bitboard = 16711680 + bbRank7 bitboard = 65280 + bbRank8 bitboard = 255 +) -// 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) +// bbForSquare returns the bitboard mask for the given square. +// This is a package-level function rather than a Square method because it +// accesses the package-level lookup table bbSquares. +func bbForSquare(sq Square) bitboard { + return bbSquares[sq] } -// Status returns the current game status (Checkmate, Stalemate, or NoMethod) -// based on the position. -// -// The status is determined by: -// - Whether the side to move is in check -// - Whether any legal moves exist +// Lookup tables for piece movement patterns and board masks. // -// If the position has cached valid moves in pos.validMoves, those will be -// used. Otherwise, moves will be calculated to determine the status. -func (engine) Status(pos *Position) Method { - var hasMove bool - if pos.validMoves != nil { - hasMove = len(pos.validMoves) > 0 - } else { - hasMove = len(engine{}.CalcMoves(pos, true)) > 0 - } - if !pos.inCheck && !hasMove { - return Stalemate - } else if pos.inCheck && !hasMove { - return Checkmate - } - return NoMethod -} +//nolint:gochecknoglobals // this is a lookup table +var ( + bbFiles = [8]bitboard{bbFileA, bbFileB, bbFileC, bbFileD, bbFileE, bbFileF, bbFileG, bbFileH} + bbRanks = [8]bitboard{bbRank1, bbRank2, bbRank3, bbRank4, bbRank5, bbRank6, bbRank7, bbRank8} -// promoPieceTypes is an immutable array of promotion piece types. -// Treat as read-only; do not modify elements. -// -//nolint:gochecknoglobals // Immutable lookup table. -var promoPieceTypes = [4]PieceType{Queen, Rook, Bishop, Knight} + bbDiagonals = [64]bitboard{9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 144396663052566528, 72057594037927936, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 144396663052566528, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 32832, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 128, 32832, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745} -const maxPossibleMoves = 218 // Maximum possible moves in any chess position + bbAntiDiagonals = [64]bitboard{9223372036854775808, 4647714815446351872, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 4647714815446351872, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 258, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 258, 1} -// movePool is a pool of Move arrays to reduce allocations -// in the standardMoves function. -// -//nolint:gochecknoglobals // this is a sync pool -var movePool = sync.Pool{ - New: func() interface{} { - return &[maxPossibleMoves]Move{} - }, -} + bbKnightMoves = [64]bitboard{9077567998918656, 4679521487814656, 38368557762871296, 19184278881435648, 9592139440717824, 4796069720358912, 2257297371824128, 1128098930098176, 2305878468463689728, 1152939783987658752, 9799982666336960512, 4899991333168480256, 2449995666584240128, 1224997833292120064, 576469569871282176, 288234782788157440, 4620693356194824192, 11533718717099671552, 5802888705324613632, 2901444352662306816, 1450722176331153408, 725361088165576704, 362539804446949376, 145241105196122112, 18049583422636032, 45053588738670592, 22667534005174272, 11333767002587136, 5666883501293568, 2833441750646784, 1416171111120896, 567348067172352, 70506185244672, 175990581010432, 88545054707712, 44272527353856, 22136263676928, 11068131838464, 5531918402816, 2216203387392, 275414786112, 687463207072, 345879119952, 172939559976, 86469779988, 43234889994, 21609056261, 8657044482, 1075839008, 2685403152, 1351090312, 675545156, 337772578, 168886289, 84410376, 33816580, 4202496, 10489856, 5277696, 2638848, 1319424, 659712, 329728, 132096} -// standardMoves generates all standard (non-castling) legal moves for the -// current position. If first is true, returns after finding the first -// legal move. -// -// 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, unsafeOnly bool) []Move { - moves, _ := movePool.Get().(*[maxPossibleMoves]Move) - defer movePool.Put(moves) - count := 0 - - // Reuse a single Move struct for temporary operations - var m Move - - bbAllowed := ^pos.board.whiteSqs - if pos.Turn() == Black { - bbAllowed = ^pos.board.blackSqs - } + bbBishopMoves = [64]bitboard{18049651735527937, 45053622886727936, 22667548931719168, 11334324221640704, 5667164249915392, 2833579985862656, 1416240237150208, 567382630219904, 4611756524879479810, 11529391036782871041, 5764696068147249408, 2882348036221108224, 1441174018118909952, 720587009051099136, 360293502378066048, 144117404414255168, 2323857683139004420, 1197958188344280066, 9822351133174399489, 4911175566595588352, 2455587783297826816, 1227793891648880768, 577868148797087808, 288793334762704928, 1161999073681608712, 581140276476643332, 326598935265674242, 9386671504487645697, 4693335752243822976, 2310639079102947392, 1155178802063085600, 577588851267340304, 580999811184992272, 290500455356698632, 145390965166737412, 108724279602332802, 9241705379636978241, 4620711952330133792, 2310355426409252880, 1155177711057110024, 290499906664153120, 145249955479592976, 72625527495610504, 424704217196612, 36100411639206946, 9241421692918565393, 4620710844311799048, 2310355422147510788, 145249953336262720, 72624976676520096, 283693466779728, 1659000848424, 141017232965652, 36099303487963146, 9241421688590368773, 4620710844295151618, 72624976668147712, 283691315142656, 1108177604608, 6480472064, 550848566272, 141012904249856, 36099303471056128, 9241421688590303744} - for _, p := range allPieces { - if pos.Turn() != p.Color() { - continue - } - s1BB := pos.board.bbForPiece(p) - if s1BB == 0 { - continue - } - for s1 := range numOfSquaresInBoard { - if s1BB&bbForSquare(Square(s1)) == 0 { - continue - } - s2BB := bbForPossibleMoves(pos, p.Type(), Square(s1)) & bbAllowed - if s2BB == 0 { - continue - } - for s2 := range numOfSquaresInBoard { - if s2BB&bbForSquare(Square(s2)) == 0 { - continue - } + bbRookMoves = [64]bitboard{9187484529235886208, 13781085504453754944, 16077885992062689312, 17226286235867156496, 17800486357769390088, 18087586418720506884, 18231136449196065282, 18302911464433844481, 9259260648297103488, 4665518383679160384, 2368647251370188832, 1220211685215703056, 645993902138460168, 358885010599838724, 215330564830528002, 143553341945872641, 9259541023762186368, 4629910699613634624, 2315095537539358752, 1157687956502220816, 578984165983651848, 289632270724367364, 144956323094725122, 72618349279904001, 9259542118978846848, 4629771607097753664, 2314886351157207072, 1157443723186933776, 578722409201797128, 289361752209228804, 144681423712944642, 72341259464802561, 9259542123257036928, 4629771063767613504, 2314885534022901792, 1157442769150545936, 578721386714368008, 289360695496279044, 144680349887234562, 72340177082712321, 9259542123273748608, 4629771061645230144, 2314885530830970912, 1157442765423841296, 578721382720276488, 289360691368494084, 144680345692602882, 72340172854657281, 9259542123273813888, 4629771061636939584, 2314885530818502432, 1157442765409283856, 578721382704674568, 289360691352369924, 144680345676217602, 72340172838141441, 9259542123273814143, 4629771061636907199, 2314885530818453727, 1157442765409226991, 578721382704613623, 289360691352306939, 144680345676153597, 72340172838076926} - // Reuse move struct by setting fields directly - m.s1 = Square(s1) - m.s2 = Square(s2) - - if (p == WhitePawn && Square(s2).Rank() == Rank8) || (p == BlackPawn && Square(s2).Rank() == Rank1) { - for _, pt := range promoPieceTypes { - m.promo = pt - m.tags = moveTags(m, pos) - if m.HasTag(inCheck) == unsafeOnly { - // Copy the valid move to the array - moves[count] = m - count++ - if first { - // For single move, return fixed array of size 1 - var result [1]Move - result[0] = moves[0] - return result[:] - } - } - } - } else { - m.promo = 0 - m.tags = moveTags(m, pos) - if m.HasTag(inCheck) == unsafeOnly { - moves[count] = m - count++ - if first { - var result [1]Move - result[0] = moves[0] - return result[:] - } - } - } - } - } - } + bbQueenMoves = [64]bitboard{9205534180971414145, 13826139127340482880, 16100553540994408480, 17237620560088797200, 17806153522019305480, 18090419998706369540, 18232552689433215490, 18303478847064064385, 13871017173176583298, 16194909420462031425, 8133343319517438240, 4102559721436811280, 2087167920257370120, 1079472019650937860, 575624067208594050, 287670746360127809, 11583398706901190788, 5827868887957914690, 12137446670713758241, 6068863523097809168, 3034571949281478664, 1517426162373248132, 722824471891812930, 361411684042608929, 10421541192660455560, 5210911883574396996, 2641485286422881314, 10544115227674579473, 5272058161445620104, 2600000831312176196, 1299860225776030242, 649930110732142865, 9840541934442029200, 4920271519124312136, 2460276499189639204, 1266167048752878738, 9820426766351346249, 4910072647826412836, 2455035776296487442, 1227517888139822345, 9550042029937901728, 4775021017124823120, 2387511058326581416, 1157867469641037908, 614821794359483434, 9530782384287059477, 4765391190004401930, 2382695595002168069, 9404792076610076608, 4702396038313459680, 2315169224285282160, 1157444424410132280, 578862399937640220, 325459994840333070, 9386102034266586375, 4693051017133293059, 9332167099941961855, 4630054752952049855, 2314886638996058335, 1157442771889699055, 578721933553179895, 289501704256556795, 180779649147209725, 9313761861428380670} - // Need to copy since we're returning array to pool - result := make([]Move, count) - copy(result, moves[:count]) - return result -} + bbKingMoves = [64]bitboard{4665729213955833856, 11592265440851656704, 5796132720425828352, 2898066360212914176, 1449033180106457088, 724516590053228544, 362258295026614272, 144959613005987840, 13853283560024178688, 16186183351374184448, 8093091675687092224, 4046545837843546112, 2023272918921773056, 1011636459460886528, 505818229730443264, 216739030602088448, 54114388906344448, 63227278716305408, 31613639358152704, 15806819679076352, 7903409839538176, 3951704919769088, 1975852459884544, 846636838289408, 211384331665408, 246981557485568, 123490778742784, 61745389371392, 30872694685696, 15436347342848, 7718173671424, 3307175149568, 825720045568, 964771708928, 482385854464, 241192927232, 120596463616, 60298231808, 30149115904, 12918652928, 3225468928, 3768639488, 1884319744, 942159872, 471079936, 235539968, 117769984, 50463488, 12599488, 14721248, 7360624, 3680312, 1840156, 920078, 460039, 197123, 49216, 57504, 28752, 14376, 7188, 3594, 1797, 770} -// moveTags computes all tags for a move from scratch based on the resulting position. -// Tags include: -// - Capture: The move captures an opponent's piece -// - EnPassant: The move is an en passant capture -// - Check: The move puts the opponent in check -// - inCheck: The move leaves the moving side's king in check (illegal) -// - KingSideCastle: The move is a king-side castle -// - QueenSideCastle: The move is a queen-side castle -func moveTags(m Move, pos *Position) MoveTag { - var tags MoveTag - p := pos.board.Piece(m.s1) - if pos.board.isOccupied(m.s2) { - tags |= Capture - } else if m.s2 == pos.enPassantSquare && p.Type() == Pawn { - tags |= EnPassant - } - // determine if move is castle - if (p == WhiteKing && m.s1 == E1) || (p == BlackKing && m.s1 == E8) { - switch m.s2 { - case C1, C8: - tags |= QueenSideCastle - case G1, G8: - tags |= KingSideCastle - } - } - // apply preliminary tags to a local copy so board.update reads them correctly - local := m - local.tags = tags - // determine if in check after move (makes move invalid) - // Simulate the move on a temporary board copy so we can test - // check status without mutating the actual position. - tempBoard := *pos.board - tempBoard.update(&local) - if tempBoard.kingSquare(pos.turn) != NoSquare { - if isSquareAttackedBy(&tempBoard, tempBoard.kingSquare(pos.turn), pos.turn.Other()) { - tags |= inCheck - } - } - // determine if opponent in check after move - if tempBoard.kingSquare(pos.turn.Other()) != NoSquare { - if isSquareAttackedBy(&tempBoard, tempBoard.kingSquare(pos.turn.Other()), pos.turn) { - tags |= Check - } - } - return tags -} + bbSquares = [64]bitboard{} + lineSquares = [4][64][8]Square{} + lineBitboards = [4][64][8]bitboard{} + lineMasks = [4][64]bitboard{} + lineBitIndexes = [4][64][64]uint8{} + lineWordIndexes = [4][64][4][65536]uint8{} + fileWordIndexes = [8][4][65536]uint8{} + lineLens = [4][64]int{} + alignedMasks = [64]bitboard{} + betweenMasks = [64][64]bitboard{} + rayFileSteps = [64][64]int8{} + rayRankSteps = [64][64]int8{} + rayDiagonals = [64][64]bool{} + slidingAttacks = [4][64][256]bitboard{} +) -// isInCheck returns true if the side to move is in check in the given position. -func isInCheck(pos *Position) bool { - kingSq := pos.board.kingSquare(pos.Turn()) - // king should only be missing in tests / examples - if kingSq == NoSquare { - return false - } - return squaresAreAttacked(pos, kingSq) -} +const ( + slideRank = iota + slideFile + slideDiag + slideAntiDiag + slideLineCount +) -// isSquareAttackedBy returns true if the given square is attacked by the specified color. -// This is a board-level operation that does not require a full Position. -// -//nolint:mnd // this is a formula to determine if a square is attacked -func isSquareAttackedBy(board *Board, sq Square, attacker Color) bool { - occ := ^board.emptySqs - - // hot path check to see if attack vector is possible - s2BB := board.blackSqs - if attacker == White { - s2BB = board.whiteSqs - } - if ((diaAttack(occ, sq)|hvAttack(occ, sq))&s2BB)|(bbKnightMoves[sq]&s2BB) == 0 { - return false - } +func lineIndex(occupied bitboard, line int, sq Square) uint8 { + u := uint64(occupied) + t := &lineWordIndexes[line][sq] + return t[0][uint16(u>>48)] | + t[1][uint16(u>>32)] | + t[2][uint16(u>>16)] | + t[3][uint16(u)] +} - // check queen attack vector - queenBB := board.bbForPiece(NewPiece(Queen, attacker)) - bb := (diaAttack(occ, sq) | hvAttack(occ, sq)) & queenBB - if bb != 0 { - return true - } - // check rook attack vector - rookBB := board.bbForPiece(NewPiece(Rook, attacker)) - bb = hvAttack(occ, sq) & rookBB - if bb != 0 { - return true - } - // check bishop attack vector - bishopBB := board.bbForPiece(NewPiece(Bishop, attacker)) - bb = diaAttack(occ, sq) & bishopBB - if bb != 0 { - return true - } - // check knight attack vector - knightBB := board.bbForPiece(NewPiece(Knight, attacker)) - bb = bbKnightMoves[sq] & knightBB - if bb != 0 { - return true - } - // check pawn attack vector - if attacker == Black { - capRight := (board.bbBlackPawn & ^bbFileH & ^bbRank1) << 7 - capLeft := (board.bbBlackPawn & ^bbFileA & ^bbRank1) << 9 - bb = (capRight | capLeft) & bbForSquare(sq) - if bb != 0 { - return true - } - } else { - capRight := (board.bbWhitePawn & ^bbFileH & ^bbRank8) >> 9 - capLeft := (board.bbWhitePawn & ^bbFileA & ^bbRank8) >> 7 - bb = (capRight | capLeft) & bbForSquare(sq) - if bb != 0 { - return true - } - } - // check king attack vector - kingBB := board.bbForPiece(NewPiece(King, attacker)) - bb = bbKingMoves[sq] & kingBB - return bb != 0 +func rankLineIndex(occupied bitboard, sq Square) uint8 { + shift := uint((7 - sq.Rank()) * 8) + return bits.Reverse8(byte(uint64(occupied) >> shift)) } -// squaresAreAttacked returns true if the opponent attacks any of the given squares -// -// in the given position. -// -// The function checks attacks from: -// - Sliding pieces (queen, rook, bishop) -// - Knights -// - Pawns -// - King -func squaresAreAttacked(pos *Position, sqs ...Square) bool { - otherColor := pos.Turn().Other() - for _, sq := range sqs { - if isSquareAttackedBy(pos.board, sq, otherColor) { - return true - } - } - return false +func fileLineIndex(occupied bitboard, sq Square) uint8 { + u := uint64(occupied) + t := &fileWordIndexes[sq.File()] + return t[0][uint16(u>>48)] | + t[1][uint16(u>>32)] | + t[2][uint16(u>>16)] | + t[3][uint16(u)] } // bbForPossibleMoves returns a bitboard with 1s in positions where the piece @@ -340,67 +128,6 @@ func bbForPossibleMoves(pos *Position, pt PieceType, sq Square) bitboard { return bitboard(0) } -// castleMoves returns all legal castling moves for the current position. -// -// A castling move is legal if: -// - The king has castling rights in that direction -// - The squares between king and rook are empty -// - The king is not in check -// - The king does not pass through check -func castleMoves(pos *Position) []Move { - var moves [2]Move // Maximum of 2 possible castle moves (king side and queen side) - count := 0 - - kingSide := pos.castleRights.CanCastle(pos.Turn(), KingSide) - queenSide := pos.castleRights.CanCastle(pos.Turn(), QueenSide) - - // white king side - if pos.turn == White && kingSide && - (^pos.board.emptySqs&(bbForSquare(F1)|bbForSquare(G1))) == 0 && - !squaresAreAttacked(pos, F1, G1) && - !pos.inCheck { - m := Move{s1: E1, s2: G1} - m.tags = moveTags(m, pos) - moves[count] = m - count++ - } - - // white queen side - if pos.turn == White && queenSide && - (^pos.board.emptySqs&(bbForSquare(B1)|bbForSquare(C1)|bbForSquare(D1))) == 0 && - !squaresAreAttacked(pos, C1, D1) && - !pos.inCheck { - m := Move{s1: E1, s2: C1} - m.tags = moveTags(m, pos) - moves[count] = m - count++ - } - - // black king side - if pos.turn == Black && kingSide && - (^pos.board.emptySqs&(bbForSquare(F8)|bbForSquare(G8))) == 0 && - !squaresAreAttacked(pos, F8, G8) && - !pos.inCheck { - m := Move{s1: E8, s2: G8} - m.tags = moveTags(m, pos) - moves[count] = m - count++ - } - - // black queen side - if pos.turn == Black && queenSide && - (^pos.board.emptySqs&(bbForSquare(B8)|bbForSquare(C8)|bbForSquare(D8))) == 0 && - !squaresAreAttacked(pos, C8, D8) && - !pos.inCheck { - m := Move{s1: E8, s2: C8} - m.tags = moveTags(m, pos) - moves[count] = m - count++ - } - - return moves[:count] -} - // pawnMoves returns a bitboard with 1s in positions where the pawn at the // given square can potentially move. // @@ -430,89 +157,241 @@ func pawnMoves(pos *Position, sq Square) bitboard { return capRight | capLeft | upOne | upTwo } -// diaAttack returns a bitboard representing possible diagonal moves for a -// sliding piece, considering occupied squares as blocking further movement. -func diaAttack(occupied bitboard, sq Square) bitboard { - pos := bbForSquare(sq) - dMask := bbDiagonals[sq] - adMask := bbAntiDiagonals[sq] - return linearAttack(occupied, pos, dMask) | linearAttack(occupied, pos, adMask) +func rayStep(from Square, to Square) (int, int, bool) { + fileDelta := int(to.File()) - int(from.File()) + rankDelta := int(to.Rank()) - int(from.Rank()) + switch { + case fileDelta == 0: + return 0, compareStep(rankDelta, 0), false + case rankDelta == 0: + return compareStep(fileDelta, 0), 0, false + case abs(fileDelta) == abs(rankDelta): + return compareStep(fileDelta, 0), compareStep(rankDelta, 0), true + default: + return 0, 0, false + } +} + +func piecePinsAlong(pt PieceType, diagonal bool) bool { + if pt == Queen { + return true + } + if diagonal { + return pt == Bishop + } + return pt == Rook } -// hvAttack returns a bitboard representing possible horizontal and vertical -func hvAttack(occupied bitboard, sq Square) bitboard { - pos := bbForSquare(sq) - rankMask := bbRanks[sq.Rank()] - fileMask := bbFiles[sq.File()] - return linearAttack(occupied, pos, rankMask) | linearAttack(occupied, pos, fileMask) +func squaresBetween(a Square, b Square) bitboard { + fileStep := compareStep(int(b.File()), int(a.File())) + rankStep := compareStep(int(b.Rank()), int(a.Rank())) + if fileStep == 0 && rankStep == 0 { + return 0 + } + if fileStep != 0 && rankStep != 0 && !sameDiagonal(a, b) { + return 0 + } + var out bitboard + file := int(a.File()) + fileStep + rank := int(a.Rank()) + rankStep + for file != int(b.File()) || rank != int(b.Rank()) { + out |= bbForSquare(NewSquare(File(file), Rank(rank))) + file += fileStep + rank += rankStep + } + return out } -// linearAttack returns a bitboard representing possible moves in a single -// direction (rank, file, or diagonal) for a sliding piece, considering -// occupied squares as blocking further movement. -func linearAttack(occupied, pos, mask bitboard) bitboard { - oInMask := occupied & mask - return ((oInMask - 2*pos) ^ (oInMask.Reverse() - 2*pos.Reverse()).Reverse()) & mask +func compareStep(a, b int) int { + switch { + case a > b: + return 1 + case a < b: + return -1 + default: + return 0 + } } -const ( - bbFileA bitboard = 9259542123273814144 - bbFileB bitboard = 4629771061636907072 - bbFileC bitboard = 2314885530818453536 - bbFileD bitboard = 1157442765409226768 - bbFileE bitboard = 578721382704613384 - bbFileF bitboard = 289360691352306692 - bbFileG bitboard = 144680345676153346 - bbFileH bitboard = 72340172838076673 +func abs(n int) int { + if n < 0 { + return -n + } + return n +} - bbRank1 bitboard = 18374686479671623680 - bbRank2 bitboard = 71776119061217280 - bbRank3 bitboard = 280375465082880 - bbRank4 bitboard = 1095216660480 - bbRank5 bitboard = 4278190080 - bbRank6 bitboard = 16711680 - bbRank7 bitboard = 65280 - bbRank8 bitboard = 255 -) +func sameDiagonal(a Square, b Square) bool { + fileDelta := int(a.File()) - int(b.File()) + if fileDelta < 0 { + fileDelta = -fileDelta + } + rankDelta := int(a.Rank()) - int(b.Rank()) + if rankDelta < 0 { + rankDelta = -rankDelta + } + return fileDelta == rankDelta +} -// bbForSquare returns the bitboard mask for the given square. -// This is a package-level function rather than a Square method because it -// accesses the package-level lookup table bbSquares. -func bbForSquare(sq Square) bitboard { - return bbSquares[sq] +func squareFromBit(bb bitboard) Square { + return Square(63 - bits.TrailingZeros64(uint64(bb))) } -// Lookup tables for piece movement patterns and board masks. +// init populates the bbSquares lookup table. This is done at package +// initialization because the values are constants derived from square indices. // -//nolint:gochecknoglobals // this is a lookup table -var ( - bbFiles = [8]bitboard{bbFileA, bbFileB, bbFileC, bbFileD, bbFileE, bbFileF, bbFileG, bbFileH} // bbFiles contains masks for each file (A-H) - bbRanks = [8]bitboard{bbRank1, bbRank2, bbRank3, bbRank4, bbRank5, bbRank6, bbRank7, bbRank8} // bbRanks contains masks for each rank (1-8) +//nolint:gochecknoinits // Required for lookup table initialization. +func init() { + const numOfSquaresInBoard = 64 + for sq := range numOfSquaresInBoard { + bbSquares[sq] = bitboard(uint64(1) << (uint8(63) - uint8(sq))) + } + initAlignedMasks() + initRayMasks() + initMagicAttackTables() + initSlidingAttackTables() +} - bbDiagonals = [64]bitboard{9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 144396663052566528, 72057594037927936, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 144396663052566528, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 288794425616760832, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 577588855528488960, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 1155177711073755136, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 2310355422147575808, 32832, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745, 4620710844295151872, 128, 32832, 8405024, 2151686160, 550831656968, 141012904183812, 36099303471055874, 9241421688590303745} +func initAlignedMasks() { + for a := range numOfSquaresInBoard { + for b := range numOfSquaresInBoard { + if squaresAligned(Square(a), Square(b)) { + alignedMasks[a] |= bbForSquare(Square(b)) + } + } + } +} - bbAntiDiagonals = [64]bitboard{9223372036854775808, 4647714815446351872, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 4647714815446351872, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 2323998145211531264, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 1161999622361579520, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 580999813328273408, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 290499906672525312, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 145249953336295424, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 258, 72624976668147840, 283691315109952, 1108169199648, 4328785936, 16909320, 66052, 258, 1} +func initRayMasks() { + for a := range numOfSquaresInBoard { + for b := range numOfSquaresInBoard { + fileStep, rankStep, diagonal := rayStep(Square(a), Square(b)) + rayFileSteps[a][b] = int8(fileStep) + rayRankSteps[a][b] = int8(rankStep) + rayDiagonals[a][b] = diagonal + betweenMasks[a][b] = squaresBetween(Square(a), Square(b)) + } + } +} - bbKnightMoves = [64]bitboard{9077567998918656, 4679521487814656, 38368557762871296, 19184278881435648, 9592139440717824, 4796069720358912, 2257297371824128, 1128098930098176, 2305878468463689728, 1152939783987658752, 9799982666336960512, 4899991333168480256, 2449995666584240128, 1224997833292120064, 576469569871282176, 288234782788157440, 4620693356194824192, 11533718717099671552, 5802888705324613632, 2901444352662306816, 1450722176331153408, 725361088165576704, 362539804446949376, 145241105196122112, 18049583422636032, 45053588738670592, 22667534005174272, 11333767002587136, 5666883501293568, 2833441750646784, 1416171111120896, 567348067172352, 70506185244672, 175990581010432, 88545054707712, 44272527353856, 22136263676928, 11068131838464, 5531918402816, 2216203387392, 275414786112, 687463207072, 345879119952, 172939559976, 86469779988, 43234889994, 21609056261, 8657044482, 1075839008, 2685403152, 1351090312, 675545156, 337772578, 168886289, 84410376, 33816580, 4202496, 10489856, 5277696, 2638848, 1319424, 659712, 329728, 132096} +func initSlidingAttackTables() { + for sq := range numOfSquaresInBoard { + initSlidingLines(Square(sq)) + } + initLineWordIndexes() + for line := range slideLineCount { + for sq := range numOfSquaresInBoard { + for idx := range 256 { + slidingAttacks[line][sq][idx] = attackOnLine(line, Square(sq), uint8(idx)) + } + } + } +} - bbBishopMoves = [64]bitboard{18049651735527937, 45053622886727936, 22667548931719168, 11334324221640704, 5667164249915392, 2833579985862656, 1416240237150208, 567382630219904, 4611756524879479810, 11529391036782871041, 5764696068147249408, 2882348036221108224, 1441174018118909952, 720587009051099136, 360293502378066048, 144117404414255168, 2323857683139004420, 1197958188344280066, 9822351133174399489, 4911175566595588352, 2455587783297826816, 1227793891648880768, 577868148797087808, 288793334762704928, 1161999073681608712, 581140276476643332, 326598935265674242, 9386671504487645697, 4693335752243822976, 2310639079102947392, 1155178802063085600, 577588851267340304, 580999811184992272, 290500455356698632, 145390965166737412, 108724279602332802, 9241705379636978241, 4620711952330133792, 2310355426409252880, 1155177711057110024, 290499906664153120, 145249955479592976, 72625527495610504, 424704217196612, 36100411639206946, 9241421692918565393, 4620710844311799048, 2310355422147510788, 145249953336262720, 72624976676520096, 283693466779728, 1659000848424, 141017232965652, 36099303487963146, 9241421688590368773, 4620710844295151618, 72624976668147712, 283691315142656, 1108177604608, 6480472064, 550848566272, 141012904249856, 36099303471056128, 9241421688590303744} +func initLineWordIndexes() { + for line := range slideLineCount { + if line == slideRank || line == slideFile { + continue + } + for sq := range numOfSquaresInBoard { + for wordIndex := range 4 { + shift := uint((3 - wordIndex) * 16) + for value := range 65536 { + chunk := bitboard(uint64(value) << shift) + lineWordIndexes[line][sq][wordIndex][value] = lineIndexFromMaskedOccupancy(chunk, line, Square(sq)) + } + } + } + } + for file := range numOfSquaresInRow { + sq := NewSquare(File(file), Rank1) + for wordIndex := range 4 { + shift := uint((3 - wordIndex) * 16) + for value := range 65536 { + chunk := bitboard(uint64(value) << shift) + fileWordIndexes[file][wordIndex][value] = lineIndexFromMaskedOccupancy(chunk, slideFile, sq) + } + } + } +} - bbRookMoves = [64]bitboard{9187484529235886208, 13781085504453754944, 16077885992062689312, 17226286235867156496, 17800486357769390088, 18087586418720506884, 18231136449196065282, 18302911464433844481, 9259260648297103488, 4665518383679160384, 2368647251370188832, 1220211685215703056, 645993902138460168, 358885010599838724, 215330564830528002, 143553341945872641, 9259541023762186368, 4629910699613634624, 2315095537539358752, 1157687956502220816, 578984165983651848, 289632270724367364, 144956323094725122, 72618349279904001, 9259542118978846848, 4629771607097753664, 2314886351157207072, 1157443723186933776, 578722409201797128, 289361752209228804, 144681423712944642, 72341259464802561, 9259542123257036928, 4629771063767613504, 2314885534022901792, 1157442769150545936, 578721386714368008, 289360695496279044, 144680349887234562, 72340177082712321, 9259542123273748608, 4629771061645230144, 2314885530830970912, 1157442765423841296, 578721382720276488, 289360691368494084, 144680345692602882, 72340172854657281, 9259542123273813888, 4629771061636939584, 2314885530818502432, 1157442765409283856, 578721382704674568, 289360691352369924, 144680345676217602, 72340172838141441, 9259542123273814143, 4629771061636907199, 2314885530818453727, 1157442765409226991, 578721382704613623, 289360691352306939, 144680345676153597, 72340172838076926} +func lineIndexFromMaskedOccupancy(occupied bitboard, line int, sq Square) uint8 { + lineOccupied := occupied & lineMasks[line][sq] + var idx uint8 + for lineOccupied != 0 { + bit := lineOccupied & -lineOccupied + idx |= lineBitIndexes[line][sq][bits.TrailingZeros64(uint64(bit))] + lineOccupied &= lineOccupied - 1 + } + return idx +} - bbQueenMoves = [64]bitboard{9205534180971414145, 13826139127340482880, 16100553540994408480, 17237620560088797200, 17806153522019305480, 18090419998706369540, 18232552689433215490, 18303478847064064385, 13871017173176583298, 16194909420462031425, 8133343319517438240, 4102559721436811280, 2087167920257370120, 1079472019650937860, 575624067208594050, 287670746360127809, 11583398706901190788, 5827868887957914690, 12137446670713758241, 6068863523097809168, 3034571949281478664, 1517426162373248132, 722824471891812930, 361411684042608929, 10421541192660455560, 5210911883574396996, 2641485286422881314, 10544115227674579473, 5272058161445620104, 2600000831312176196, 1299860225776030242, 649930110732142865, 9840541934442029200, 4920271519124312136, 2460276499189639204, 1266167048752878738, 9820426766351346249, 4910072647826412836, 2455035776296487442, 1227517888139822345, 9550042029937901728, 4775021017124823120, 2387511058326581416, 1157867469641037908, 614821794359483434, 9530782384287059477, 4765391190004401930, 2382695595002168069, 9404792076610076608, 4702396038313459680, 2315169224285282160, 1157444424410132280, 578862399937640220, 325459994840333070, 9386102034266586375, 4693051017133293059, 9332167099941961855, 4630054752952049855, 2314886638996058335, 1157442771889699055, 578721933553179895, 289501704256556795, 180779649147209725, 9313761861428380670} +func initSlidingLines(sq Square) { + f := int(sq.File()) + r := int(sq.Rank()) - bbKingMoves = [64]bitboard{4665729213955833856, 11592265440851656704, 5796132720425828352, 2898066360212914176, 1449033180106457088, 724516590053228544, 362258295026614272, 144959613005987840, 13853283560024178688, 16186183351374184448, 8093091675687092224, 4046545837843546112, 2023272918921773056, 1011636459460886528, 505818229730443264, 216739030602088448, 54114388906344448, 63227278716305408, 31613639358152704, 15806819679076352, 7903409839538176, 3951704919769088, 1975852459884544, 846636838289408, 211384331665408, 246981557485568, 123490778742784, 61745389371392, 30872694685696, 15436347342848, 7718173671424, 3307175149568, 825720045568, 964771708928, 482385854464, 241192927232, 120596463616, 60298231808, 30149115904, 12918652928, 3225468928, 3768639488, 1884319744, 942159872, 471079936, 235539968, 117769984, 50463488, 12599488, 14721248, 7360624, 3680312, 1840156, 920078, 460039, 197123, 49216, 57504, 28752, 14376, 7188, 3594, 1797, 770} + for file := range numOfSquaresInRow { + appendLineSquare(slideRank, sq, NewSquare(File(file), Rank(r))) + } + for rank := range numOfSquaresInRow { + appendLineSquare(slideFile, sq, NewSquare(File(f), Rank(rank))) + } - bbSquares = [64]bitboard{} -) + startF, startR := f, r + for startF > 0 && startR > 0 { + startF-- + startR-- + } + for startF < 8 && startR < 8 { + appendLineSquare(slideDiag, sq, NewSquare(File(startF), Rank(startR))) + startF++ + startR++ + } -// init populates the bbSquares lookup table. This is done at package -// initialization because the values are constants derived from square indices. -// -//nolint:gochecknoinits // Required for lookup table initialization. -func init() { - const numOfSquaresInBoard = 64 - for sq := range numOfSquaresInBoard { - bbSquares[sq] = bitboard(uint64(1) << (uint8(63) - uint8(sq))) + startF, startR = f, r + for startF > 0 && startR < 7 { + startF-- + startR++ + } + for startF < 8 && startR >= 0 { + appendLineSquare(slideAntiDiag, sq, NewSquare(File(startF), Rank(startR))) + startF++ + startR-- + } +} + +func appendLineSquare(line int, src Square, sq Square) { + idx := lineLens[line][src] + lineSquares[line][src][idx] = sq + bb := bbForSquare(sq) + lineBitboards[line][src][idx] = bb + lineMasks[line][src] |= bb + lineBitIndexes[line][src][bits.TrailingZeros64(uint64(bb))] = 1 << idx + lineLens[line][src]++ +} + +func attackOnLine(line int, src Square, idx uint8) bitboard { + var srcIdx int + for i := range lineLens[line][src] { + if lineSquares[line][src][i] == src { + srcIdx = i + break + } + } + + var attacks bitboard + for i := srcIdx + 1; i < lineLens[line][src]; i++ { + sq := lineSquares[line][src][i] + attacks |= bbForSquare(sq) + if idx&(1<= 0; i-- { + sq := lineSquares[line][src][i] + attacks |= bbForSquare(sq) + if idx&(1< c.maxDepth { + break + } + got := pos.Perft(depth + 1) + if got != want { + t.Errorf("depth %d: got %d, want %d", depth+1, got, want) + } + } + }) + } +} + +func TestPerftBaseCases(t *testing.T) { + t.Run("depth_zero_is_one", func(t *testing.T) { + pos := chess.NewGame().Position() + if got := pos.Perft(0); got != 1 { + t.Errorf("Perft(0) = %d, want 1", got) + } + }) + + t.Run("depth_one_equals_legal_moves", func(t *testing.T) { + pos := chess.NewGame().Position() + if got, want := pos.Perft(1), uint64(len(pos.ValidMoves())); got != want { + t.Errorf("Perft(1) = %d, want %d", got, want) + } + }) + + t.Run("terminal_position_returns_zero", func(t *testing.T) { + // Fool's mate: 1.f3 e5 2.g4 Qh4# -- black just delivered mate + opt, err := chess.FEN("rnbqkbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 2") + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + pos := chess.NewGame(opt).Position() + if got := pos.Perft(1); got != 0 { + t.Errorf("Perft(1) on checkmate position = %d, want 0", got) + } + }) + + t.Run("nil_position_is_zero", func(t *testing.T) { + var pos *chess.Position + if got := pos.Perft(3); got != 0 { + t.Errorf("Perft(3) on nil = %d, want 0", got) + } + }) +} + +func TestPerftRuleRegressions(t *testing.T) { + tests := []struct { + name string + fen string + nodes []uint64 + }{ + { + name: "castling_rights", + fen: "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", + nodes: []uint64{26, 568}, + }, + { + name: "en_passant", + fen: "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3", + nodes: []uint64{31, 807}, + }, + { + name: "promotion", + fen: "4k3/P7/8/8/8/8/8/4K3 w - - 0 1", + nodes: []uint64{9, 41}, + }, + { + name: "pinned_piece", + fen: "4k3/8/8/8/8/8/4R3/4K2r w - - 0 1", + nodes: []uint64{2, 8}, + }, + { + name: "single_check", + fen: "4k3/8/8/8/8/8/4r3/4K3 w - - 0 1", + nodes: []uint64{3, 41}, + }, + { + name: "double_check", + fen: "4k3/8/8/8/8/3b4/4r3/4K3 w - - 0 1", + nodes: []uint64{2, 54}, + }, + { + name: "checkmate", + fen: "rnbqkbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 2", + nodes: []uint64{0, 0}, + }, + { + name: "stalemate", + fen: "7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", + nodes: []uint64{0, 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt, err := chess.FEN(tt.fen) + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + pos := chess.NewGame(opt).Position() + for depth, want := range tt.nodes { + if got := pos.Perft(depth + 1); got != want { + t.Errorf("Perft(%d) = %d, want %d", depth+1, got, want) + } + } + }) + } +} + +func TestDivide(t *testing.T) { + opt, err := chess.FEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + pos := chess.NewGame(opt).Position() + got := pos.Divide(1) + if len(got) != 20 { + t.Fatalf("Divide(1) returned %d moves, want 20", len(got)) + } + for _, count := range got { + if count != 1 { + t.Errorf("Divide(1) per-move count = %d, want 1", count) + } + } + + // depth 1 sum must equal Perft(1) + var sum uint64 + for _, v := range got { + sum += v + } + if sum != pos.Perft(1) { + t.Errorf("sum(Divide(1)) = %d, want %d", sum, pos.Perft(1)) + } + + // depth 2: known total 400 + depth2 := pos.Divide(2) + var sum2 uint64 + for _, v := range depth2 { + sum2 += v + } + if sum2 != 400 { + t.Errorf("sum(Divide(2)) = %d, want 400", sum2) + } +} + +func TestDivideStable(t *testing.T) { + // Verify the map is well-defined (not a pointer map): two equal moves + // coming from a second call produce the same key. + pos := chess.NewGame().Position() + first := pos.Divide(1) + second := pos.Divide(1) + if len(first) != len(second) { + t.Fatalf("Divide(1) non-deterministic size: %d vs %d", len(first), len(second)) + } + keys := make([]string, 0, len(first)) + for k := range first { + keys = append(keys, k.String()) + } + sort.Strings(keys) + for i := 1; i < len(keys); i++ { + if keys[i] == keys[i-1] { + t.Errorf("Divide(1) returned duplicate move key %q", keys[i]) + } + } +} + +func TestPerftAndDivideDoNotChangePosition(t *testing.T) { + tests := []struct { + name string + fen string + }{ + { + name: "startpos", + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + }, + { + name: "castling_and_en_passant", + fen: "r3k2r/pbppqpb1/1pn3p1/7p/1N2pPn1/1PP4N/PB1P2PP/2QRKR2 b kq f3 0 1", + }, + { + name: "promotion", + fen: "4k3/P7/8/8/8/8/8/4K3 w - - 0 1", + }, + { + name: "check_giving_moves", + fen: "4k3/8/8/8/4N3/8/8/4R1K1 w - - 0 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := positionFromFEN(t, tt.fen) + beforeFEN := pos.String() + beforeMoves := moveStrings(pos.ValidMoves()) + + _ = pos.Perft(3) + if got := pos.String(); got != beforeFEN { + t.Fatalf("Perft mutated position: got %q, want %q", got, beforeFEN) + } + if got := moveStrings(pos.ValidMoves()); !equalStrings(got, beforeMoves) { + t.Fatalf("Perft changed legal moves: got %v, want %v", got, beforeMoves) + } + + _ = pos.Divide(3) + if got := pos.String(); got != beforeFEN { + t.Fatalf("Divide mutated position: got %q, want %q", got, beforeFEN) + } + if got := moveStrings(pos.ValidMoves()); !equalStrings(got, beforeMoves) { + t.Fatalf("Divide changed legal moves: got %v, want %v", got, beforeMoves) + } + }) + } +} + +func moveStrings(moves []chess.Move) []string { + out := make([]string, 0, len(moves)) + for _, move := range moves { + out = append(out, move.String()) + } + sort.Strings(out) + return out +} + +func equalStrings(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pgn.go b/pgn.go index c1624ab8..41eccb61 100644 --- a/pgn.go +++ b/pgn.go @@ -6,7 +6,7 @@ Example usage: // Create parser from tokens tokens := TokenizeGame(game) - parser := NewParser(tokens) + parser := newParser(tokens) // Parse complete game game, err := parser.Parse() @@ -15,53 +15,104 @@ package chess import ( "errors" - "fmt" "strconv" + "strings" ) // Parser holds the state needed during parsing. type Parser struct { - game *Game - currentMove *Move - tokens []Token - errors []ParserError - position int + game *Game + tokens pgnTokenSource + moveText MoveTextCodec + token Token + initErr error + errors []ParserError + position int + tagOutcome Outcome + tokenOutcome Outcome } -// NewParser creates a new parser instance initialized with the given tokens. +type pgnTokenSource interface { + NextToken() (Token, error) +} + +type sliceTokenSource struct { + tokens []Token + pos int +} + +func (s *sliceTokenSource) NextToken() (Token, error) { + if s.pos >= len(s.tokens) { + return Token{Type: EOF}, nil + } + token := s.tokens[s.pos] + s.pos++ + return token, nil +} + +// newParser creates a new parser instance initialized with the given tokens. // The parser starts with a root move containing the starting position. // // Example: // // tokens := TokenizeGame(game) -// parser := NewParser(tokens) -func NewParser(tokens []Token) *Parser { - rootMove := &Move{ - position: StartingPosition(), +// parser := newParser(tokens) +func newParser(tokens []Token) *Parser { + return newParserFromSource(&sliceTokenSource{tokens: tokens}, defaultPGNOptions()) +} + +func newParserFromSource(tokens pgnTokenSource, opts ...pgnOptions) *Parser { + options := defaultPGNOptions() + if len(opts) > 0 { + options = opts[0] } - return &Parser{ + pos := StartingPosition() + tree := newMoveTree(pos) + parser := &Parser{ tokens: tokens, game: &Game{ - tagPairs: make(TagPairs), - pos: StartingPosition(), - rootMove: rootMove, // Empty root move - currentMove: rootMove, + tagPairs: make(TagPairs), + tree: tree, + outcome: NoOutcome, + method: NoMethod, }, - currentMove: rootMove, + moveText: options.moveTextCodec, } + token, err := tokens.NextToken() + if err != nil { + parser.initErr = err + parser.token = Token{Type: Undefined, Value: err.Error()} + return parser + } + parser.token = token + return parser } // currentToken returns the current token being processed. func (p *Parser) currentToken() Token { - if p.position >= len(p.tokens) { - return Token{Type: EOF} - } - return p.tokens[p.position] + return p.token } // advance moves to the next token. func (p *Parser) advance() { p.position++ + token, err := p.tokens.NextToken() + if err != nil { + p.token = Token{Type: Undefined, Value: err.Error()} + return + } + p.token = token +} + +func (p *Parser) atEnd() bool { + return p.currentToken().Type == EOF +} + +func (p *Parser) currentMove() *MoveNode { + if p == nil || p.game == nil || p.game.tree == nil { + return nil + } + return p.game.tree.Current() } // Parse processes all tokens and returns the complete game. @@ -78,34 +129,94 @@ func (p *Parser) advance() { // } // fmt.Printf("Event: %s\n", game.GetTagPair("Event")) func (p *Parser) Parse() (*Game, error) { + if p.initErr != nil { + return nil, p.initErr + } + // Parse header section (tag pairs) if err := p.parseHeader(); err != nil { - return nil, errors.New("parsing header") + return nil, errors.New("chess: parsing header") } + p.tagOutcome = outcomeFromResultString(p.game.tagPairs["Result"]) + // check if the game has a starting position if value, ok := p.game.tagPairs["FEN"]; ok { pos, err := decodeFEN(value) if err != nil { - return nil, errors.New("invalid FEN") + return nil, errors.New("chess: invalid FEN") } - p.game.rootMove.position = pos - p.game.pos = pos + p.game.tree.setRootPosition(pos) } // Parse moves section if err := p.parseMoveText(); err != nil { return nil, err } + p.game.evaluateTerminalPositionStatus() - if p.game.outcome == UnknownOutcome { - p.game.outcome = NoOutcome + if err := p.resolveOutcome(); err != nil { + return nil, err } - p.game.currentMove = p.currentMove - return p.game, nil } +func (p *Parser) resolveOutcome() error { + boardMethod := p.game.method + boardOutcome := p.game.outcome + tagOutcome := normalizeOutcome(p.tagOutcome) + tokenOutcome := normalizeOutcome(p.tokenOutcome) + + boardTerminal := boardMethod == Checkmate || boardMethod == Stalemate + + if boardTerminal { + if tokenOutcome != NoOutcome && tokenOutcome != boardOutcome { + return &ParserError{ + Message: "movetext result token conflicts with board-derivable outcome", + Position: p.position, + } + } + if tagOutcome != NoOutcome && tagOutcome != boardOutcome { + return &ParserError{ + Message: "Result tag conflicts with board-derivable outcome", + Position: p.position, + } + } + p.game.outcome = boardOutcome + p.game.method = boardMethod + return nil + } + + if tokenOutcome != NoOutcome { + if tagOutcome != NoOutcome && tagOutcome != tokenOutcome { + return &ParserError{ + Message: "movetext result token conflicts with Result tag", + Position: p.position, + } + } + p.game.outcome = tokenOutcome + p.game.method = NoMethod + return nil + } + + if tagOutcome != NoOutcome { + p.game.outcome = tagOutcome + p.game.method = NoMethod + return nil + } + + p.game.outcome = NoOutcome + p.game.method = NoMethod + return nil +} + +func normalizeOutcome(o Outcome) Outcome { + if o == UnknownOutcome { + return NoOutcome + } + return o +} + func (p *Parser) parseHeader() error { for p.currentToken().Type == TagStart { if err := p.parseTagPair(); err != nil { @@ -170,13 +281,13 @@ func (p *Parser) parseTagPair() error { func (p *Parser) parseMoveText() error { var moveNumber uint64 ply := 1 - for p.position < len(p.tokens) { + for !p.atEnd() { token := p.currentToken() switch token.Type { case MoveNumber: number, err := strconv.ParseUint(token.Value, 10, 32) - if err == nil && p.currentMove != nil { + if err == nil && p.currentMove() != nil { moveNumber = number ply = int((moveNumber-1)*2 + 1) } @@ -189,36 +300,22 @@ func (p *Parser) parseMoveText() error { p.advance() ply++ + case NullMove: + p.addMove(NewNullMove(), uint(moveNumber)) + p.advance() + ply++ + case PIECE, SQUARE, FILE, KingsideCastle, QueensideCastle: move, err := p.parseMove() if err != nil { return err } - if moveNumber > 0 { - move.number = uint(moveNumber) - } - p.addMove(move) + p.addMove(move, uint(moveNumber)) ply++ // Collect all NAGs and comments that follow the move - collectLoop: - for { - tok := p.currentToken() - switch tok.Type { - case NAG: - p.currentMove.nag = tok.Value - p.advance() - case CommentStart: - block, err := p.parseComment() - if err != nil { - return err - } - if p.currentMove != nil { - p.currentMove.addCommentBlock(block) - } - default: - break collectLoop - } + if err = p.collectMoveAnnotations(); err != nil { + return err } case CommentStart: @@ -226,8 +323,8 @@ func (p *Parser) parseMoveText() error { if err != nil { return err } - if p.currentMove != nil { - p.currentMove.addCommentBlock(block) + if current := p.currentMove(); current != nil { + current.addCommentBlock(block) } case VariationStart: @@ -247,24 +344,26 @@ func (p *Parser) parseMoveText() error { } // parseMove processes tokens until it has a complete move, then validates against legal moves. -func (p *Parser) parseMove() (*Move, error) { - move := &Move{} +func (p *Parser) parseMove() (Move, error) { + move := Move{} // Handle castling first as it's a special case if p.currentToken().Type == KingsideCastle { move.tags = KingSideCastle - for _, m := range p.game.pos.ValidMovesUnsafe() { + var castles [2]Move + count := castleMovesInto(p.game.currentPosition(), &castles, generateLegalAnnotated) + for _, m := range castles[:count] { if m.HasTag(KingSideCastle) { move.s1 = m.S1() move.s2 = m.S2() if m.HasTag(Check) { - move.AddTag(Check) + move = move.WithTag(Check) } p.advance() return move, nil } } - return nil, &ParserError{ + return Move{}, &ParserError{ Message: "illegal kingside castle", TokenType: p.currentToken().Type, TokenValue: p.currentToken().Value, @@ -274,19 +373,20 @@ func (p *Parser) parseMove() (*Move, error) { if p.currentToken().Type == QueensideCastle { move.tags = QueenSideCastle - for _, m := range p.game.pos.ValidMovesUnsafe() { + var castles [2]Move + count := castleMovesInto(p.game.currentPosition(), &castles, generateLegalAnnotated) + for _, m := range castles[:count] { if m.HasTag(QueenSideCastle) { move.s1 = m.S1() move.s2 = m.S2() - move.position = p.game.pos if m.HasTag(Check) { - move.AddTag(Check) + move = move.WithTag(Check) } p.advance() return move, nil } } - return nil, &ParserError{ + return Move{}, &ParserError{ Message: "illegal queenside castle", TokenType: p.currentToken().Type, TokenValue: p.currentToken().Value, @@ -311,13 +411,14 @@ func (p *Parser) parseMove() (*Move, error) { p.advance() // Check for disambiguation - if p.currentToken().Type == FILE { + switch p.currentToken().Type { + case FILE: moveData.originFile = p.currentToken().Value p.advance() - } else if p.currentToken().Type == RANK { + case RANK: moveData.originRank = p.currentToken().Value p.advance() - } else if p.currentToken().Type == DeambiguationSquare { + case DeambiguationSquare: // Full square disambiguation (e.g., "Qe8f7" -> piece: Q, origin: e8, dest: f7) originSquare := p.currentToken().Value if len(originSquare) == 2 { @@ -330,7 +431,6 @@ func (p *Parser) parseMove() (*Move, error) { case FILE: moveData.originFile = p.currentToken().Value p.advance() - } // Handle capture @@ -341,7 +441,7 @@ func (p *Parser) parseMove() (*Move, error) { // Get destination square if p.currentToken().Type != SQUARE { - return nil, &ParserError{ + return Move{}, &ParserError{ Message: "expected destination square", TokenType: p.currentToken().Type, TokenValue: p.currentToken().Value, @@ -351,11 +451,24 @@ func (p *Parser) parseMove() (*Move, error) { moveData.destSquare = p.currentToken().Value p.advance() + // Get target square before promotion handling so import-only promotion + // spellings such as e8Q can be recognised without consuming ordinary + // piece tokens after non-promotion moves. + targetSquare := parseSquare(moveData.destSquare) + if targetSquare == NoSquare { + return Move{}, &ParserError{ + Message: "invalid destination square", + TokenType: p.currentToken().Type, + TokenValue: p.currentToken().Value, + Position: p.position, + } + } + // Handle promotion if p.currentToken().Type == PROMOTION { p.advance() if p.currentToken().Type != PromotionPiece { - return nil, &ParserError{ + return Move{}, &ParserError{ Message: "expected promotion piece", TokenType: p.currentToken().Type, TokenValue: p.currentToken().Value, @@ -365,104 +478,44 @@ func (p *Parser) parseMove() (*Move, error) { moveData.promotion = parsePieceType(p.currentToken().Value) p.advance() } - - // Get target square - targetSquare := parseSquare(moveData.destSquare) - if targetSquare == NoSquare { - return nil, &ParserError{ - Message: "invalid destination square", - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, + if p.moveText.Policy() == MoveTextPolicyPGNImport && + moveData.promotion == NoPieceType && + moveData.piece == "" && + p.currentToken().Type == PIECE && + isPromotionDestination(targetSquare) { + promo := parsePieceType(p.currentToken().Value) + switch promo { + case Queen, Rook, Bishop, Knight: + moveData.promotion = promo + p.advance() } } - // Find matching legal move - var matchingMove *Move - var err error - validMoves := p.game.pos.ValidMovesUnsafe() - for _, m := range validMoves { - //nolint:nestif // readability - if m.S2() == targetSquare { - pos := p.game.pos - piece := pos.Board().Piece(m.S1()) - - // Check piece type - if moveData.piece != "" && piece.Type() != PieceTypeFromString(moveData.piece) || moveData.piece == "" && piece.Type() != Pawn { - err = &ParserError{ - Message: "piece type mismatch", - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, - } - continue - } - - // Check disambiguation - if moveData.originFile != "" && m.S1().File().String() != moveData.originFile { - err = &ParserError{ - Message: "origin file mismatch", - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, - } - continue - } - if moveData.originRank != "" && strconv.Itoa(int((m.S1()/8)+1)) != moveData.originRank { - err = &ParserError{ - Message: fmt.Sprintf("origin rank mismatch: %d", m.S1()/8+1), - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, - } - continue - } - - // Check capture - if moveData.isCapture != (m.HasTag(Capture) || m.HasTag(EnPassant)) { - err = &ParserError{ - Message: "capture mismatch", - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, - } - continue - } - - // Check promotion - if moveData.promotion != NoPieceType && m.promo != moveData.promotion { - err = &ParserError{ - Message: "promotion mismatch", - TokenType: p.currentToken().Type, - TokenValue: p.currentToken().Value, - Position: p.position, - } - continue - } - - matchingMove = &m - break - } + movePieceType := Pawn + if moveData.piece != "" { + movePieceType = PieceTypeFromString(moveData.piece) } - if matchingMove == nil { - if err != nil { - return nil, &ParserError{ - Message: fmt.Sprintf("no legal move found for position: %s", err.Error()), - Position: p.position, - } - } - return nil, &ParserError{ - Message: "no legal move found for position", + matched, err := resolveSANMove(p.game.currentPosition(), sanMoveData{ + piece: movePieceType, + originFile: moveData.originFile, + originRank: moveData.originRank, + dest: targetSquare, + capture: moveData.isCapture, + promotion: moveData.promotion, + canonical: p.moveText.Policy() == MoveTextPolicyStrict, + }) + if err != nil { + return Move{}, &ParserError{ + Message: strings.TrimPrefix(err.Error(), "chess: "), Position: p.position, } } - // Copy the matched move details - move.s1 = matchingMove.S1() - move.s2 = matchingMove.S2() - move.tags = matchingMove.tags - move.promo = matchingMove.promo + move.s1 = matched.S1() + move.s2 = matched.S2() + move.tags = matched.tags + move.promo = matched.promo // Handle check/checkmate if present if p.currentToken().Type == CHECK { @@ -470,28 +523,19 @@ func (p *Parser) parseMove() (*Move, error) { p.advance() } - // Handle NAG if present - if p.currentToken().Type == NAG { - move.nag = p.currentToken().Value - p.advance() - } - - // Set move number for both white and black moves - if p.game.pos != nil && p.game.pos.Turn() == Black { - if parentMoveNum := p.currentMove.number; parentMoveNum > 0 { - move.number = parentMoveNum - } - } - return move, nil } +func isPromotionDestination(s Square) bool { + return s.Rank() == Rank1 || s.Rank() == Rank8 +} + func (p *Parser) parseComment() (CommentBlock, error) { p.advance() // Consume "{" block := CommentBlock{} - for p.currentToken().Type != CommentEnd && p.position < len(p.tokens) { + for p.currentToken().Type != CommentEnd && !p.atEnd() { switch p.currentToken().Type { case CommandStart: command, err := p.parseCommand() @@ -513,7 +557,7 @@ func (p *Parser) parseComment() (CommentBlock, error) { p.advance() } - if p.position >= len(p.tokens) { + if p.atEnd() { return CommentBlock{}, &ParserError{ Message: "unterminated comment", Position: p.position, @@ -531,10 +575,17 @@ func (p *Parser) parseCommand() (CommentItem, error) { // Consume the opening "[" p.advance() - for p.currentToken().Type != CommandEnd && p.position < len(p.tokens) { + for p.currentToken().Type != CommandEnd && !p.atEnd() { switch p.currentToken().Type { - case CommandName: + if key != "" { + return CommentItem{}, &ParserError{ + Message: "duplicate command name in command", + Position: p.position, + TokenType: p.currentToken().Type, + TokenValue: p.currentToken().Value, + } + } // The first token in a command is treated as the key key = p.currentToken().Value case CommandParam: @@ -553,46 +604,68 @@ func (p *Parser) parseCommand() (CommentItem, error) { p.advance() } - if p.position >= len(p.tokens) { + if p.atEnd() { return CommentItem{}, &ParserError{ Message: "unterminated command", Position: p.position, } } - // p.advance() // Consume the closing "]" return CommentItem{Kind: CommentCommand, Key: key, Value: value}, nil } +// collectMoveAnnotations consumes all NAGs and comments immediately following +// the current move, attaching them to the current move node. +func (p *Parser) collectMoveAnnotations() error { + for { + tok := p.currentToken() + switch tok.Type { + case NAG: + if nagErr := p.currentMove().AddNAG(tok.Value); nagErr != nil { + return &ParserError{ + Message: nagErr.Error(), + TokenValue: tok.Value, + TokenType: NAG, + Position: p.position, + } + } + p.advance() + case CommentStart: + block, err := p.parseComment() + if err != nil { + return err + } + if current := p.currentMove(); current != nil { + current.addCommentBlock(block) + } + default: + return nil + } + } +} + func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { p.advance() // consume ( // Save current state to restore later - parentMove := p.currentMove - oldPos := p.game.pos + parentMove := p.currentMove() + oldCurrent := p.game.tree.Current() // For variations at game start, we attach to root - variationParent := p.game.rootMove + variationParent := p.game.tree.Root() - // Find the move this variation should branch from - if parentMove != p.game.rootMove && parentMove.parent != nil { + // Find the move this variation should diverge from + if parentMove != p.game.tree.Root() && parentMove.parent != nil { variationParent = parentMove.parent - if variationParent.parent != nil && variationParent.parent.position != nil { - p.game.pos = variationParent.parent.position.Update(variationParent) - } else { - p.game.pos = p.game.rootMove.position.copy() - } - } else { - p.game.pos = p.game.rootMove.position.copy() } - p.currentMove = variationParent + p.game.tree.setCurrent(variationParent) moveNumber := parentMoveNumber ply := parentPly isBlackMove := false - for p.currentToken().Type != VariationEnd && p.position < len(p.tokens) { + for p.currentToken().Type != VariationEnd && !p.atEnd() { switch p.currentToken().Type { case MoveNumber: num, err := strconv.ParseUint(p.currentToken().Value, 10, 32) @@ -611,6 +684,12 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { isBlackMove = true ply++ + case NullMove: + p.addMove(NewNullMove(), uint(moveNumber)) + p.advance() + ply++ + isBlackMove = !isBlackMove + case VariationStart: if err := p.parseVariation(moveNumber, ply); err != nil { return err @@ -621,16 +700,23 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { if err != nil { return err } - if p.currentMove != nil { - p.currentMove.addCommentBlock(block) + if current := p.currentMove(); current != nil { + current.addCommentBlock(block) } case NAG: - p.currentMove.nag = p.currentToken().Value + if nagErr := p.currentMove().AddNAG(p.currentToken().Value); nagErr != nil { + return &ParserError{ + Message: nagErr.Error(), + TokenValue: p.currentToken().Value, + TokenType: NAG, + Position: p.position, + } + } p.advance() case PIECE, SQUARE, FILE, KingsideCastle, QueensideCastle: - if isBlackMove != (p.game.pos.Turn() == Black) { + if isBlackMove != (p.game.currentPosition().Turn() == Black) { return &ParserError{ Message: "move color mismatch", Position: p.position, @@ -642,36 +728,13 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { return err } - move.parent = p.currentMove - p.currentMove.children = append(p.currentMove.children, move) - move.number = uint(moveNumber) - - p.game.pos = p.game.pos.Update(move) - - move.position = p.game.pos - p.currentMove = move + p.addMove(move, uint(moveNumber)) ply++ isBlackMove = !isBlackMove // Collect all NAGs and comments that follow the move - collectVariationAnnotations: - for { - tok := p.currentToken() - switch tok.Type { - case NAG: - p.currentMove.nag = tok.Value - p.advance() - case CommentStart: - block, err := p.parseComment() - if err != nil { - return err - } - if p.currentMove != nil { - p.currentMove.addCommentBlock(block) - } - default: - break collectVariationAnnotations - } + if err = p.collectMoveAnnotations(); err != nil { + return err } default: @@ -679,7 +742,7 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { } } - if p.position >= len(p.tokens) { + if p.atEnd() { return &ParserError{ Message: "unterminated variation", Position: p.position, @@ -688,49 +751,40 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { p.advance() // consume ) - p.game.pos = oldPos - p.currentMove = parentMove - p.game.currentMove = p.currentMove + p.game.tree.setCurrent(oldCurrent) return nil } func (p *Parser) parseResult() { - result := p.currentToken().Value - switch result { - case "1-0": - p.game.outcome = WhiteWon - case "0-1": - p.game.outcome = BlackWon - case "1/2-1/2": - p.game.outcome = Draw - default: - p.game.outcome = NoOutcome - } + p.tokenOutcome = outcomeFromResultString(p.currentToken().Value) p.advance() } -func (p *Parser) addMove(move *Move) { - // For the first move in the game - if p.currentMove == p.game.rootMove { - move.parent = p.game.rootMove - p.game.rootMove.children = append(p.game.rootMove.children, move) - } else { - // Normal move in the main line - move.parent = p.currentMove - p.currentMove.children = append(p.currentMove.children, move) +func outcomeFromResultString(s string) Outcome { + switch s { + case string(WhiteWon): + return WhiteWon + case string(BlackWon): + return BlackWon + case string(Draw): + return Draw + default: + return NoOutcome } +} + +func (p *Parser) addMove(move Move, number uint) { + parent := p.currentMove() + node := &MoveNode{move: move, parent: parent, number: number} + parent.children = append(parent.children, node) // Update position - if newPos := p.game.pos.Update(move); newPos != nil { - p.game.pos = newPos - p.game.evaluatePositionStatus() + if newPos := p.game.currentPosition().Update(move); newPos != nil { + node.position = newPos } - // Cache position after the move - move.position = p.game.pos - - p.currentMove = move + p.game.tree.setCurrent(node) } // parsePieceType converts a piece character into a PieceType. @@ -755,18 +809,8 @@ func parsePieceType(s string) PieceType { // parseSquare converts a square name (e.g., "e4") into a Square. func parseSquare(s string) Square { - const squareLen = 2 - if len(s) != squareLen { + if len(s) != 2 { return NoSquare } - - file := int(s[0] - 'a') - rank := int(s[1] - '1') - - // Validate file and rank are within bounds - if file < 0 || file > 7 || rank < 0 || rank > 7 { - return NoSquare - } - - return Square(rank*8 + file) + return squareFromFileRank(s[0], s[1]) } diff --git a/pgn_decoder.go b/pgn_decoder.go new file mode 100644 index 00000000..ed7ca42f --- /dev/null +++ b/pgn_decoder.go @@ -0,0 +1,458 @@ +package chess + +import ( + "context" + "errors" + "io" + "iter" + "runtime" + "strings" + "sync" +) + +// PGNOption configures PGN decoding. +type PGNOption func(*pgnOptions) + +type pgnOptions struct { + bufferSize int + expandVariations bool + moveTextCodec MoveTextCodec +} + +// WithPGNExpandVariations expands each Game's Variations into separate Games. +func WithPGNExpandVariations() PGNOption { + return func(o *pgnOptions) { + o.expandVariations = true + } +} + +// WithPGNBufferSize configures the reader buffer size used for PGN record framing. +func WithPGNBufferSize(n int) PGNOption { + return func(o *pgnOptions) { + if n > 0 { + o.bufferSize = n + } + } +} + +// WithStrictSAN requires PGN movetext to use strict canonical SAN. +func WithStrictSAN() PGNOption { + return func(o *pgnOptions) { + o.moveTextCodec = SAN() + } +} + +// WithImportSAN accepts documented PGN import SAN spellings. +func WithImportSAN() PGNOption { + return func(o *pgnOptions) { + o.moveTextCodec = PGNImportSAN() + } +} + +// PGNDecoder streams Games from a PGN reader. +type PGNDecoder struct { + framer *pgnFramer + options []PGNOption + parsedGames []*Game + index int64 + offset int64 +} + +// NewPGNDecoder creates a decoder that reads Games from r. +func NewPGNDecoder(r io.Reader, opts ...PGNOption) *PGNDecoder { + return &PGNDecoder{framer: newPGNFramer(r, opts...), options: opts} +} + +// Decode returns the next Game from the PGN stream. +func (d *PGNDecoder) Decode() (*Game, error) { + if len(d.parsedGames) > 0 { + game := d.parsedGames[0] + d.parsedGames = d.parsedGames[1:] + d.index++ + return game, nil + } + + record, err := d.framer.next() + if err != nil { + return nil, err + } + d.index++ + d.offset = record.Offset + + options := applyPGNOptions(d.options) + + game, err := parsePGNText(record.Raw, options) + if err != nil { + return nil, err + } + + if options.expandVariations { + games := game.Split() + if len(games) == 0 { + return nil, io.EOF + } + d.parsedGames = games[1:] + game = games[0] + } + + return game, nil +} + +// Index returns the number of Games successfully decoded by this decoder. +func (d *PGNDecoder) Index() int64 { + return d.index +} + +// Offset returns the byte offset of the current PGN record when available. +func (d *PGNDecoder) Offset() int64 { + return d.offset +} + +// ParsePGN parses the first Game from r. +func ParsePGN(r io.Reader, opts ...PGNOption) (*Game, error) { + return NewPGNDecoder(r, opts...).Decode() +} + +// PGNGames returns an iterator over Games decoded from r. +func PGNGames(r io.Reader, opts ...PGNOption) iter.Seq2[*Game, error] { + return func(yield func(*Game, error) bool) { + dec := NewPGNDecoder(r, opts...) + for { + game, err := dec.Decode() + if errors.Is(err, io.EOF) { + return + } + if !yield(game, err) || err != nil { + return + } + } + } +} + +// PGNRecord is one complete PGN game record from a stream. +type PGNRecord struct { + Index int64 + Offset int64 + Raw string +} + +// Decode parses this record into a Game. +func (r PGNRecord) Decode(opts ...PGNOption) (*Game, error) { + return parsePGNText(r.Raw, applyPGNOptions(opts)) +} + +// Tags returns the PGN tag pairs in this record without building a Game. +func (r PGNRecord) Tags() (map[string]string, error) { + lex := NewLexer(r.Raw) + tags := make(map[string]string) + for { + tok := lex.NextToken() + if tok.Type == EOF || tok.Type != TagStart { + return tags, nil + } + key := lex.NextToken() + val := lex.NextToken() + end := lex.NextToken() + if end.Type == TagEnd && key.Type == TagKey && val.Type == TagValue { + tags[key.Value] = val.Value + } + } +} + +// PGNRecords returns an iterator over raw PGN records. +func PGNRecords(ctx context.Context, r io.Reader, opts ...PGNOption) iter.Seq2[PGNRecord, error] { + return func(yield func(PGNRecord, error) bool) { + framer := newPGNFramer(r, opts...) + for { + if err := ctx.Err(); err != nil { + yield(PGNRecord{}, err) + return + } + + record, err := framer.next() + if errors.Is(err, io.EOF) { + return + } + if !yield(record, err) || err != nil { + return + } + } + } +} + +// PGNParallelOptions configures parallel PGN Game decoding. +type PGNParallelOptions struct { + Workers int + Buffer int + Ordered bool + Options []PGNOption +} + +// PGNResult is the result of decoding one PGN record. +type PGNResult struct { + Game *Game + Err error + Index int64 + Offset int64 +} + +// DecodePGNGamesParallel decodes PGN records across worker goroutines. +func DecodePGNGamesParallel(ctx context.Context, r io.Reader, opts PGNParallelOptions) <-chan PGNResult { + if opts.Workers <= 0 { + opts.Workers = runtime.GOMAXPROCS(0) + } + if opts.Buffer <= 0 { + opts.Buffer = opts.Workers + } + + out := make(chan PGNResult, opts.Buffer) + jobs := make(chan PGNRecord, opts.Buffer) + results := make(chan PGNResult, opts.Buffer) + + go func() { + defer close(jobs) + for record, err := range PGNRecords(ctx, r, opts.Options...) { + if err != nil { + results <- PGNResult{Err: err} + return + } + select { + case jobs <- record: + case <-ctx.Done(): + results <- PGNResult{Err: ctx.Err()} + return + } + } + }() + + var wg sync.WaitGroup + wg.Add(opts.Workers) + for range opts.Workers { + go func() { + defer wg.Done() + for record := range jobs { + game, err := record.Decode(opts.Options...) + result := PGNResult{Game: game, Err: err, Index: record.Index, Offset: record.Offset} + select { + case results <- result: + case <-ctx.Done(): + return + } + } + }() + } + + go func() { + wg.Wait() + close(results) + }() + + go func() { + defer close(out) + if opts.Ordered { + yieldOrderedPGNResults(ctx, out, results) + return + } + for result := range results { + if !sendPGNResult(ctx, out, result) { + return + } + } + }() + + return out +} + +func sendPGNResult(ctx context.Context, out chan<- PGNResult, result PGNResult) bool { + select { + case out <- result: + return true + case <-ctx.Done(): + select { + case out <- PGNResult{Err: ctx.Err()}: + default: + } + return false + } +} + +func yieldOrderedPGNResults(ctx context.Context, out chan<- PGNResult, results <-chan PGNResult) { + // Index zero or negative results (typically record-stream errors from + // the framer goroutine) are emitted immediately without ordering. + // Workers may still be decoding in-flight records when an error arrives; + // their results will be dropped because the results channel closes after + // this function returns. + next := int64(1) + pending := make(map[int64]PGNResult) + for result := range results { + if result.Index <= 0 { + if !sendPGNResult(ctx, out, result) { + return + } + continue + } + pending[result.Index] = result + for { + ready, ok := pending[next] + if !ok { + break + } + delete(pending, next) + if !sendPGNResult(ctx, out, ready) { + return + } + next++ + } + } +} + +// PGNEventKind identifies a semantic PGN event. +type PGNEventKind uint8 + +const ( + PGNEventUnknown PGNEventKind = iota + PGNTag + PGNMove + PGNComment + PGNNAG + PGNVariationStart + PGNVariationEnd + PGNGameEnd +) + +// PGNEvent is one semantic event from a PGN stream. +type PGNEvent struct { + Kind PGNEventKind + Index int64 + Offset int64 + Name string + Value string + Move string + NAG string +} + +// PGNEvents returns an iterator over semantic PGN events without building Games. +func PGNEvents(r io.Reader, opts ...PGNOption) iter.Seq2[PGNEvent, error] { + return func(yield func(PGNEvent, error) bool) { + for record, err := range PGNRecords(context.Background(), r, opts...) { + if err != nil { + yield(PGNEvent{}, err) + return + } + if !yieldPGNRecordEvents(record, yield) { + return + } + } + } +} + +func yieldPGNRecordEvents(record PGNRecord, yield func(PGNEvent, error) bool) bool { + lex := NewLexer(record.Raw) + var pending *Token + for { + var tok Token + if pending != nil { + tok = *pending + pending = nil + } else { + tok = lex.NextToken() + } + if tok.Type == EOF { + return yield(PGNEvent{Kind: PGNGameEnd, Index: record.Index, Offset: record.Offset}, nil) + } + event := PGNEvent{Index: record.Index, Offset: record.Offset} + switch tok.Type { + case TagStart: + key := lex.NextToken() + val := lex.NextToken() + lex.NextToken() // TagEnd + if key.Type == TagKey && val.Type == TagValue { + event.Kind = PGNTag + event.Name = key.Value + event.Value = val.Value + if !yield(event, nil) { + return false + } + } + case CommentStart: + comment := collectPGNComment(lex) + if comment != "" { + event.Kind = PGNComment + event.Value = comment + if !yield(event, nil) { + return false + } + } + case NAG: + event.Kind = PGNNAG + event.NAG = tok.Value + if !yield(event, nil) { + return false + } + case VariationStart: + event.Kind = PGNVariationStart + if !yield(event, nil) { + return false + } + case VariationEnd: + event.Kind = PGNVariationEnd + if !yield(event, nil) { + return false + } + default: + if isPGNMoveToken(tok.Type) { + move, terminator := collectPGNMove(lex, tok) + event.Kind = PGNMove + event.Move = move + if !yield(event, nil) { + return false + } + pending = &terminator + } + } + } +} + +func collectPGNComment(lex *Lexer) string { + var b strings.Builder + lastWasComment := false + for { + tok := lex.NextToken() + switch tok.Type { + case COMMENT: + b.WriteString(tok.Value) + lastWasComment = true + case CommandStart: + if lastWasComment && b.Len() > 0 { + b.WriteByte(' ') + } + lastWasComment = false + case CommentEnd: + return b.String() + case EOF: + return b.String() + } + } +} + +func collectPGNMove(lex *Lexer, first Token) (string, Token) { + var b strings.Builder + b.WriteString(first.Value) + for { + tok := lex.NextToken() + if !isPGNMoveToken(tok.Type) { + return b.String(), tok + } + b.WriteString(tok.Value) + } +} + +func isPGNMoveToken(tokenType TokenType) bool { + switch tokenType { + case PIECE, SQUARE, CAPTURE, FILE, RANK, KingsideCastle, QueensideCastle, + PROMOTION, PromotionPiece, CHECK, CHECKMATE, DeambiguationSquare, NullMove: + return true + default: + return false + } +} diff --git a/pgn_decoder_test.go b/pgn_decoder_test.go new file mode 100644 index 00000000..c9828d76 --- /dev/null +++ b/pgn_decoder_test.go @@ -0,0 +1,125 @@ +package chess_test + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +const decoderGameOne = `[Event "one"] +[Site "?"] +[Date "2026.06.28"] +[Round "?"] +[White "White"] +[Black "Black"] +[Result "*"] + +1. e4 * +` + +const decoderGameTwo = `[Event "two"] +[Site "?"] +[Date "2026.06.28"] +[Round "?"] +[White "White"] +[Black "Black"] +[Result "*"] + +1. d4 * +` + +func TestParsePGNReturnsSingleGame(t *testing.T) { + game, err := chess.ParsePGN(strings.NewReader(decoderGameOne)) + if err != nil { + t.Fatalf("ParsePGN error: %v", err) + } + if got := game.GetTagPair("Event"); got != "one" { + t.Fatalf("Event tag = %q, want %q", got, "one") + } + if got := len(game.Moves()); got != 1 { + t.Fatalf("len(Moves) = %d, want 1", got) + } +} + +func TestPGNDecoderDecodesMultipleGames(t *testing.T) { + dec := chess.NewPGNDecoder(strings.NewReader(decoderGameOne + "\n" + decoderGameTwo)) + + first, err := dec.Decode() + if err != nil { + t.Fatalf("first Decode error: %v", err) + } + if got := first.GetTagPair("Event"); got != "one" { + t.Fatalf("first Event tag = %q, want %q", got, "one") + } + if dec.Index() != 1 { + t.Fatalf("Index after first Decode = %d, want 1", dec.Index()) + } + + second, err := dec.Decode() + if err != nil { + t.Fatalf("second Decode error: %v", err) + } + if got := second.GetTagPair("Event"); got != "two" { + t.Fatalf("second Event tag = %q, want %q", got, "two") + } + if dec.Index() != 2 { + t.Fatalf("Index after second Decode = %d, want 2", dec.Index()) + } + + if _, err := dec.Decode(); !errors.Is(err, io.EOF) { + t.Fatalf("final Decode error = %v, want io.EOF", err) + } +} + +func TestPGNGamesIteratesGamesAndErrors(t *testing.T) { + var events []string + for game, err := range chess.PGNGames(strings.NewReader(decoderGameOne + "\n" + decoderGameTwo)) { + if err != nil { + t.Fatalf("PGNGames yielded error: %v", err) + } + events = append(events, game.GetTagPair("Event")) + } + + if strings.Join(events, ",") != "one,two" { + t.Fatalf("events = %v, want [one two]", events) + } +} + +func TestPGNDecoderEmptyInputReturnsEOF(t *testing.T) { + dec := chess.NewPGNDecoder(strings.NewReader(" \n\t")) + if _, err := dec.Decode(); !errors.Is(err, io.EOF) { + t.Fatalf("Decode error = %v, want io.EOF", err) + } +} + +func TestPGNDecoderExpandsVariations(t *testing.T) { + pgn := `[Event "variations"] +[Site "?"] +[Date "2026.06.28"] +[Round "?"] +[White "White"] +[Black "Black"] +[Result "*"] + +1. e4 (1. d4) * +` + dec := chess.NewPGNDecoder(strings.NewReader(pgn), chess.WithPGNExpandVariations()) + + first, err := dec.Decode() + if err != nil { + t.Fatalf("first Decode error: %v", err) + } + second, err := dec.Decode() + if err != nil { + t.Fatalf("second Decode error: %v", err) + } + if _, err := dec.Decode(); !errors.Is(err, io.EOF) { + t.Fatalf("final Decode error = %v, want io.EOF", err) + } + if len(first.Moves()) != 1 || len(second.Moves()) != 1 { + t.Fatalf("expanded games should each have one move, got %d and %d", len(first.Moves()), len(second.Moves())) + } +} diff --git a/pgn_disambiguation_test.go b/pgn_disambiguation_test.go index 06581031..3c097687 100644 --- a/pgn_disambiguation_test.go +++ b/pgn_disambiguation_test.go @@ -1,153 +1,112 @@ -package chess +package chess_test 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. + "github.com/corentings/chess/v3" +) -func TestPGNDisambiguationSquares(t *testing.T) { +func TestPGN_ParsesDisambiguationGame(t *testing.T) { tests := []struct { - name string - pgn string - shouldFail bool - description string + name string + pgn string + shouldFail bool }{ { - 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)", + 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, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - reader := strings.NewReader(tc.pgn) - scanner := NewScanner(reader) - - game, err := scanner.ParseNext() + game, err := chess.ParsePGN(strings.NewReader(tc.pgn)) 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) + t.Errorf("%s: expected PGN parsing to succeed but got error: %v", tc.name, err) } return } - if tc.shouldFail { - t.Errorf("Expected test case '%s' to fail but it succeeded. Description: %s", tc.name, tc.description) + t.Errorf("%s: expected test case to fail but it succeeded", tc.name) } - - // Additional validation for successful cases - if !tc.shouldFail { - if game == nil { - t.Errorf("Game should not be nil for successful parsing") - } + if game == nil { + t.Errorf("%s: game should not be nil for successful parsing", tc.name) } }) } } -// TestTokenizerDisambiguationSquares tests that the lexer correctly tokenizes disambiguation squares -func TestTokenizerDisambiguationSquares(t *testing.T) { +func TestLexer_FullSquareDisambiguation(t *testing.T) { tests := []struct { name string input string - expected []TokenType + expected []chess.Token }{ - { - 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: "queen_with_full_square_disambiguation_capture", - input: "Qg8xg7", - expected: []TokenType{PIECE, DeambiguationSquare, CAPTURE, SQUARE}, - }, - { - name: "rook_with_full_square_disambiguation_capture", - input: "Ra1xa8", - expected: []TokenType{PIECE, DeambiguationSquare, CAPTURE, SQUARE}, - }, - { - name: "knight_with_full_square_disambiguation_capture", - input: "Nb1xc3", - expected: []TokenType{PIECE, DeambiguationSquare, CAPTURE, 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}, - }, + {"queen_full_square", "Qe8f7", []chess.Token{ + {Type: chess.PIECE, Value: "Q"}, + {Type: chess.DeambiguationSquare, Value: "e8"}, + {Type: chess.SQUARE, Value: "f7"}, + }}, + {"rook_full_square", "Ra1d1", []chess.Token{ + {Type: chess.PIECE, Value: "R"}, + {Type: chess.DeambiguationSquare, Value: "a1"}, + {Type: chess.SQUARE, Value: "d1"}, + }}, + {"knight_full_square", "Nb1c3", []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.DeambiguationSquare, Value: "b1"}, + {Type: chess.SQUARE, Value: "c3"}, + }}, + {"queen_full_square_capture", "Qg8xg7", []chess.Token{ + {Type: chess.PIECE, Value: "Q"}, + {Type: chess.DeambiguationSquare, Value: "g8"}, + {Type: chess.CAPTURE, Value: "x"}, + {Type: chess.SQUARE, Value: "g7"}, + }}, + {"standard_piece_move", "Nf3", []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.SQUARE, Value: "f3"}, + }}, + {"file_disambiguation", "Nbd2", []chess.Token{ + {Type: chess.PIECE, Value: "N"}, + {Type: chess.FILE, Value: "b"}, + {Type: chess.SQUARE, Value: "d2"}, + }}, + {"rank_disambiguation", "R1d2", []chess.Token{ + {Type: chess.PIECE, Value: "R"}, + {Type: chess.RANK, Value: "1"}, + {Type: chess.SQUARE, Value: "d2"}, + }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - lexer := NewLexer(tc.input) - var actualTypes []TokenType - - for { + lexer := chess.NewLexer(tc.input) + for i, expected := range tc.expected { token := lexer.NextToken() - if token.Type == EOF { - break + if token.Type != expected.Type || token.Value != expected.Value { + t.Errorf("Token %d: expected {%v, %q}, got {%v, %q}", + i, expected.Type, expected.Value, token.Type, token.Value) } - 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]) - } + if tok := lexer.NextToken(); tok.Type != chess.EOF { + t.Errorf("expected EOF after tokens, got {%v, %q}", tok.Type, tok.Value) } }) } } -func TestPGNFullSquareDisambiguationCapture(t *testing.T) { +func TestPGN_ParsesFullSquareCapture(t *testing.T) { pgn := `[Event "rated blitz game"] [Site "https://lichess.org/FaQvH6Iq"] [Result "1-0"] 1. c4 Nf6 2. Nc3 Ng8 3. e4 Nf6 4. e5 Ng8 5. d4 e6 6. f4 Ne7 7. Nf3 Ng8 8. d5 Ne7 9. Be3 Nf5 10. Bf2 Ne7 11. Bd3 Ng8 12. O-O Nh6 13. h3 Ng8 14. g4 h5 15. g5 d6 16. Qc2 Qf6 17. exf6 Nc6 18. fxg7 Bd7 19. gxh8=Q O-O-O 20. g6 Bg7 21. gxf7 Kb8 22. fxg8=Q Ka8 23. f5 Rf8 24. f6 Be8 25. f7 Nd8 26. fxe8=Q Kb8 27. Qhxh5 Ka8 28. Qg4 Kb8 29. h4 Ka8 30. h5 Kb8 31. h6 Ka8 32. h7 Kb8 33. h8=Q Ka8 34. dxe6 Kb8 35. e7 Ka8 36. exf8=Q Kb8 37. Qg8xg7 Ka8 38. Qgg8 Kb8 39. Q4g7 Ka8 40. c5 Kb8 41. cxd6 Ka8 42. dxc7 b6 43. cxd8=Q# 1-0` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := chess.ParsePGN(strings.NewReader(pgn)) if err != nil { t.Fatalf("expected PGN with full-square disambiguation capture to parse: %v", err) } @@ -155,18 +114,58 @@ func TestPGNFullSquareDisambiguationCapture(t *testing.T) { t.Fatal("expected game to be parsed") } - // Verify the specific problematic move (Qg8xg7) was parsed correctly. - // Move 37 is at index 72 (0-based) in the moves slice. moves := game.Moves() if len(moves) < 73 { t.Fatalf("expected at least 73 moves, got %d", len(moves)) } move37 := moves[72] - if move37.String() != "g8g7" { - t.Errorf("expected move 37 to be g8g7, got %s", move37.String()) + if move37.S1() != chess.G8 || move37.S2() != chess.G7 { + t.Errorf("expected move 37 to be g8g7, got %s%s", move37.S1(), move37.S2()) } - if !move37.HasTag(Capture) { + if !move37.HasTag(chess.Capture) { t.Errorf("expected move 37 to have Capture tag") } } + +func TestAlgebraicNotation_EncodeDisambiguation(t *testing.T) { + tests := []struct { + name string + fen string + from, to chess.Square + wantSAN string + }{ + {"none", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", chess.E2, chess.E4, "e4"}, + {"file_only", "rnbqkbnr/pppp1ppp/8/4p3/3P4/5N2/PPP1PPPP/RNBQKB1R w KQkq - 0 1", chess.F3, chess.D2, "Nfd2"}, + {"rank_only", "4k3/8/8/8/R7/8/8/R3K3 w - - 0 1", chess.A4, chess.A3, "R4a3"}, + {"file_and_rank", "1k6/8/8/8/Q7/8/8/Q2QK3 w - - 0 1", chess.A1, chess.D4, "Qa1d4"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opt, err := chess.FEN(tc.fen) + if err != nil { + t.Fatalf("%s: invalid FEN: %v", tc.name, err) + } + pos := chess.NewGame(opt).Position() + var target chess.Move + found := false + for _, m := range pos.ValidMoves() { + if m.S1() == tc.from && m.S2() == tc.to { + target = m + found = true + break + } + } + if !found { + t.Skipf("%s: move %s%s unavailable in position", tc.name, tc.from, tc.to) + } + got, err := chess.SAN().Encode(pos, target) + if err != nil { + t.Fatalf("%s: Encode error: %v", tc.name, err) + } + if got != tc.wantSAN { + t.Errorf("%s: Encode = %q, want %q", tc.name, got, tc.wantSAN) + } + }) + } +} diff --git a/pgn_edge_cases_test.go b/pgn_edge_cases_test.go new file mode 100644 index 00000000..8afc97c3 --- /dev/null +++ b/pgn_edge_cases_test.go @@ -0,0 +1,239 @@ +package chess_test + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestPGNDecoderPreservesEscapedQuoteInTagValue(t *testing.T) { + const escapedQuoteGame = `[Event "Internet Section 08A g/8'+2\""] +[Site "Dos Hermanas"] +[Date "2004.03.08"] +[Round "6"] +[White "Di Berardino, Diego Rafael"] +[Black "Mar, Fernando"] +[Result "1/2-1/2"] +[ECO "B96"] + +1. e4 c5 2. Nf3 1/2-1/2 +` + dec := chess.NewPGNDecoder(strings.NewReader(escapedQuoteGame)) + game, err := dec.Decode() + if err != nil { + t.Fatalf("parser rejects spec-legal escaped quote in tag value: %v", err) + } + if game.GetTagPair("Event") != `Internet Section 08A g/8'+2"` { + t.Fatalf("expected unescaped quote in tag value, got %q", game.GetTagPair("Event")) + } + pgn := game.String() + if !strings.Contains(pgn, `[Event "Internet Section 08A g/8'+2\""]`) { + t.Fatalf("round-trip PGN does not contain escaped quote: %s", pgn) + } +} + +func TestPGNImportAcceptsPromotionWithoutEquals(t *testing.T) { + const pgn = `[Event "Promotion import"] +[SetUp "1"] +[FEN "6k1/4P3/8/8/8/8/8/4K3 w - - 0 1"] +[Result "*"] + +1. e8Q * +` + + game, err := chess.ParsePGN(strings.NewReader(pgn)) + if err != nil { + t.Fatalf("ParsePGN rejected import promotion without equals: %v", err) + } + if got := game.String(); !strings.Contains(got, "1. e8=Q") { + t.Fatalf("generated PGN should canonicalise promotion with equals, got:\n%s", got) + } +} + +func TestPGNSANPolicyOptionsLastOneWins(t *testing.T) { + const pgn = `[Event "Promotion import"] +[SetUp "1"] +[FEN "6k1/4P3/8/8/8/8/8/4K3 w - - 0 1"] +[Result "*"] + +1. e8Q * +` + + if _, err := chess.ParsePGN(strings.NewReader(pgn), chess.WithStrictSAN()); err == nil { + t.Fatalf("ParsePGN WithStrictSAN accepted import-only promotion spelling") + } + if _, err := chess.ParsePGN(strings.NewReader(pgn), chess.WithStrictSAN(), chess.WithImportSAN()); err != nil { + t.Fatalf("ParsePGN last import option error = %v", err) + } + if _, err := chess.ParsePGN(strings.NewReader(pgn), chess.WithImportSAN(), chess.WithStrictSAN()); err == nil { + t.Fatalf("ParsePGN last strict option accepted import-only promotion spelling") + } +} + +func TestPGNDecoderGameBoundaries(t *testing.T) { + tests := []struct { + name string + input string + wantGames int + wantOutcome chess.Outcome + }{ + {"empty", "", 0, chess.NoOutcome}, + {"whitespace_only", " \n\t ", 0, chess.NoOutcome}, + {"result_only", "1-0\n", 1, chess.WhiteWon}, + {"star_result_only_not_detected", "*\n", 0, chess.NoOutcome}, + {"tagless_game", "1. e4 e5 1-0\n", 1, chess.WhiteWon}, + {"tags_only", "[Event \"x\"]\n[Site \"y\"]\n\n", 1, chess.NoOutcome}, + {"crlf_no_tag_separator", "1. e4 e5 1-0\r\n1. d4 d5 0-1\r\n", 1, chess.WhiteWon}, + {"bom_prefix", "\xEF\xBB\xBF1. e4 e5 1-0\n", 0, chess.NoOutcome}, + {"no_blank_line_separator", "1. e4 e5 1-0\n[Event \"z\"]\n\n1. d4 d5 0-1\n", 1, chess.BlackWon}, + {"trailing_garbage", "1. e4 e5 1-0\nGARBAGE\n", 1, chess.WhiteWon}, + {"result_prefix_no_false_positive", "10-0\n", 1, chess.NoOutcome}, + {"result_embedded_in_movetext", "1-0e5\n", 1, chess.NoOutcome}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := chess.NewPGNDecoder(strings.NewReader(tt.input)) + count := 0 + var lastOutcome chess.Outcome + for { + g, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lastOutcome = g.Outcome() + count++ + } + if count != tt.wantGames { + t.Errorf("game count = %d, want %d", count, tt.wantGames) + } + if tt.wantGames > 0 && lastOutcome != tt.wantOutcome { + t.Errorf("outcome = %s, want %s", lastOutcome, tt.wantOutcome) + } + }) + } +} + +func TestPGNDecoderRoundTrip(t *testing.T) { + pgn := mustReadFile("fixtures/pgns/variations.pgn") + dec := chess.NewPGNDecoder(strings.NewReader(pgn)) + for { + g, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("parse error: %v", err) + } + rendered := g.String() + reparsed := mustParseSingleGame(t, rendered) + if reparsed.String() != rendered { + t.Errorf("round-trip mismatch:\nfirst:\n%s\nsecond:\n%s", rendered, reparsed.String()) + } + } +} + +func TestPGNDecoderVariationExpansionFixtures(t *testing.T) { + tests := []struct { + name string + fixture string + expandVariations bool + lastLines []string + finalPos []string + }{ + {"variations_expand", "fixtures/pgns/variations.pgn", true, + []string{ + "1. e4 e5 2. Nf3 Nc6 3. d4 exd4 4. Nxd4 *", + "1. e4 e5 2. Nc3 Nf6 3. f4 *", + "1. e4 d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 dxe5 5. Qxd8+ Kxd8 *", + "1. e4 d6 2. d4 Nf6 3. Nc3 e5 4. Nf3 Nbd7 *", + "1. e3 e5 *", + }, + []string{ + "r1bqkbnr/pppp1ppp/2n5/8/3NP3/8/PPP2PPP/RNBQKB1R b KQkq - 0 4", + "rnbqkb1r/pppp1ppp/5n2/4p3/4PP2/2N5/PPPP2PP/R1BQKBNR b KQkq - 0 3", + "rnbk1b1r/ppp2ppp/5n2/4p3/4P3/2N5/PPP2PPP/R1B1KBNR w KQ - 0 6", + "r1bqkb1r/pppn1ppp/3p1n2/4p3/3PP3/2N2N2/PPP2PPP/R1BQKB1R w KQkq - 2 5", + "rnbqkbnr/pppp1ppp/8/4p3/8/4P3/PPPP1PPP/RNBQKBNR w KQkq - 0 2", + }}, + {"variations_no_expand", "fixtures/pgns/variations.pgn", false, + []string{ + "1. e4 (1. e3 e5) 1... e5 (1... d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 (4. Nf3 Nbd7) 4... dxe5 5. Qxd8+ Kxd8) 2. Nf3 (2. Nc3 Nf6 3. f4) 2... Nc6 3. d4 exd4 4. Nxd4 1-0", + }, + []string{ + "r1bqkbnr/pppp1ppp/2n5/8/3NP3/8/PPP2PPP/RNBQKB1R b KQkq - 0 4", + }}, + {"multi_frompos_no_expand", "fixtures/pgns/multi_frompos_games.pgn", false, + []string{ + "1. d4 d5 2. c4 c6 { [%eval 0.21]} *", + "3. Nf3 (3. Nc3 Nf6 4. Nf3) 3... Nf6 4. Nc3 { [%eval 0.16]} *", + "4... a6 { [%eval 0.19]} *", + "5. cxd5 (5. e3 e6 6. cxd5 cxd5) 5... cxd5 6. e3 e6 { [%eval 0.11]} *", + }, + []string{ + "rnbqkbnr/pp2pppp/2p5/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 0 3", + "rnbqkb1r/pp2pppp/2p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq - 3 4", + "rnbqkb1r/1p2pppp/p1p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 0 5", + "rnbqkb1r/1p3ppp/p3pn2/3p4/3P4/2N1PN2/PP3PPP/R1BQKB1R w KQkq - 0 7", + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pgn := mustReadFile(tt.fixture) + var dec *chess.PGNDecoder + if tt.expandVariations { + dec = chess.NewPGNDecoder(strings.NewReader(pgn), chess.WithPGNExpandVariations()) + } else { + dec = chess.NewPGNDecoder(strings.NewReader(pgn)) + } + count := 0 + for { + game, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("fail to parse game %d: %s", count+1, err.Error()) + } + if game == nil { + t.Fatalf("game is nil") + } + if count >= len(tt.lastLines) { + t.Fatalf("expected %d games but found at least %d", len(tt.lastLines), count+1) + } + lines := strings.Split(game.String(), "\n") + lastLine := lines[len(lines)-1] + if lastLine != tt.lastLines[count] { + t.Errorf("game output not correct\n\tExpected:'%v'\n\tGot: '%v'\n", tt.lastLines[count], lastLine) + } + fen := game.Position().XFENString() + if fen != tt.finalPos[count] { + t.Errorf("game position not correct\n\tExpected:'%v'\n\tGot: '%v'\n", tt.finalPos[count], fen) + } + count++ + } + if count != len(tt.lastLines) { + t.Fatalf("expected %d games but found only %d", len(tt.lastLines), count) + } + }) + } +} + +func TestPGNGamesReadsMultiGameFixture(t *testing.T) { + pgn := mustReadFile("fixtures/pgns/multi_game.pgn") + var games int + for _, err := range chess.PGNGames(strings.NewReader(pgn)) { + if err != nil { + t.Fatalf("unexpected error on game %d: %v", games+1, err) + } + games++ + } + if games != 4 { + t.Errorf("expected 4 games, got %d", games) + } +} diff --git a/pgn_events_test.go b/pgn_events_test.go new file mode 100644 index 00000000..ca8ed346 --- /dev/null +++ b/pgn_events_test.go @@ -0,0 +1,113 @@ +package chess_test + +import ( + "context" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestPGNEventsStreamsTagsMovesCommentsAndGameEnd(t *testing.T) { + pgn := `[Event "events"] +[Site "somewhere"] +[Result "*"] + +1. e4 {hello [%clk 0:01:00]} $1 (1. d4) * +` + + var kinds []chess.PGNEventKind + var tagEvent chess.PGNEvent + var commentEvent chess.PGNEvent + var nagEvent chess.PGNEvent + var moveValues []string + + for event, err := range chess.PGNEvents(strings.NewReader(pgn)) { + if err != nil { + t.Fatalf("PGNEvents error: %v", err) + } + kinds = append(kinds, event.Kind) + switch event.Kind { + case chess.PGNTag: + if event.Name == "Event" { + tagEvent = event + } + case chess.PGNMove: + moveValues = append(moveValues, event.Move) + case chess.PGNComment: + commentEvent = event + case chess.PGNNAG: + nagEvent = event + } + } + + if tagEvent.Value != "events" { + t.Fatalf("Event tag value = %q, want events", tagEvent.Value) + } + if commentEvent.Value != "hello " { + t.Fatalf("comment value = %q, want hello with trailing space", commentEvent.Value) + } + if nagEvent.NAG != "$1" { + t.Fatalf("NAG = %q, want $1", nagEvent.NAG) + } + if strings.Join(moveValues, ",") != "e4,d4" { + t.Fatalf("moves = %v, want [e4 d4]", moveValues) + } + if len(kinds) == 0 || kinds[len(kinds)-1] != chess.PGNGameEnd { + t.Fatalf("last event kind = %v, want PGNGameEnd", kinds) + } +} + +func TestPGNEventsStreamsMultipleGamesWithIndexes(t *testing.T) { + var endIndexes []int64 + for event, err := range chess.PGNEvents(strings.NewReader(decoderGameOne + "\n" + decoderGameTwo)) { + if err != nil { + t.Fatalf("PGNEvents error: %v", err) + } + if event.Kind == chess.PGNGameEnd { + endIndexes = append(endIndexes, event.Index) + } + } + if len(endIndexes) != 2 || endIndexes[0] != 1 || endIndexes[1] != 2 { + t.Fatalf("game end indexes = %v, want [1 2]", endIndexes) + } +} + +func TestPGNRecordTagsExtractsTagsWithoutDecodingGame(t *testing.T) { + var record chess.PGNRecord + for r, err := range chess.PGNRecords(context.Background(), strings.NewReader(decoderGameOne)) { + if err != nil { + t.Fatalf("PGNRecords error: %v", err) + } + record = r + } + + tags, err := record.Tags() + if err != nil { + t.Fatalf("Tags error: %v", err) + } + if tags["Event"] != "one" { + t.Fatalf("Event tag = %q, want one", tags["Event"]) + } + if _, ok := tags["White"]; !ok { + t.Fatal("White tag missing") + } +} + +func TestPGNRecordTagsOnTruncatedTag(t *testing.T) { + var record chess.PGNRecord + for r, err := range chess.PGNRecords(context.Background(), strings.NewReader(`[Event "x"`)) { + if err != nil { + t.Fatalf("PGNRecords error: %v", err) + } + record = r + } + + tags, err := record.Tags() + if err != nil { + t.Fatalf("Tags error: %v", err) + } + if len(tags) != 0 { + t.Fatalf("tags = %v, want empty map", tags) + } +} diff --git a/pgn_frame_benchmark_test.go b/pgn_frame_benchmark_test.go new file mode 100644 index 00000000..258275bf --- /dev/null +++ b/pgn_frame_benchmark_test.go @@ -0,0 +1,85 @@ +package chess + +import ( + "bytes" + "context" + "testing" +) + +func BenchmarkPGN_Frame_Big(b *testing.B) { + data := readPGNFixture("big.pgn") + meta := pgnMeta("big.pgn") + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + games := 0 + for _, err := range PGNRecords(context.Background(), bytes.NewReader(data)) { + if err != nil { + b.Fatalf("frame error: %v", err) + } + games++ + } + if games != meta.games { + b.Fatalf("expected %d games, framed %d", meta.games, games) + } + } +} + +func BenchmarkPGNRecord_Tags_Big(b *testing.B) { + data := readPGNFixture("big.pgn") + meta := pgnMeta("big.pgn") + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + for record, err := range PGNRecords(context.Background(), bytes.NewReader(data)) { + if err != nil { + b.Fatalf("record error: %v", err) + } + if _, err := record.Tags(); err != nil { + b.Fatalf("Tags error: %v", err) + } + } + } +} + +func BenchmarkPGNEvents_Big(b *testing.B) { + data := readPGNFixture("big.pgn") + meta := pgnMeta("big.pgn") + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + for _, err := range PGNEvents(bytes.NewReader(data)) { + if err != nil { + b.Fatalf("event error: %v", err) + } + } + } +} + +func BenchmarkPGNRecord_TagsThenDecode_Big(b *testing.B) { + data := readPGNFixture("big.pgn") + meta := pgnMeta("big.pgn") + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + for record, err := range PGNRecords(context.Background(), bytes.NewReader(data)) { + if err != nil { + b.Fatalf("record error: %v", err) + } + if _, err := record.Tags(); err != nil { + b.Fatalf("Tags error: %v", err) + } + if _, err := record.Decode(); err != nil { + b.Fatalf("Decode error: %v", err) + } + } + } +} diff --git a/pgn_full_game_benchmark_test.go b/pgn_full_game_benchmark_test.go new file mode 100644 index 00000000..6ecdad49 --- /dev/null +++ b/pgn_full_game_benchmark_test.go @@ -0,0 +1,205 @@ +package chess + +import ( + "bytes" + "context" + "errors" + "io" + "runtime" + "testing" +) + +type pgnDecodeSummary struct { + games int + errors int + moves int + tags int + comments int + variations int + withOutcome int +} + +var pgnDecodeSummarySink pgnDecodeSummary + +func BenchmarkPGN_FullGameDecode_BigBig(b *testing.B) { + benchFullGameDecodeFixture(b, "big_big.pgn") +} + +func BenchmarkPGN_FullGameDecode_Big(b *testing.B) { + benchFullGameDecodeFixture(b, "big.pgn") +} + +func BenchmarkPGN_FullGameDecode_RecordDecode_BigBig(b *testing.B) { + data := readPGNFixture("big_big.pgn") + meta := pgnMeta("big_big.pgn") + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + var summary pgnDecodeSummary + for record, err := range PGNRecords(context.Background(), bytes.NewReader(data)) { + if err != nil { + b.Fatalf("record error: %v", err) + } + game, err := record.Decode() + if err != nil { + if isKnownInconsistentPgn(err) { + summary.errors++ + continue + } + b.Fatalf("decode error: %v", err) + } + accumulatePGNDecodeSummary(&summary, game) + } + if summary.games+summary.errors != meta.games { + b.Fatalf("expected %d games total, parsed %d with %d errors", meta.games, summary.games, summary.errors) + } + pgnDecodeSummarySink = summary + } +} + +func BenchmarkPGN_FullGameDecode_Parallel_BigBig(b *testing.B) { + data := readPGNFixture("big_big.pgn") + meta := pgnMeta("big_big.pgn") + workers := runtime.GOMAXPROCS(0) + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + var summary pgnDecodeSummary + results := DecodePGNGamesParallel(context.Background(), bytes.NewReader(data), PGNParallelOptions{ + Workers: workers, + Buffer: workers * 2, + }) + for result := range results { + if result.Err != nil { + if isKnownInconsistentPgn(result.Err) { + summary.errors++ + continue + } + b.Fatalf("parallel decode error: %v", result.Err) + } + accumulatePGNDecodeSummary(&summary, result.Game) + } + if summary.games+summary.errors != meta.games { + b.Fatalf("expected %d games total, parsed %d with %d errors", meta.games, summary.games, summary.errors) + } + pgnDecodeSummarySink = summary + } +} + +func BenchmarkPGN_FullGameDecode_ExpandVariations(b *testing.B) { + data := []byte(`[Event "Variation Benchmark"] +[Site "Internet"] +[Date "2023.12.06"] +[Round "1"] +[White "Player1"] +[Black "Player2"] +[Result "1-0"] + +1. e4 e5 2. Nf3 Nc6 3. Bb5 (3. Bc4 Nf6 4. d3) a6 4. Ba4 Nf6 5. O-O Be7 1-0`) + + b.SetBytes(int64(len(data))) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + var summary pgnDecodeSummary + dec := NewPGNDecoder(bytes.NewReader(data), WithPGNExpandVariations()) + for { + game, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + b.Fatalf("decode error: %v", err) + } + accumulatePGNDecodeSummary(&summary, game) + } + pgnDecodeSummarySink = summary + } +} + +func BenchmarkPGN_FullGameDecode_CompleteGameDetails(b *testing.B) { + data := readPGNFixture("complete_game.pgn") + b.SetBytes(int64(len(data))) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + game, err := NewPGNDecoder(bytes.NewReader(data)).Decode() + if err != nil { + b.Fatalf("decode error: %v", err) + } + var summary pgnDecodeSummary + accumulatePGNDecodeSummary(&summary, game) + pgnDecodeSummarySink = summary + } +} + +func benchFullGameDecodeFixture(b *testing.B, name string) { + b.Helper() + data := readPGNFixture(name) + meta := pgnMeta(name) + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + var summary pgnDecodeSummary + dec := NewPGNDecoder(bytes.NewReader(data)) + for { + game, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + if isKnownInconsistentPgn(err) { + summary.errors++ + continue + } + b.Fatalf("decode error: %v", err) + } + accumulatePGNDecodeSummary(&summary, game) + } + if summary.games+summary.errors != meta.games { + b.Fatalf("expected %d games total, parsed %d with %d errors", meta.games, summary.games, summary.errors) + } + pgnDecodeSummarySink = summary + } +} + +func accumulatePGNDecodeSummary(summary *pgnDecodeSummary, game *Game) { + if game == nil { + return + } + summary.games++ + summary.tags += len(game.tagPairs) + if game.Outcome() != NoOutcome { + summary.withOutcome++ + } + for _, block := range game.comments { + summary.comments += len(block) + } + accumulateMoveTreeSummary(summary, game.MoveTree().Root()) +} + +func accumulateMoveTreeSummary(summary *pgnDecodeSummary, node *MoveNode) { + if node == nil { + return + } + if node.parent != nil { + summary.moves++ + } + if len(node.children) > 1 { + summary.variations += len(node.children) - 1 + } + for _, block := range node.commentBlocks { + summary.comments += len(block.Items) + } + for _, child := range node.children { + accumulateMoveTreeSummary(summary, child) + } +} diff --git a/pgn_outcome_golden_test.go b/pgn_outcome_golden_test.go new file mode 100644 index 00000000..402bfeed --- /dev/null +++ b/pgn_outcome_golden_test.go @@ -0,0 +1,70 @@ +package chess_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +// TestPGNOutcomeGolden is an end-to-end regression for the unified outcome +// policy. Each .pgn fixture in testdata/outcomes is parsed, then its PGN-level +// Outcome/Method and every Split game's Outcome/Method/Result-tag are rendered +// to a stable text form and compared against a sibling .golden file. +// +// Regenerate the golden files with: +// +// UPDATE_GOLDEN=1 go test -run TestPGNOutcomeGolden +func TestPGNOutcomeGolden(t *testing.T) { + files, err := filepath.Glob("testdata/outcomes/*.pgn") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no .pgn fixtures found in testdata/outcomes") + } + + for _, pgnPath := range files { + name := strings.TrimSuffix(filepath.Base(pgnPath), ".pgn") + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(pgnPath) + if err != nil { + t.Fatal(err) + } + game, err := chess.NewPGNDecoder(strings.NewReader(string(data))).Decode() + if err != nil { + t.Fatalf("parse %s: %v", pgnPath, err) + } + + var b strings.Builder + fmt.Fprintf(&b, "pgn: outcome=%s method=%s\n", game.Outcome(), game.Method()) + for i, sg := range game.Split() { + result := sg.GetTagPair("Result") + if result == "" { + result = "none" + } + fmt.Fprintf(&b, "split[%d]: outcome=%s method=%s result=%s\n", i, sg.Outcome(), sg.Method(), result) + } + + got := b.String() + goldenPath := strings.TrimSuffix(pgnPath, ".pgn") + ".golden" + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s", goldenPath) + return + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("missing or unreadable golden file %s (run UPDATE_GOLDEN=1 go test -run TestPGNOutcomeGolden to create it): %v", goldenPath, err) + } + if got != string(want) { + t.Errorf("golden mismatch for %s\n--- want ---\n%s\n--- got ---\n%s", goldenPath, want, got) + } + }) + } +} diff --git a/pgn_parallel_test.go b/pgn_parallel_test.go new file mode 100644 index 00000000..f02eeaad --- /dev/null +++ b/pgn_parallel_test.go @@ -0,0 +1,101 @@ +package chess_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestDecodePGNGamesParallelDecodesAllGames(t *testing.T) { + ctx := context.Background() + results := chess.DecodePGNGamesParallel(ctx, strings.NewReader(decoderGameOne+"\n"+decoderGameTwo), chess.PGNParallelOptions{ + Workers: 2, + Buffer: 1, + }) + + events := map[string]bool{} + for result := range results { + if result.Err != nil { + t.Fatalf("parallel result error: %v", result.Err) + } + events[result.Game.GetTagPair("Event")] = true + } + if !events["one"] || !events["two"] || len(events) != 2 { + t.Fatalf("decoded events = %v, want one and two", events) + } +} + +func TestDecodePGNGamesParallelOrderedPreservesPGNOrder(t *testing.T) { + ctx := context.Background() + results := chess.DecodePGNGamesParallel(ctx, strings.NewReader(decoderGameOne+"\n"+decoderGameTwo), chess.PGNParallelOptions{ + Workers: 2, + Buffer: 2, + Ordered: true, + }) + + var events []string + var indexes []int64 + for result := range results { + if result.Err != nil { + t.Fatalf("parallel result error: %v", result.Err) + } + events = append(events, result.Game.GetTagPair("Event")) + indexes = append(indexes, result.Index) + } + if strings.Join(events, ",") != "one,two" { + t.Fatalf("events = %v, want [one two]", events) + } + if len(indexes) != 2 || indexes[0] != 1 || indexes[1] != 2 { + t.Fatalf("indexes = %v, want [1 2]", indexes) + } +} + +func TestDecodePGNGamesParallelReportsPerGameErrors(t *testing.T) { + bad := `[Event "bad"] +[Result "*"] + +1. NotAMove * +` + ctx := context.Background() + results := chess.DecodePGNGamesParallel(ctx, strings.NewReader(decoderGameOne+"\n"+bad+"\n"+decoderGameTwo), chess.PGNParallelOptions{ + Workers: 2, + Buffer: 2, + Ordered: true, + }) + + var good int + var failed []int64 + for result := range results { + if result.Err != nil { + failed = append(failed, result.Index) + continue + } + good++ + } + if good != 2 { + t.Fatalf("good result count = %d, want 2", good) + } + if len(failed) != 1 || failed[0] != 2 { + t.Fatalf("failed indexes = %v, want [2]", failed) + } +} + +func TestDecodePGNGamesParallelHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + results := chess.DecodePGNGamesParallel(ctx, strings.NewReader(decoderGameOne), chess.PGNParallelOptions{Workers: 1, Buffer: 1}) + result, ok := <-results + if !ok { + t.Fatal("result channel closed without cancellation result") + } + if !errors.Is(result.Err, context.Canceled) { + t.Fatalf("result error = %v, want context.Canceled", result.Err) + } + if _, ok := <-results; ok { + t.Fatal("result channel should close after cancellation") + } +} diff --git a/pgn_records_test.go b/pgn_records_test.go new file mode 100644 index 00000000..6aa0432a --- /dev/null +++ b/pgn_records_test.go @@ -0,0 +1,102 @@ +package chess_test + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestPGNRecordsStreamsRecordsWithIndexAndOffset(t *testing.T) { + pgn := "\n\n" + decoderGameOne + "\n" + decoderGameTwo + var records []chess.PGNRecord + + for record, err := range chess.PGNRecords(context.Background(), strings.NewReader(pgn)) { + if err != nil { + t.Fatalf("PGNRecords error: %v", err) + } + records = append(records, record) + } + + if len(records) != 2 { + t.Fatalf("len(records) = %d, want 2", len(records)) + } + if records[0].Index != 1 || records[1].Index != 2 { + t.Fatalf("record indexes = %d,%d want 1,2", records[0].Index, records[1].Index) + } + if records[0].Offset != 2 { + t.Fatalf("first offset = %d, want 2", records[0].Offset) + } + if !strings.Contains(records[0].Raw, `[Event "one"]`) { + t.Fatalf("first record raw does not contain first game: %q", records[0].Raw) + } +} + +func TestPGNRecordDecodeReturnsGame(t *testing.T) { + var record chess.PGNRecord + for r, err := range chess.PGNRecords(context.Background(), strings.NewReader(decoderGameOne)) { + if err != nil { + t.Fatalf("PGNRecords error: %v", err) + } + record = r + } + + game, err := record.Decode() + if err != nil { + t.Fatalf("Decode error: %v", err) + } + if got := game.GetTagPair("Event"); got != "one" { + t.Fatalf("Event tag = %q, want one", got) + } +} + +func TestPGNRecordsHandlesLargeRecord(t *testing.T) { + largeComment := strings.Repeat("a", 128*1024) + pgn := strings.Replace(decoderGameOne, "1. e4 *", "1. e4 {"+largeComment+"} *", 1) + + count := 0 + for record, err := range chess.PGNRecords(context.Background(), strings.NewReader(pgn)) { + if err != nil { + t.Fatalf("PGNRecords error: %v", err) + } + count++ + if len(record.Raw) < 128*1024 { + t.Fatalf("record Raw len = %d, want at least %d", len(record.Raw), 128*1024) + } + } + if count != 1 { + t.Fatalf("record count = %d, want 1", count) + } +} + +func TestPGNRecordsHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + for _, err := range chess.PGNRecords(ctx, strings.NewReader(decoderGameOne)) { + if !errors.Is(err, context.Canceled) { + t.Fatalf("PGNRecords error = %v, want context.Canceled", err) + } + return + } + t.Fatal("PGNRecords did not yield context cancellation") +} + +func TestPGNDecoderUsesRecordOffsets(t *testing.T) { + dec := chess.NewPGNDecoder(strings.NewReader("\n\n" + decoderGameOne)) + if _, err := dec.Decode(); err != nil { + t.Fatalf("Decode error: %v", err) + } + if dec.Index() != 1 { + t.Fatalf("Index = %d, want 1", dec.Index()) + } + if dec.Offset() != 2 { + t.Fatalf("Offset = %d, want 2", dec.Offset()) + } + if _, err := dec.Decode(); !errors.Is(err, io.EOF) { + t.Fatalf("final Decode error = %v, want io.EOF", err) + } +} diff --git a/pgn_renderer.go b/pgn_renderer.go new file mode 100644 index 00000000..4c72c96e --- /dev/null +++ b/pgn_renderer.go @@ -0,0 +1,335 @@ +package chess + +import ( + "io" + "slices" + "strconv" + "strings" +) + +// PGNRenderer renders a Game into PGN text. It is a stateless +// function object that reads Game state and writes formatted PGN +// to a string or io.Writer without mutating the Game. +// +// The renderer takes a pointer so callers may extend it in the +// future (custom tag ordering, value escaping toggles, optional +// result token, etc.). All such configuration fields are deferred +// to v3.1; this initial type is a pure extraction with no knobs. +type PGNRenderer struct{} + +// DefaultPGNRenderer is the package-level renderer used by +// Game.String and Game.WritePGN. +var DefaultPGNRenderer = &PGNRenderer{} + +// Render returns the PGN text for g. +func (r *PGNRenderer) Render(g *Game) string { + var sb strings.Builder + if err := r.renderTo(g, &sb); err != nil { + return "" + } + return sb.String() +} + +// RenderGameTo writes the PGN text for g to w. It returns any +// write error from w. +func (r *PGNRenderer) RenderGameTo(g *Game, w io.Writer) error { + return r.renderTo(g, w) +} + +func (r *PGNRenderer) renderTo(g *Game, w io.Writer) error { + var sb strings.Builder + + tagPairList := make([]sortableTagPair, len(g.tagPairs)) + + var idx uint + for tag, value := range g.tagPairs { + tagPairList[idx] = sortableTagPair{ + Key: tag, + Value: value, + } + idx++ + } + + slices.SortFunc(tagPairList, cmpTags) + + for _, tagPair := range tagPairList { + sb.WriteByte('[') + sb.WriteString(tagPair.Key) + sb.WriteString(" \"") + sb.WriteString(escapeTagValue(tagPair.Value)) + sb.WriteString("\"]\n") + } + + if len(g.tagPairs) > 0 { + sb.WriteString("\n") + } + + needTrailingSpace := false + if g.tree != nil && g.tree.Root() != nil { + root := g.tree.Root() + if len(root.children) > 0 { + writeMoves(root, + root.position.moveCount, + root.position.turn == White, &sb, false, false, true) + needTrailingSpace = true + } else if root.hasAnnotations() { + writeAnnotations(root, &sb) + } + } + + if needTrailingSpace { + sb.WriteString(" ") + } + sb.WriteString(g.Outcome().String()) + + _, err := io.WriteString(w, sb.String()) + return err +} + +// sortableTagPair is the key/value row used to sort a Game's tag pairs into +// PGN output order. +type sortableTagPair struct { + Key string + Value string +} + +// cmpTags orders two tag pairs. The Seven Tag Roster (Event, Site, Date, +// Round, White, Black, Result) takes priority; everything else is sorted +// ascending by key. Duplicate keys are treated as equal so the sort is +// stable. +func cmpTags(a, b sortableTagPair) int { + // Don't re-order duplicate keys + if a.Key == b.Key { + return 0 + } + + // PGN defined tags take priority + for _, req := range []string{ + "Event", + "Site", + "Date", + "Round", + "White", + "Black", + "Result", + } { + if a.Key == req { + return -1 + } + if b.Key == req { + return +1 + } + } + + // Finally compare the keys directly and sort by ascending + if a.Key < b.Key { + return -1 + } else if b.Key < a.Key { + return +1 + } + return 0 +} + +// escapeTagValue escapes backslash and double-quote characters so that the +// resulting string is safe to embed inside a PGN tag value. +func escapeTagValue(v string) string { + var sb strings.Builder + for i := range len(v) { + c := v[i] + if c == '\\' || c == '"' { + sb.WriteByte('\\') + } + sb.WriteByte(c) + } + return sb.String() +} + +// writeMoves recursively writes the PGN-formatted move sequence starting from the given move node into the provided strings.Builder. +// It handles move numbering for white and black moves, encodes moves using algebraic notation based on the appropriate position, +// and appends comments and command annotations if present. The function distinguishes between main line moves and sub-variations; +// when processing a sub-variation, moves are enclosed in parentheses. +// +// Parameters: +// +// node - pointer to the current move node from which to write moves. +// moveNum - the current move number corresponding to white’s moves. +// isWhite - true if it is white’s move, false if it is black’s move. +// sb - pointer to a strings.Builder where the formatted move notation is appended. +// subVariation - true if the current call is within a sub-variation, affecting formatting details. +// closedVariation - true if the prior call closed a sub-variation, affecting formatting details. +// isRoot - true if the current move is the root move of a game, affecting formatting details. +// +// The function recurses through the move tree, writing the main line first and then processing any additional variations, +// ensuring that the output adheres to standard PGN conventions. Future enhancements may include support for all NAG values. +func writeMoves(node *MoveNode, moveNum int, isWhite bool, sb *strings.Builder, + subVariation, closedVariation, isRoot bool, +) { + // If no moves remain, stop. + if node == nil { + return + } + + // Handle root move comments before processing children + if isRoot && node.hasAnnotations() { + writeAnnotations(node, sb) + } + + var currentMove *MoveNode + + // The main line is the first child. + if subVariation { + currentMove = node + } else { + if len(node.children) == 0 { + return // nothing to print if no child exists (should not happen for a proper game) + } + currentMove = node.children[0] + } + + writeMoveNumber(moveNum, isWhite, subVariation, closedVariation, isRoot, sb) + + // Encode the move using your algebraicNotation. + writeMoveEncoding(node, currentMove, subVariation, sb) + + writeAnnotations(currentMove, sb) + + if len(node.children) > 1 || len(currentMove.children) > 0 { + sb.WriteString(" ") + } + // Process any variations (children beyond the first). + // In PGN, variations are enclosed in parentheses. + closedVar := writeVariations(node, moveNum, isWhite, sb) + + if len(currentMove.children) > 0 { + var nextMoveNum int + var nextIsWhite bool + if isWhite { + // After white's move, black plays using the same move number. + nextMoveNum = moveNum + nextIsWhite = false + } else { + // After black's move, increment move number. + nextMoveNum = moveNum + 1 + nextIsWhite = true + } + writeMoves(currentMove, nextMoveNum, nextIsWhite, sb, false, closedVar, + false) + } +} + +func writeMoveNumber(moveNum int, isWhite bool, subVariation, closedVariation, + isRoot bool, sb *strings.Builder, +) { + if closedVariation { + sb.WriteString(" ") + } + if isWhite { + sb.WriteString(strconv.Itoa(moveNum)) + sb.WriteString(". ") + } else if subVariation || closedVariation || isRoot { + sb.WriteString(strconv.Itoa(moveNum)) + sb.WriteString("... ") + } +} + +func writeMoveEncoding(node *MoveNode, currentMove *MoveNode, subVariation bool, sb *strings.Builder) { + var ( + moveStr string + err error + ) + if subVariation && node.parent != nil { + moveStr, err = SAN().Encode(node.parent.position, currentMove.move) + } else { + moveStr, err = SAN().Encode(node.position, currentMove.move) + } + if err == nil { + sb.WriteString(moveStr) + } +} + +func sortedCommandKeys(commands map[string]string) []string { + keys := make([]string, 0, len(commands)) + for key := range commands { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func writeAnnotations(move *MoveNode, sb *strings.Builder) { + if move == nil { + return + } + + for _, nag := range move.nags { + sb.WriteByte(' ') + sb.WriteString(nag) + } + + if len(move.commentBlocks) > 0 { + writeCommentBlocks(move.commentBlocks, sb) + } +} + +func writeCommentBlocks(blocks []CommentBlock, sb *strings.Builder) { + for _, block := range blocks { + if len(block.Items) == 0 { + continue + } + + sb.WriteString(" {") + lastByte := byte('{') + for _, item := range block.Items { + switch item.Kind { + case CommentText: + lastByte = writeEscapedCommentText(sb, item.Text, lastByte) + case CommentCommand: + if needsCommandSeparator(lastByte) { + sb.WriteString(" ") + } + sb.WriteString("[%") + sb.WriteString(item.Key) + sb.WriteString(" ") + sb.WriteString(item.Value) + sb.WriteString("]") + lastByte = ']' + } + } + sb.WriteString("}") + } +} + +func writeEscapedCommentText(sb *strings.Builder, text string, lastByte byte) byte { + for i := range len(text) { + if text[i] == '}' { + sb.WriteByte('\\') + } + sb.WriteByte(text[i]) + lastByte = text[i] + } + return lastByte +} + +func needsCommandSeparator(lastByte byte) bool { + return lastByte != ' ' +} + +func writeVariations(node *MoveNode, moveNum int, isWhite bool, sb *strings.Builder) bool { + wroteAtLeastOneVar := false + + if len(node.children) > 1 { + for i := 1; i < len(node.children); i++ { + if wroteAtLeastOneVar { + sb.WriteString(" ") + } + wroteAtLeastOneVar = true + + variation := node.children[i] + sb.WriteString("(") + writeMoves(variation, moveNum, isWhite, sb, true, false, false) + sb.WriteString(")") + } + } + + return wroteAtLeastOneVar +} diff --git a/pgn_renderer_test.go b/pgn_renderer_test.go new file mode 100644 index 00000000..2b6b864e --- /dev/null +++ b/pgn_renderer_test.go @@ -0,0 +1,274 @@ +package chess_test + +import ( + "bytes" + "errors" + "regexp" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestPGNRenderer_RenderMatchesGameString(t *testing.T) { + g := chess.NewGame() + g.AddTagPair("Event", "Test Event") + g.AddTagPair("Site", "Test Site") + g.AddTagPair("Date", "2024.01.01") + g.AddTagPair("Round", "1") + g.AddTagPair("White", "Player A") + g.AddTagPair("Black", "Player B") + g.AddTagPair("Result", "*") + + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatal(err) + } + + got := g.String() + rendered := chess.DefaultPGNRenderer.Render(g) + if got != rendered { + t.Errorf("Game.String() and DefaultPGNRenderer.Render disagree:\n%s\nvs\n%s", got, rendered) + } +} + +func TestGame_WritePGNMatchesString(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("d4", nil); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("d5", nil); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := g.WritePGN(&buf); err != nil { + t.Fatal(err) + } + + if buf.String() != g.String() { + t.Errorf("WritePGN output differs from String():\n%s\nvs\n%s", buf.String(), g.String()) + } +} + +type errWriter struct { + err error +} + +func (w *errWriter) Write(_ []byte) (int, error) { + return 0, w.err +} + +func TestPGNRenderer_RenderGameToPropagatesWriterError(t *testing.T) { + g := chess.NewGame() + want := errors.New("disk full") + w := &errWriter{err: want} + + err := chess.DefaultPGNRenderer.RenderGameTo(g, w) + if !errors.Is(err, want) { + t.Errorf("expected error %v, got %v", want, err) + } +} + +func TestPGNRenderer_EndsEmptyGameWithNoOutcome(t *testing.T) { + g := chess.NewGame() + out := chess.DefaultPGNRenderer.Render(g) + if !strings.HasSuffix(out, string(chess.NoOutcome)) { + t.Errorf("expected output to end with NoOutcome %q, got %q", chess.NoOutcome, out) + } +} + +func TestPGNRenderer_MoveNumberUsesDotAndSpace(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("Nf3", nil); err != nil { + t.Fatal(err) + } + + out := chess.DefaultPGNRenderer.Render(g) + + for _, pattern := range []string{`\b1\. e4\b`, ` 2\. Nf3\b`} { + re := regexp.MustCompile(pattern) + if !re.MatchString(out) { + t.Errorf("expected output to match %q, got %q", pattern, out) + } + } + if strings.Contains(out, "1.e4") || strings.Contains(out, "2.Nf3") { + t.Errorf("expected a space between move number and move, got %q", out) + } +} + +func TestPGNRenderer_TrailingSpaceAfterMoves(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatal(err) + } + out := chess.DefaultPGNRenderer.Render(g) + if !strings.HasSuffix(out, " *") { + t.Errorf("expected a single trailing space before outcome token in %q", out) + } +} + +func TestPGNRenderer_NoTrailingSpaceForEmptyGame(t *testing.T) { + g := chess.NewGame() + out := chess.DefaultPGNRenderer.Render(g) + if strings.HasSuffix(out, " *") { + t.Errorf("empty game must not gain a trailing space before outcome: %q", out) + } +} + +func TestPGNRenderer_EscapesCommentEndBrace(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + g.MoveTree().Root().Children()[0].SetComment("keeps } inside") + + out := chess.DefaultPGNRenderer.Render(g) + if !strings.Contains(out, `keeps \} inside`) { + t.Fatalf("rendered PGN did not escape comment brace: %q", out) + } + + parsed := chess.NewGame(mustPGNOption(t, out)) + if got := parsed.MoveTree().Root().Children()[0].Comments(); got != "keeps } inside" { + t.Fatalf("round-tripped comment = %q, want %q", got, "keeps } inside") + } +} + +func TestPGNRenderer_EscapesTagValueQuotes(t *testing.T) { + g := chess.NewGame() + g.AddTagPair("White", `A "B" C`) + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + + out := chess.DefaultPGNRenderer.Render(g) + if !strings.Contains(out, `A \"B\" C`) { + t.Fatalf("rendered PGN did not escape tag value quotes: %q", out) + } +} + +func TestPGNRenderer_EmitsCommandAnnotation(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + g.MoveTree().Root().Children()[0].SetCommand("clk", "0:01:23") + + out := chess.DefaultPGNRenderer.Render(g) + if !strings.Contains(out, "[%clk 0:01:23]") { + t.Fatalf("rendered PGN did not contain command annotation: %q", out) + } +} + +func TestPGNRenderer_OrdersTagsBySevenTagRosterThenAlpha(t *testing.T) { + g := chess.NewGame() + g.AddTagPair("Result", "*") + g.AddTagPair("Zebra", "z") + g.AddTagPair("Date", "2026.06.19") + g.AddTagPair("Black", "B") + g.AddTagPair("Alpha", "a") + g.AddTagPair("Round", "1") + g.AddTagPair("White", "A") + g.AddTagPair("Event", "E") + g.AddTagPair("Site", "S") + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + + out := chess.DefaultPGNRenderer.Render(g) + idx := func(tag string) int { return strings.Index(out, "["+tag+" \"") } + order := []string{"Event", "Site", "Date", "Round", "White", "Black", "Result", "Alpha", "Zebra"} + for i := 1; i < len(order); i++ { + if idx(order[i]) <= idx(order[i-1]) { + t.Fatalf("tags out of order: %q (idx %d) should come after %q (idx %d)\n%s", + order[i], idx(order[i]), order[i-1], idx(order[i-1]), out) + } + } +} + +func TestPGNRenderer_DoesNotMutateGame(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + g.MoveTree().Root().Children()[0].SetComment("a comment") + g.AddTagPair("Custom", "value") + + before := g.String() + beforePos := g.Position().XFENString() + _ = chess.DefaultPGNRenderer.Render(g) + if g.String() != before { + t.Errorf("Render mutated game string:\nbefore:\n%s\nafter:\n%s", before, g.String()) + } + if g.Position().XFENString() != beforePos { + t.Errorf("Render mutated position: before %q after %q", beforePos, g.Position().XFENString()) + } +} + +func TestPGNRenderer_RoundTripsVariations(t *testing.T) { + pgn := withMinimalTags("1. e4 {main} e5 (1...c5 {sicilian} 2. Nf3) 2. Nf3 *") + g := mustParseSingleGame(t, pgn) + + first := chess.DefaultPGNRenderer.Render(g) + reparsed := mustParseSingleGame(t, first) + second := chess.DefaultPGNRenderer.Render(reparsed) + + if first != second { + t.Fatalf("render not idempotent for variations:\nfirst:\n%s\nsecond:\n%s", first, second) + } + if !strings.Contains(first, "c5") { + t.Fatalf("variation move c5 missing from render: %q", first) + } +} + +func TestPGNRenderer_IsIdempotentOnAnnotations(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + g.MoveTree().Root().Children()[0].SetComment("best move") + if err := g.MoveTree().Root().Children()[0].SetNAGs([]string{"$1"}); err != nil { + t.Fatal(err) + } + if _, err := g.PushMove("e5", nil); err != nil { + t.Fatal(err) + } + + first := chess.DefaultPGNRenderer.Render(g) + reparsed := chess.NewGame(mustPGNOption(t, first)) + second := chess.DefaultPGNRenderer.Render(reparsed) + + if first != second { + t.Fatalf("render not idempotent:\nfirst:\n%s\nsecond:\n%s", first, second) + } +} + +func TestPGNRenderer_WritesNAGsBeforeComments(t *testing.T) { + g := chess.NewGame() + if _, err := g.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + move := g.MoveTree().Root().Children()[0] + move.SetComment("best move") + // Multiple NAGs are kept in order and rendered as canonical "$N" before + // the comment, even when imported via symbolic spellings. + if err := move.SetNAGs([]string{"!", "$1406"}); err != nil { + t.Fatal(err) + } + + out := chess.DefaultPGNRenderer.Render(g) + if !strings.Contains(out, "e4 $1 $1406 {best move}") { + t.Fatalf("expected NAGs before comment, got:\n%s", out) + } +} diff --git a/pgn_result_test.go b/pgn_result_test.go new file mode 100644 index 00000000..e71e8911 --- /dev/null +++ b/pgn_result_test.go @@ -0,0 +1,85 @@ +package chess_test + +import ( + "errors" + "strings" + "testing" + + "github.com/corentings/chess/v3" +) + +func TestPGN_ResolvesOutcomeFromTagTokenAndBoard(t *testing.T) { + moves := "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6" + scholarMate := "1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#" + + tests := []struct { + name string + pgn string + wantErr bool + wantOutcome chess.Outcome + wantMethod chess.Method + }{ + {"tag_white_won", "[Result \"1-0\"]\n\n" + moves + " *", false, chess.WhiteWon, chess.NoMethod}, + {"tag_draw", "[Result \"1/2-1/2\"]\n\n" + moves + " *", false, chess.Draw, chess.NoMethod}, + {"tag_black_won", "[Result \"0-1\"]\n\n" + moves + " *", false, chess.BlackWon, chess.NoMethod}, + {"token_white_won", moves + " 1-0", false, chess.WhiteWon, chess.NoMethod}, + {"token_black_won", moves + " 0-1", false, chess.BlackWon, chess.NoMethod}, + {"token_no_outcome", moves + " *", false, chess.NoOutcome, chess.NoMethod}, + {"token_draw", moves + " 1/2-1/2", false, chess.Draw, chess.NoMethod}, + {"tag_token_agree", "[Result \"1-0\"]\n\n" + moves + " 1-0", false, chess.WhiteWon, chess.NoMethod}, + {"tag_token_conflict", "[Result \"0-1\"]\n\n" + moves + " 1-0", true, chess.NoOutcome, chess.NoMethod}, + {"board_checkmate_conflicts_with_tag", "[Result \"0-1\"]\n\n" + scholarMate + " 1-0", true, chess.NoOutcome, chess.NoMethod}, + {"board_checkmate_agrees_with_tag", "[Result \"1-0\"]\n\n" + scholarMate + " 1-0", false, chess.WhiteWon, chess.Checkmate}, + {"tag_overrides_star_token", "[Result \"0-1\"]\n\n" + moves + " *", false, chess.BlackWon, chess.NoMethod}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, err := chess.ParsePGN(strings.NewReader(tt.pgn)) + if tt.wantErr { + var pe *chess.ParserError + if !errors.As(err, &pe) { + t.Fatalf("expected *ParserError, got %T (%v)", err, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := g.Outcome(); got != tt.wantOutcome { + t.Errorf("Outcome() = %v, want %v", got, tt.wantOutcome) + } + if got := g.Method(); got != tt.wantMethod { + t.Errorf("Method() = %v, want %v", got, tt.wantMethod) + } + }) + } +} + +func TestPGNDrawMovetextTokenRecognized(t *testing.T) { + g, err := chess.ParsePGN(strings.NewReader("1. e4 e5 1/2-1/2")) + if err != nil { + t.Fatal(err) + } + if got := g.Outcome(); got != chess.Draw { + t.Errorf("Outcome() = %v, want %v", got, chess.Draw) + } + if got := g.Method(); got != chess.NoMethod { + t.Errorf("Method() = %v, want %v", got, chess.NoMethod) + } +} + +func TestPGNTerminalVariationDoesNotSetMainLineOutcome(t *testing.T) { + g, err := chess.ParsePGN(strings.NewReader(` +[Result "*"] + +(1. f3 e5 2. g4 Qh4#) 1. e4 e5 *`)) + if err != nil { + t.Fatal(err) + } + if got := g.Outcome(); got != chess.NoOutcome { + t.Fatalf("Outcome() = %v, want %v", got, chess.NoOutcome) + } + if got := g.Method(); got != chess.NoMethod { + t.Fatalf("Method() = %v, want %v", got, chess.NoMethod) + } +} diff --git a/pgn_test.go b/pgn_test.go index 8892366f..737877b8 100644 --- a/pgn_test.go +++ b/pgn_test.go @@ -1,10 +1,14 @@ package chess import ( + "bytes" + "context" + "errors" "fmt" "io" "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -68,12 +72,36 @@ var _ = []commentTest{ func BenchmarkPGN(b *testing.B) { pgn := mustParsePGN("fixtures/pgns/0001.pgn") b.ResetTimer() - for n := 0; n < b.N; n++ { + for range b.N { opt, _ := PGN(strings.NewReader(pgn)) NewGame(opt) } } +func TestNewParserSharesStartingPosition(t *testing.T) { + parser := NewParser(nil) + if parser.game.currentPosition() != parser.game.MoveTree().Root().position { + t.Fatal("parser game and root move should share the starting position") + } +} + +type errorTokenSource struct { + err error +} + +func (s errorTokenSource) NextToken() (Token, error) { + return Token{}, s.err +} + +func TestParserReportsInitialTokenSourceError(t *testing.T) { + wantErr := errors.New("token source failed") + + _, err := newParserFromSource(errorTokenSource{err: wantErr}).Parse() + if !errors.Is(err, wantErr) { + t.Fatalf("Parse() error = %v, want %v", err, wantErr) + } +} + func mustParsePGN(fname string) string { f, err := os.Open(fname) if err != nil { @@ -87,11 +115,17 @@ func mustParsePGN(fname string) string { return string(b) } +func isKnownInconsistentPgn(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "conflicts with") +} + func TestGamesFromPGN(t *testing.T) { for idx, test := range validPGNs { reader := strings.NewReader(test.PGN) - scanner := NewScanner(reader) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(reader).Decode() if err != nil { t.Fatalf("fail to parse game from valid pgn %d: %s", idx, err.Error()) } @@ -105,8 +139,7 @@ func TestGameWithVariations(t *testing.T) { pgn := mustParsePGN("fixtures/pgns/variations.pgn") reader := strings.NewReader(pgn) - scanner := NewScanner(reader) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(reader).Decode() if err != nil { t.Fatalf("fail to parse game from pgn: %s", err.Error()) } @@ -123,7 +156,7 @@ func TestGameWithVariations(t *testing.T) { t.Fatalf("game output blank") } - const expectedLastLine = "1. e4 (1. e3 e5) 1... e5 (1... d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 (4. Nf3 Nbd7) 4... dxe5 5. Qxd8+ Kxd8) 2. Nf3 (2. Nc3 Nf6 3. f4) 2... Nc6 3. d4 exd4 4. Nxd4 *" + const expectedLastLine = "1. e4 (1. e3 e5) 1... e5 (1... d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 (4. Nf3 Nbd7) 4... dxe5 5. Qxd8+ Kxd8) 2. Nf3 (2. Nc3 Nf6 3. f4) 2... Nc6 3. d4 exd4 4. Nxd4 1-0" lastLine := lines[len(lines)-1] if lastLine != expectedLastLine { t.Fatalf("game output not correct\n\tExpected:'%v'\n\tGot: '%v'\n", @@ -135,9 +168,7 @@ func TestSingleGameFromPGN(t *testing.T) { pgn := mustParsePGN("fixtures/pgns/single_game.pgn") reader := strings.NewReader(pgn) - scanner := NewScanner(reader) - - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(reader).Decode() if err != nil { t.Fatalf("fail to read games from valid pgn: %s", err.Error()) } @@ -179,7 +210,7 @@ func TestSingleGameFromPGN(t *testing.T) { t.Fatalf("game moves are not correct, expected 6, got %d", len(game.Moves())) } - for i, move := range game.Moves() { + for i, move := range game.MoveTree().MainLine() { // check move number for each move // Get the full move number fullMoveNumber := (i / 2) + 1 @@ -193,38 +224,41 @@ func TestSingleGameFromPGN(t *testing.T) { } // print all moves - moves := game.Moves() + moves := game.MoveTree().MainLine() - if moves[4].comments == "" { - t.Fatalf("game move 6 is not correct, expected comment, got %s", moves[5].comments) + if moves[4].Comments() == "" { + t.Fatalf("game move 6 is not correct, expected comment, got %s", moves[5].Comments()) } } func TestBigPgn(t *testing.T) { pgn := mustParsePGN("fixtures/pgns/big.pgn") - reader := strings.NewReader(pgn) - scanner := NewScanner(reader) count := 0 - - for scanner.HasNext() { + for record, err := range PGNRecords(context.Background(), strings.NewReader(pgn)) { count++ t.Run(fmt.Sprintf("big pgn : %d", count), func(t *testing.T) { - scannedGame, err := scanner.ScanGame() if err != nil { - t.Fatalf("fail to scan game from valid pgn: %s", err.Error()) + if isKnownInconsistentPgn(err) { + t.Skipf("skipping inconsistent real-world PGN: %s", err.Error()) + return + } + t.Fatalf("fail to read record: %s", err.Error()) } - tokens, err := TokenizeGame(scannedGame) + raw := record.Raw + tokens, err := TokenizeGame(&GameScanned{Raw: raw}) if err != nil { t.Fatalf("fail to tokenize game from valid pgn: %s", err.Error()) } - raw := scannedGame.Raw - parser := NewParser(tokens) game, err := parser.Parse() if err != nil { + if isKnownInconsistentPgn(err) { + t.Skipf("skipping inconsistent real-world PGN: %s", err.Error()) + return + } t.Fatalf("fail to read games from valid pgn: %s | %s", err.Error(), raw[:min(200, len(raw))]) } @@ -241,7 +275,7 @@ func TestBigPgn(t *testing.T) { t.Skip("Skipping test for From Position") } - for i, move := range game.Moves() { + for i, move := range game.MoveTree().MainLine() { // check move number for each move // Get the full move number fullMoveNumber := (i / 2) + 1 @@ -256,32 +290,21 @@ func TestBigPgn(t *testing.T) { } func TestBigBigPgn(t *testing.T) { - t.Skip("This test is too slow") pgn := mustParsePGN("fixtures/pgns/big_big.pgn") reader := strings.NewReader(pgn) - scanner := NewScanner(reader) + dec := NewPGNDecoder(reader) count := 0 - for scanner.HasNext() { + for { count++ + game, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } t.Run(fmt.Sprintf("bigbig pgn : %d", count), func(t *testing.T) { - scannedGame, err := scanner.ScanGame() if err != nil { - t.Fatalf("fail to scan game from valid pgn: %s", err.Error()) - } - - tokens, err := TokenizeGame(scannedGame) - if err != nil { - t.Fatalf("fail to tokenize game from valid pgn: %s", err.Error()) - } - - raw := scannedGame.Raw - - parser := NewParser(tokens) - game, err := parser.Parse() - if err != nil { - t.Fatalf("fail to read games from valid pgn: %s | %s", err.Error(), raw[:min(200, len(raw))]) + t.Fatalf("fail to read games from valid pgn: %s", err.Error()) } if game == nil { @@ -295,8 +318,7 @@ func TestCompleteGame(t *testing.T) { pgn := mustParsePGN("fixtures/pgns/complete_game.pgn") reader := strings.NewReader(pgn) - scanner := NewScanner(reader) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(reader).Decode() if err != nil { t.Fatalf("fail to read games from valid pgn: %s", err.Error()) } @@ -338,23 +360,23 @@ func TestCompleteGame(t *testing.T) { t.Fatalf("game move 1 is not correct, expected d4, got %s", game.Moves()[0].String()) } - if game.Moves()[0].comments != "" { - t.Fatalf("game move 1 is not correct, expected no comment, got %s", game.Moves()[0].comments) + if game.MoveTree().MainLine()[0].Comments() != "" { + t.Fatalf("game move 1 is not correct, expected no comment, got %s", game.MoveTree().MainLine()[0].Comments()) } // print all moves - moves := game.Moves() + moves := game.MoveTree().MainLine() - if game.Moves()[0].command["eval"] != "0.17" { - t.Fatalf("game move 1 is not correct, expected eval, got %s", game.Moves()[0].command["eval"]) + if got, ok := game.MoveTree().MainLine()[0].GetCommand("eval"); !ok || got != "0.17" { + t.Fatalf("game move 1 is not correct, expected eval, got %s", got) } - if moves[6].comments != "A57 Benko Gambit Declined: Main Line" { - t.Fatalf("game move 4 is not correct, expected comment, got %s", moves[6].comments) + if moves[6].Comments() != "A57 Benko Gambit Declined: Main Line" { + t.Fatalf("game move 4 is not correct, expected comment, got %s", moves[6].Comments()) } - if moves[44].nag != "?!" { - t.Fatalf("game move 44 is not correct, expected nag '!?', got %s", moves[44].nag) + if got := moves[44].NAGs(); !reflect.DeepEqual(got, []string{"$6"}) { + t.Fatalf("game move 44 is not correct, expected NAG '$6', got %v", got) } } @@ -364,10 +386,10 @@ func TestLichessMultipleCommand(t *testing.T) { t.Fatalf("Failed to open fixture file: %v", err) } - scanner := NewScanner(file) + dec := NewPGNDecoder(file) // Test first game - game, err := scanner.ParseNext() + game, err := dec.Decode() if err != nil { t.Fatalf("fail to read games from valid pgn: %s", err.Error()) } @@ -381,27 +403,27 @@ func TestLichessMultipleCommand(t *testing.T) { } // Check if move one has the correct command - if game.Moves()[0].command["eval"] != "0.0" { - t.Fatalf("game move 1 is not correct, expected eval, got %s", game.Moves()[0].command["eval"]) + if got, ok := game.MoveTree().MainLine()[0].GetCommand("eval"); !ok || got != "0.0" { + t.Fatalf("game move 1 is not correct, expected eval, got %s", got) } // Check for clock also - if game.Moves()[0].command["clk"] != "0:03:00" { - t.Fatalf("game move 1 is not correct, expected clock, got %s", game.Moves()[0].command["clk"]) + if got, ok := game.MoveTree().MainLine()[0].GetCommand("clk"); !ok || got != "0:03:00" { + t.Fatalf("game move 1 is not correct, expected clock, got %s", got) } // Check move 5 for comment and eval - if game.Moves()[4].comments != "E00 Catalan Opening" { - t.Fatalf("game move 5 is not correct, expected comment, got %s", game.Moves()[4].comments) + if game.MoveTree().MainLine()[4].Comments() != "E00 Catalan Opening" { + t.Fatalf("game move 5 is not correct, expected comment, got %s", game.MoveTree().MainLine()[4].Comments()) } - if game.Moves()[4].command["eval"] != "0.14" { - t.Fatalf("game move 5 is not correct, expected eval, got %s", game.Moves()[4].command["eval"]) + if got, ok := game.MoveTree().MainLine()[4].GetCommand("eval"); !ok || got != "0.14" { + t.Fatalf("game move 5 is not correct, expected eval, got %s", got) } // check for clock - 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"]) + if got, ok := game.MoveTree().MainLine()[4].GetCommand("clk"); !ok || got != "0:02:58" { + t.Fatalf("game move 5 is not correct, expected clock, got %s", got) } } @@ -416,28 +438,27 @@ func TestParseMoveWithNAGAndComment(t *testing.T) { 1. e4 $1 {Good move} e5 {Solid} $2 2. Nf3 $3 {Another comment} Nc6 $4 {Yet another}` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } - moves := game.Moves() + moves := game.MoveTree().MainLine() if len(moves) < 4 { t.Fatalf("expected at least 4 moves, got %d", len(moves)) } - if moves[0].nag == "" || moves[0].comments == "" { - t.Errorf("move 1 should have both NAG and comment, got nag: '%s', comment: '%s'", moves[0].nag, moves[0].comments) + if len(moves[0].NAGs()) == 0 || moves[0].Comments() == "" { + t.Errorf("move 1 should have both NAG and comment, got nags: '%v', comment: '%s'", moves[0].NAGs(), moves[0].Comments()) } - if moves[1].nag == "" || moves[1].comments == "" { - t.Errorf("move 2 should have both NAG and comment, got nag: '%s', comment: '%s'", moves[1].nag, moves[1].comments) + if len(moves[1].NAGs()) == 0 || moves[1].Comments() == "" { + t.Errorf("move 2 should have both NAG and comment, got nags: '%v', comment: '%s'", moves[1].NAGs(), moves[1].Comments()) } - if moves[2].nag == "" || moves[2].comments == "" { - t.Errorf("move 3 should have both NAG and comment, got nag: '%s', comment: '%s'", moves[2].nag, moves[2].comments) + if len(moves[2].NAGs()) == 0 || moves[2].Comments() == "" { + t.Errorf("move 3 should have both NAG and comment, got nags: '%v', comment: '%s'", moves[2].NAGs(), moves[2].Comments()) } - if moves[3].nag == "" || moves[3].comments == "" { - t.Errorf("move 4 should have both NAG and comment, got nag: '%s', comment: '%s'", moves[3].nag, moves[3].comments) + if len(moves[3].NAGs()) == 0 || moves[3].Comments() == "" { + t.Errorf("move 4 should have both NAG and comment, got nags: '%v', comment: '%s'", moves[3].NAGs(), moves[3].Comments()) } } @@ -452,21 +473,20 @@ func TestVariationComments(t *testing.T) { 1. e4 {main line comment} e5 (1... d5 {variation comment on d5} 2. exd5 {variation comment on exd5} Qxd5) 2. Nf3 Nc6 *` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } // Check main line comment on 1. e4 - mainMoves := game.Moves() - if mainMoves[0].comments != "main line comment" { - t.Errorf("expected main line comment on e4, got %q", mainMoves[0].comments) + mainMoves := game.MoveTree().MainLine() + if mainMoves[0].Comments() != "main line comment" { + t.Errorf("expected main line comment on e4, got %q", mainMoves[0].Comments()) } // 1... e5 is mainMoves[1], and its parent (rootMove child for e4) should have // a second child which is the variation 1... d5 - e4Move := game.rootMove.children[0] // 1. e4 + e4Move := game.MoveTree().Root().children[0] // 1. e4 if len(e4Move.children) < 2 { t.Fatalf("expected at least 2 children on e4 (main line e5 + variation d5), got %d", len(e4Move.children)) } @@ -474,8 +494,8 @@ func TestVariationComments(t *testing.T) { // First child is the main line 1... e5 // Second child is the variation 1... d5 d5Move := e4Move.children[1] - if d5Move.comments != "variation comment on d5" { - t.Errorf("expected 'variation comment on d5' on 1...d5, got %q", d5Move.comments) + if d5Move.Comments() != "variation comment on d5" { + t.Errorf("expected 'variation comment on d5' on 1...d5, got %q", d5Move.Comments()) } // d5's first child should be 2. exd5 @@ -483,8 +503,8 @@ func TestVariationComments(t *testing.T) { t.Fatalf("expected children on d5 variation move") } exd5Move := d5Move.children[0] - if exd5Move.comments != "variation comment on exd5" { - t.Errorf("expected 'variation comment on exd5' on 2.exd5, got %q", exd5Move.comments) + if exd5Move.Comments() != "variation comment on exd5" { + t.Errorf("expected 'variation comment on exd5' on 2.exd5, got %q", exd5Move.Comments()) } } @@ -499,40 +519,39 @@ func TestVariationNAGs(t *testing.T) { 1. e4 e5 (1... d5 $1 {great move} 2. exd5 $6 Qxd5 $2) 2. Nf3 *` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } // Find the variation: 1... d5 - e4Move := game.rootMove.children[0] + e4Move := game.MoveTree().Root().children[0] if len(e4Move.children) < 2 { t.Fatalf("expected variation on e4, got %d children", len(e4Move.children)) } d5Move := e4Move.children[1] - if d5Move.nag != "$1" { - t.Errorf("expected NAG '$1' on 1...d5, got %q", d5Move.nag) + if got := d5Move.NAGs(); !reflect.DeepEqual(got, []string{"$1"}) { + t.Errorf("expected NAG '$1' on 1...d5, got %v", got) } - if d5Move.comments != "great move" { - t.Errorf("expected comment 'great move' on 1...d5, got %q", d5Move.comments) + if d5Move.Comments() != "great move" { + t.Errorf("expected comment 'great move' on 1...d5, got %q", d5Move.Comments()) } if len(d5Move.children) == 0 { t.Fatalf("expected children on d5") } exd5Move := d5Move.children[0] - if exd5Move.nag != "$6" { - t.Errorf("expected NAG '$6' on 2.exd5, got %q", exd5Move.nag) + if got := exd5Move.NAGs(); !reflect.DeepEqual(got, []string{"$6"}) { + t.Errorf("expected NAG '$6' on 2.exd5, got %v", got) } if len(exd5Move.children) == 0 { t.Fatalf("expected children on exd5") } qxd5Move := exd5Move.children[0] - if qxd5Move.nag != "$2" { - t.Errorf("expected NAG '$2' on 2...Qxd5, got %q", qxd5Move.nag) + if got := qxd5Move.NAGs(); !reflect.DeepEqual(got, []string{"$2"}) { + t.Errorf("expected NAG '$2' on 2...Qxd5, got %v", got) } } @@ -547,26 +566,25 @@ func TestVariationCommands(t *testing.T) { 1. e4 e5 (1... d5 {good move [%eval -0.5] [%clk 0:05:00]} 2. exd5) 2. Nf3 *` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } - e4Move := game.rootMove.children[0] + e4Move := game.MoveTree().Root().children[0] if len(e4Move.children) < 2 { t.Fatalf("expected variation on e4, got %d children", len(e4Move.children)) } d5Move := e4Move.children[1] - if d5Move.comments != "good move" { - t.Errorf("expected comment 'good move' on 1...d5, got %q", d5Move.comments) + if d5Move.Comments() != "good move" { + t.Errorf("expected comment 'good move' on 1...d5, got %q", d5Move.Comments()) } - if d5Move.command["eval"] != "-0.5" { - t.Errorf("expected eval command '-0.5' on 1...d5, got %q", d5Move.command["eval"]) + if got, ok := d5Move.GetCommand("eval"); !ok || got != "-0.5" { + t.Errorf("expected eval command '-0.5' on 1...d5, got %q", got) } - if d5Move.command["clk"] != "0:05:00" { - t.Errorf("expected clk command '0:05:00' on 1...d5, got %q", d5Move.command["clk"]) + if got, ok := d5Move.GetCommand("clk"); !ok || got != "0:05:00" { + t.Errorf("expected clk command '0:05:00' on 1...d5, got %q", got) } } @@ -581,21 +599,20 @@ func TestNestedVariationComments(t *testing.T) { 1. e4 e5 2. Nf3 Nc6 3. Bb5 {Ruy Lopez} (3. Bc4 {Italian Game} Nf6 (3... Bc5 {Giuoco Piano}) 4. d3) 3... a6 *` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } // Main line: 3. Bb5 should have comment "Ruy Lopez" - mainMoves := game.Moves() + mainMoves := game.MoveTree().MainLine() // Moves: e4, e5, Nf3, Nc6, Bb5, a6 => index 4 is Bb5 if len(mainMoves) < 5 { t.Fatalf("expected at least 5 main line moves, got %d", len(mainMoves)) } bb5Move := mainMoves[4] - if bb5Move.comments != "Ruy Lopez" { - t.Errorf("expected 'Ruy Lopez' comment on 3.Bb5, got %q", bb5Move.comments) + if bb5Move.Comments() != "Ruy Lopez" { + t.Errorf("expected 'Ruy Lopez' comment on 3.Bb5, got %q", bb5Move.Comments()) } // Variation: 3. Bc4 should have comment "Italian Game" @@ -604,8 +621,8 @@ func TestNestedVariationComments(t *testing.T) { t.Fatalf("expected variation at move 3, got %d children", len(nc6Move.children)) } bc4Move := nc6Move.children[1] - if bc4Move.comments != "Italian Game" { - t.Errorf("expected 'Italian Game' comment on 3.Bc4, got %q", bc4Move.comments) + if bc4Move.Comments() != "Italian Game" { + t.Errorf("expected 'Italian Game' comment on 3.Bc4, got %q", bc4Move.Comments()) } // Nested variation: 3... Bc5 should have comment "Giuoco Piano" @@ -613,8 +630,8 @@ func TestNestedVariationComments(t *testing.T) { t.Fatalf("expected nested variation on Bc4, got %d children", len(bc4Move.children)) } bc5Move := bc4Move.children[1] - if bc5Move.comments != "Giuoco Piano" { - t.Errorf("expected 'Giuoco Piano' comment on 3...Bc5, got %q", bc5Move.comments) + if bc5Move.Comments() != "Giuoco Piano" { + t.Errorf("expected 'Giuoco Piano' comment on 3...Bc5, got %q", bc5Move.Comments()) } } @@ -629,17 +646,13 @@ func TestRoundTripWithVariationsAndCommandAnnotations(t *testing.T) { 1. e4 e5 2. Nf3 Nc6 3. Bb5 {Ruy Lopez} (3. Bc4 {Italian Game} Nf6 (3... Bc5 {Giuoco Piano}) 4. d3) 3... a6 *` - base := NewScanner(strings.NewReader(pgn)) - if !base.HasNext() { - t.Fatal("expected one game in input pgn") - } - game, err := base.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("failed to parse input pgn: %v", err) } - var walk func(*Move) - walk = func(m *Move) { + var walk func(*MoveNode) + walk = func(m *MoveNode) { if m == nil { return } @@ -650,7 +663,7 @@ func TestRoundTripWithVariationsAndCommandAnnotations(t *testing.T) { walk(child) } } - walk(game.GetRootMove()) + walk(game.MoveTree().Root()) roundTrip := game.String() @@ -673,7 +686,7 @@ func TestPGNAnnotationFidelityRoundTrip(t *testing.T) { pgn := withMinimalTags(`1. e4 {Good move [%clk 0:05:00]} {second [%eval 0.25]} *`) game := mustParseSingleGame(t, pgn) - move := game.Moves()[0] + move := game.MoveTree().MainLine()[0] if move.Comments() != "Good move second" { t.Fatalf("expected flattened comments, got %q", move.Comments()) } @@ -687,7 +700,7 @@ func TestPGNAnnotationFidelityRoundTrip(t *testing.T) { } reparsed := mustParseSingleGame(t, roundTrip) - blocks := reparsed.Moves()[0].CommentBlocks() + blocks := reparsed.MoveTree().MainLine()[0].CommentBlocks() if len(blocks) != 2 { t.Fatalf("expected 2 comment blocks, got %#v", blocks) } @@ -703,7 +716,7 @@ func TestPGNAnnotationFidelityPreservesOrderAndDuplicateCommands(t *testing.T) { game := mustParseSingleGame(t, pgn) roundTrip := game.String() reparsed := mustParseSingleGame(t, roundTrip) - move := reparsed.Moves()[0] + move := reparsed.MoveTree().MainLine()[0] if got, ok := move.GetCommand("clk"); !ok || got != "0:04:59" { t.Fatalf("expected last duplicate clk command, got %q, %v", got, ok) @@ -726,7 +739,7 @@ func TestPGNAnnotationFidelityPreservesOrderAndDuplicateCommands(t *testing.T) { func TestPGNAnnotationFidelityCommandUsesFirstParameter(t *testing.T) { game := mustParseSingleGame(t, withMinimalTags(`1. e4 {[%command 1:45:12,Nf6,"very interesting, but wrong"]} *`)) - move := game.Moves()[0] + move := game.MoveTree().MainLine()[0] if got, ok := move.GetCommand("command"); !ok || got != "1:45:12" { t.Fatalf("expected first command parameter, got %q, %v", got, ok) @@ -746,7 +759,7 @@ func TestPGNAnnotationFidelityVariationsAndExpansion(t *testing.T) { roundTrip := game.String() reparsed := mustParseSingleGame(t, roundTrip) - e4 := reparsed.Moves()[0] + e4 := reparsed.MoveTree().MainLine()[0] if len(e4.Children()) < 2 { t.Fatalf("expected e4 variation, got %d children", len(e4.Children())) } @@ -758,9 +771,12 @@ func TestPGNAnnotationFidelityVariationsAndExpansion(t *testing.T) { assertCommentItem(t, blocks[0].Items[0], CommentText, "Sicilian", "", "") assertCommentItem(t, blocks[0].Items[1], CommentCommand, "", "eval", "0.12") - scanner := NewScanner(strings.NewReader(roundTrip), WithExpandVariations()) - for scanner.HasNext() { - if _, err := scanner.ParseNext(); err != nil { + dec := NewPGNDecoder(strings.NewReader(roundTrip), WithPGNExpandVariations()) + for { + if _, err := dec.Decode(); err != nil { + if errors.Is(err, io.EOF) { + break + } t.Fatalf("expanded annotated variation should parse: %v", err) } } @@ -768,10 +784,10 @@ func TestPGNAnnotationFidelityVariationsAndExpansion(t *testing.T) { func TestPGNAnnotationFidelityLegacyAPIsAndDefensiveCopies(t *testing.T) { game := NewGame() - if err := game.PushMove("e4", nil); err != nil { + if _, err := game.PushMove("e4", nil); err != nil { t.Fatal(err) } - move := game.Moves()[0] + move := game.MoveTree().MainLine()[0] move.SetComment("Good move") move.SetCommand("clk", "0:05:00") move.SetCommand("clk", "0:04:59") @@ -788,7 +804,7 @@ func TestPGNAnnotationFidelityLegacyAPIsAndDefensiveCopies(t *testing.T) { } parsed := mustParseSingleGame(t, withMinimalTags(`1. e4 {Good [%clk 0:05:00]} *`)) - parsedMove := parsed.Moves()[0] + parsedMove := parsed.MoveTree().MainLine()[0] blocks := parsedMove.CommentBlocks() blocks[0].Items[0].Text = "mutated" blocks[0].Items = append(blocks[0].Items, CommentItem{Kind: CommentCommand, Key: "eval", Value: "9"}) @@ -800,10 +816,30 @@ func TestPGNAnnotationFidelityLegacyAPIsAndDefensiveCopies(t *testing.T) { } } +// TestPGNCommentCommandMergingIssue104 is a regression test for issue #104: +// a parsed text comment followed by SetCommand must merge into a single +// comment block rather than emitting two separate {} blocks. v3's +// SetCommand appends to the last block when no matching key exists, so +// this pins the single-block contract. See the reporter's v2 scenario. +func TestPGNCommentCommandMergingIssue104(t *testing.T) { + game := mustParseSingleGame(t, withMinimalTags(`1. e4 {Ruy Lopez} *`)) + move := game.MoveTree().MainLine()[0] + move.SetCommand("eval", "0.25") + + roundTrip := game.String() + want := "{Ruy Lopez [%eval 0.25]}" + if !strings.Contains(roundTrip, want) { + t.Fatalf("expected merged single block %q in:\n%s", want, roundTrip) + } + // The two-block failure mode would look like "{Ruy Lopez} {[%eval 0.25]}". + if strings.Contains(roundTrip, "{Ruy Lopez} {") { + t.Fatalf("expected single merged block, got two separate blocks:\n%s", roundTrip) + } +} + func mustParseSingleGame(t *testing.T, pgn string) *Game { t.Helper() - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("failed to parse pgn: %v", err) } @@ -892,6 +928,49 @@ func TestParserAnnotationErrors(t *testing.T) { t.Fatal("expected unterminated command error") } }) + + t.Run("ParseMoveJoinsMismatchReasons", func(t *testing.T) { + // Position: white rook on a1 and bishop on c1, both can reach e1. + // Asking for "Nxe1" (knight captures e1) should fail with reasons + // from both candidates joined into a single ParserError. + opt, err := FEN("1k6/8/8/8/8/8/8/1BR4K w - - 0 1") + if err != nil { + t.Fatalf("FEN: %v", err) + } + g := NewGame(opt) + parser := NewParser([]Token{ + {Type: PIECE, Value: "N"}, + {Type: CAPTURE, Value: "x"}, + {Type: SQUARE, Value: "e1"}, + }) + parser.game = g + _, err = parser.parseMove() + if err == nil { + t.Fatal("expected parser error for Nxe1") + } + var pe *ParserError + if !errors.As(err, &pe) { + t.Fatalf("expected *ParserError, got %T: %v", err, err) + } + if !strings.Contains(pe.Message, "piece type mismatch") { + t.Errorf("expected joined message to contain 'piece type mismatch', got %q", pe.Message) + } + }) + + t.Run("DuplicateCommandName", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommandStart, Value: "[%"}, + {Type: CommandName, Value: "clk"}, + {Type: CommandParam, Value: "0:05:00"}, + {Type: CommandName, Value: "eval"}, + {Type: CommandParam, Value: "0.24"}, + {Type: CommandEnd, Value: "]"}, + }) + _, err := parser.parseCommand() + if err == nil { + t.Fatal("expected parseCommand error for duplicate command name") + } + }) } func TestParserVariationErrors(t *testing.T) { @@ -932,6 +1011,9 @@ func TestParserVariationErrors(t *testing.T) { func TestParseResultDraw(t *testing.T) { parser := NewParser([]Token{{Type: RESULT, Value: "1/2-1/2"}}) parser.parseResult() + if err := parser.resolveOutcome(); err != nil { + t.Fatal(err) + } if parser.game.Outcome() != Draw { t.Fatalf("expected draw outcome, got %s", parser.game.Outcome()) } @@ -948,38 +1030,37 @@ func TestVariationMoveNumbers(t *testing.T) { 1. e4 e5 2. Nf3 Nc6 3. Bb5 (3. Bc4 Nf6 4. d3) a6 4. Ba4 Nf6 5. O-O Be7 1-0` - scanner := NewScanner(strings.NewReader(pgn)) - game, err := scanner.ParseNext() + game, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { t.Fatalf("fail to parse game: %v", err) } // Helper to recursively check move numbers - var checkMoveNumbers func(m *Move, expectedNum int) - checkMoveNumbers = func(m *Move, expectedNum int) { + var checkMoveNumbers func(m *MoveNode, expectedNum int) + checkMoveNumbers = func(m *MoveNode, expectedNum int) { fullMove := (expectedNum-1)/2 + 1 for _, child := range m.children { - if child.number != 0 && int(child.Ply()) != expectedNum { - t.Errorf("move %s: expected move number %d, got %d", child.String(), expectedNum, child.Ply()) + if child.number != 0 && child.Ply() != expectedNum { + t.Errorf("move %s: expected move number %d, got %d", child.Move().String(), expectedNum, child.Ply()) } if child.FullMoveNumber() != fullMove { - t.Errorf("move %s: expected full move number %d, got %d", child.String(), fullMove, child.FullMoveNumber()) + t.Errorf("move %s: expected full move number %d, got %d", child.Move().String(), fullMove, child.FullMoveNumber()) } - // If this move starts a variation, the move number should be correct for the branch + // If this move starts a variation, the move number should be correct for the variation checkMoveNumbers(child, expectedNum+1) } } - t.Logf("Root move number: %d", game.rootMove.number) - t.Logf("Root move ply: %d", game.rootMove.Ply()) - t.Logf("Root move full move number: %d", game.rootMove.FullMoveNumber()) - t.Logf("Second move: %v", game.rootMove.Children()[0]) + t.Logf("Root move number: %d", game.MoveTree().Root().number) + t.Logf("Root move ply: %d", game.MoveTree().Root().Ply()) + t.Logf("Root move full move number: %d", game.MoveTree().Root().FullMoveNumber()) + t.Logf("Second move: %v", game.MoveTree().Root().Children()[0]) // Mainline starts at move 1 - checkMoveNumbers(game.rootMove, 1) + checkMoveNumbers(game.MoveTree().Root(), 1) - // Check specific variation branch - mainMoves := game.Moves() + // Check specific variation + mainMoves := game.MoveTree().MainLine() if len(mainMoves) < 3 { t.Fatalf("expected at least 3 mainline moves, got %d", len(mainMoves)) } @@ -1007,16 +1088,115 @@ func BenchmarkPGNWithVariations(b *testing.B) { 1. e4 e5 2. Nf3 Nc6 3. Bb5 (3. Bc4 Nf6 4. d3) a6 4. Ba4 Nf6 5. O-O Be7 1-0` - scanner := NewScanner(strings.NewReader(pgn)) - b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { - // Reset scanner for each iteration - scanner = NewScanner(strings.NewReader(pgn)) - _, err := scanner.ParseNext() + for range b.N { + _, err := NewPGNDecoder(strings.NewReader(pgn)).Decode() if err != nil { b.Fatalf("parse error: %v", err) } } } + +// readPGNFixture returns the raw bytes of a fixture PGN file. +func readPGNFixture(name string) []byte { + data, err := os.ReadFile(filepath.Join("fixtures", "pgns", name)) + if err != nil { + panic(err) + } + return data +} + +// pgnFixtureMeta reports size and game count for a fixture file. +type pgnFixtureMeta struct { + path string + size int64 + games int + perGame int // approximate bytes per game +} + +func pgnMeta(name string) pgnFixtureMeta { + data := readPGNFixture(name) + games := bytes.Count(data, []byte("[Event ")) + return pgnFixtureMeta{ + path: filepath.Join("fixtures", "pgns", name), + size: int64(len(data)), + games: games, + perGame: len(data) / max(games, 1), + } +} + +// BenchmarkPGN_Stream_Big parses the 1000-game lichess big.pgn fixture once per +// iteration via the streaming PGNDecoder API. Equivalent in workload to the +// chess-library example (full SAN validation + move tree building). +func BenchmarkPGN_Stream_Big(b *testing.B) { + benchStreamFixture(b, "big.pgn") +} + +// BenchmarkPGN_Stream_BigBig parses the 10000-game lichess big_big.pgn fixture. +func BenchmarkPGN_Stream_BigBig(b *testing.B) { + benchStreamFixture(b, "big_big.pgn") +} + +// BenchmarkPGN_Stream_Variations parses a small PGN rich in variations to +// exercise the variation path separately. +func BenchmarkPGN_Stream_Variations(b *testing.B) { + pgn := []byte(`[Event "Variation Benchmark"] +[Site "Internet"] +[Date "2023.12.06"] +[Round "1"] +[White "Player1"] +[Black "Player2"] +[Result "1-0"] + +1. e4 e5 2. Nf3 Nc6 3. Bb5 (3. Bc4 Nf6 4. d3) a6 4. Ba4 Nf6 5. O-O Be7 1-0`) + + b.SetBytes(int64(len(pgn))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + dec := NewPGNDecoder(bytes.NewReader(pgn)) + for { + _, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + b.Fatalf("parse error: %v", err) + } + } + } +} + +func benchStreamFixture(b *testing.B, name string) { + b.Helper() + data := readPGNFixture(name) + meta := pgnMeta(name) + b.SetBytes(meta.size) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + dec := NewPGNDecoder(bytes.NewReader(data)) + games := 0 + errs := 0 + for { + _, err := dec.Decode() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + // Real-world lichess data has ~0.7% games with a result + // inconsistent with the board-derivable outcome (e.g. "Black + // wins on time" when the final position is checkmate). + // Skip those rather than aborting the benchmark. + errs++ + continue + } + games++ + } + if games+errs != meta.games { + b.Fatalf("expected %d games total, parsed %d with %d errors", meta.games, games, errs) + } + } +} diff --git a/piece.go b/piece.go index bca3e91c..df07721d 100644 --- a/piece.go +++ b/piece.go @@ -202,45 +202,49 @@ var allPieces = [12]Piece{ BlackKing, BlackQueen, BlackRook, BlackBishop, BlackKnight, BlackPawn, } +//nolint:gochecknoglobals // Immutable lookup tables. +var ( + pieceTypes = [13]PieceType{ + NoPieceType, + King, Queen, Rook, Bishop, Knight, Pawn, + King, Queen, Rook, Bishop, Knight, Pawn, + } + pieceColors = [13]Color{ + NoColor, + White, White, White, White, White, White, + Black, Black, Black, Black, Black, Black, + } +) + // NewPiece returns the piece matching the PieceType and Color. // NoPiece is returned if the PieceType or Color isn't valid. func NewPiece(t PieceType, c Color) Piece { - for _, p := range allPieces { - if p.Color() == c && p.Type() == t { - return p - } + if t < King || t > Pawn { + return NoPiece + } + switch c { + case White: + return Piece(t) + case Black: + return Piece(int(t) + 6) } return NoPiece } // Type returns the type of the piece. func (p Piece) Type() PieceType { - switch p { - case WhiteKing, BlackKing: - return King - case WhiteQueen, BlackQueen: - return Queen - case WhiteRook, BlackRook: - return Rook - case WhiteBishop, BlackBishop: - return Bishop - case WhiteKnight, BlackKnight: - return Knight - case WhitePawn, BlackPawn: - return Pawn + if p < NoPiece || int(p) >= len(pieceTypes) { + return NoPieceType } - return NoPieceType + return pieceTypes[p] } // Color returns the color of the piece. func (p Piece) Color() Color { - switch p { - case WhiteKing, WhiteQueen, WhiteRook, WhiteBishop, WhiteKnight, WhitePawn: - return White - case BlackKing, BlackQueen, BlackRook, BlackBishop, BlackKnight, BlackPawn: - return Black + if p < NoPiece || int(p) >= len(pieceColors) { + return NoColor } - return NoColor + return pieceColors[p] } // String implements the fmt.Stringer interface. @@ -252,7 +256,7 @@ func (p Piece) String() string { } // DarkString is equivalent to String() except colors reversed for terminal -// windows in dark mode +// windows in dark mode. func (p Piece) DarkString() string { return pieceDarkUnicodes[int(p)] } diff --git a/piece_test.go b/piece_test.go index 5b807ee0..9d0da921 100644 --- a/piece_test.go +++ b/piece_test.go @@ -1,47 +1,347 @@ -package chess +package chess_test import ( "bytes" "testing" + + "github.com/corentings/chess/v3" ) -func TestPieceString(t *testing.T) { - tables := []struct { - piece PieceType - str string +func TestPieceType_FENString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pt chess.PieceType + want string + }{ + {"king", chess.King, "k"}, + {"queen", chess.Queen, "q"}, + {"rook", chess.Rook, "r"}, + {"bishop", chess.Bishop, "b"}, + {"knight", chess.Knight, "n"}, + {"pawn", chess.Pawn, "p"}, + {"none", chess.NoPieceType, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.pt.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestPieceType_Bytes(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pt chess.PieceType + want []byte + }{ + {"king", chess.King, []byte{'k'}}, + {"queen", chess.Queen, []byte{'q'}}, + {"rook", chess.Rook, []byte{'r'}}, + {"bishop", chess.Bishop, []byte{'b'}}, + {"knight", chess.Knight, []byte{'n'}}, + {"pawn", chess.Pawn, []byte{'p'}}, + {"none", chess.NoPieceType, []byte{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if !bytes.Equal(tt.pt.Bytes(), tt.want) { + t.Errorf("Bytes() = %v, want %v", tt.pt.Bytes(), tt.want) + } + }) + } +} + +func TestPieceType_ToPolyglotPromotionValue(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pt chess.PieceType + want int }{ - {King, "k"}, - {Queen, "q"}, - {Rook, "r"}, - {Bishop, "b"}, - {Knight, "n"}, - {Pawn, "p"}, + {"knight", chess.Knight, 1}, + {"bishop", chess.Bishop, 2}, + {"rook", chess.Rook, 3}, + {"queen", chess.Queen, 4}, + {"king_is_zero", chess.King, 0}, + {"pawn_is_zero", chess.Pawn, 0}, + {"none_is_zero", chess.NoPieceType, 0}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.pt.ToPolyglotPromotionValue(); got != tt.want { + t.Errorf("ToPolyglotPromotionValue() = %d, want %d", got, tt.want) + } + }) + } +} - for _, table := range tables { - if table.piece.String() != table.str { - t.Errorf("String version of piece was incorrect.") - } +func TestPieceType_StringRoundTrip(t *testing.T) { + t.Parallel() + for _, pt := range chess.PieceTypes() { + t.Run(pt.String(), func(t *testing.T) { + t.Parallel() + if got := chess.PieceTypeFromString(pt.String()); got != pt { + t.Errorf("PieceTypeFromString(%q) = %v, want %v", pt.String(), got, pt) + } + if got := chess.PieceTypeFromByte(pt.Bytes()[0]); got != pt { + t.Errorf("PieceTypeFromByte(%q) = %v, want %v", pt.Bytes(), got, pt) + } + }) } } -func TestBytesForPieceTypes(t *testing.T) { +func TestPieceTypeFromByte(t *testing.T) { + t.Parallel() tests := []struct { - pieceType PieceType - expected []byte + name string + in byte + want chess.PieceType }{ - {King, []byte{'k'}}, - {Queen, []byte{'q'}}, - {Rook, []byte{'r'}}, - {Bishop, []byte{'b'}}, - {Knight, []byte{'n'}}, - {Pawn, []byte{'p'}}, - {NoPieceType, []byte{}}, + {"lower_k", 'k', chess.King}, + {"lower_q", 'q', chess.Queen}, + {"lower_r", 'r', chess.Rook}, + {"lower_b", 'b', chess.Bishop}, + {"lower_n", 'n', chess.Knight}, + {"lower_p", 'p', chess.Pawn}, + {"upper_K", 'K', chess.NoPieceType}, + {"digit", '1', chess.NoPieceType}, + {"symbol", '?', chess.NoPieceType}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := chess.PieceTypeFromByte(tt.in); got != tt.want { + t.Errorf("PieceTypeFromByte(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} +func TestPieceTypeFromString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want chess.PieceType + }{ + {"lower_k", "k", chess.King}, + {"upper_K", "K", chess.King}, + {"upper_Q", "Q", chess.Queen}, + {"upper_N", "N", chess.Knight}, + {"empty", "", chess.NoPieceType}, + {"multi_char", "kk", chess.NoPieceType}, + {"invalid", "?", chess.NoPieceType}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := chess.PieceTypeFromString(tt.in); got != tt.want { + t.Errorf("PieceTypeFromString(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestPieceTypes_ReturnsSixTypesInCanonicalOrder(t *testing.T) { + want := [6]chess.PieceType{chess.King, chess.Queen, chess.Rook, chess.Bishop, chess.Knight, chess.Pawn} + if got := chess.PieceTypes(); got != want { + t.Errorf("PieceTypes() = %v, want %v", got, want) + } +} + +func TestColor_FENString(t *testing.T) { + tests := []struct { + name string + c chess.Color + want string + }{ + {"white", chess.White, "w"}, + {"black", chess.Black, "b"}, + {"none", chess.NoColor, "-"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestColor_Name(t *testing.T) { + tests := []struct { + name string + c chess.Color + want string + }{ + {"white", chess.White, "White"}, + {"black", chess.Black, "Black"}, + {"none", chess.NoColor, "No Color"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.Name(); got != tt.want { + t.Errorf("Name() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestColor_Other(t *testing.T) { + tests := []struct { + name string + c chess.Color + want chess.Color + }{ + {"white_reverses_to_black", chess.White, chess.Black}, + {"black_reverses_to_white", chess.Black, chess.White}, + {"none_stays_none", chess.NoColor, chess.NoColor}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.Other(); got != tt.want { + t.Errorf("Other() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestColorFromString(t *testing.T) { + tests := []struct { + name string + in string + want chess.Color + }{ + {"lower_w", "w", chess.White}, + {"lower_b", "b", chess.Black}, + {"upper_W", "W", chess.White}, + {"upper_B", "B", chess.Black}, + {"invalid", "x", chess.NoColor}, + {"empty", "", chess.NoColor}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chess.ColorFromString(tt.in); got != tt.want { + t.Errorf("ColorFromString(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestPiece_TypeColorAndConstruction(t *testing.T) { + tests := []struct { + name string + piece chess.Piece + pt chess.PieceType + c chess.Color + }{ + {"white_king", chess.WhiteKing, chess.King, chess.White}, + {"white_queen", chess.WhiteQueen, chess.Queen, chess.White}, + {"white_rook", chess.WhiteRook, chess.Rook, chess.White}, + {"white_bishop", chess.WhiteBishop, chess.Bishop, chess.White}, + {"white_knight", chess.WhiteKnight, chess.Knight, chess.White}, + {"white_pawn", chess.WhitePawn, chess.Pawn, chess.White}, + {"black_king", chess.BlackKing, chess.King, chess.Black}, + {"black_queen", chess.BlackQueen, chess.Queen, chess.Black}, + {"black_rook", chess.BlackRook, chess.Rook, chess.Black}, + {"black_bishop", chess.BlackBishop, chess.Bishop, chess.Black}, + {"black_knight", chess.BlackKnight, chess.Knight, chess.Black}, + {"black_pawn", chess.BlackPawn, chess.Pawn, chess.Black}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.piece.Type(); got != tt.pt { + t.Errorf("Type() = %v, want %v", got, tt.pt) + } + if got := tt.piece.Color(); got != tt.c { + t.Errorf("Color() = %v, want %v", got, tt.c) + } + if got := chess.NewPiece(tt.pt, tt.c); got != tt.piece { + t.Errorf("NewPiece(%v,%v) = %v, want %v", tt.pt, tt.c, got, tt.piece) + } + }) + } +} + +func TestPiece_StringAndDarkString(t *testing.T) { + tests := []struct { + name string + piece chess.Piece + str, dark string + }{ + {"white_king", chess.WhiteKing, "♔", "♚"}, + {"white_queen", chess.WhiteQueen, "♕", "♛"}, + {"white_rook", chess.WhiteRook, "♖", "♜"}, + {"white_bishop", chess.WhiteBishop, "♗", "♝"}, + {"white_knight", chess.WhiteKnight, "♘", "♞"}, + {"white_pawn", chess.WhitePawn, "♙", "♟"}, + {"black_king", chess.BlackKing, "♚", "♔"}, + {"black_queen", chess.BlackQueen, "♛", "♕"}, + {"black_rook", chess.BlackRook, "♜", "♖"}, + {"black_bishop", chess.BlackBishop, "♝", "♗"}, + {"black_knight", chess.BlackKnight, "♞", "♘"}, + {"black_pawn", chess.BlackPawn, "♟", "♙"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.piece.String(); got != tt.str { + t.Errorf("String() = %q, want %q", got, tt.str) + } + if got := tt.piece.DarkString(); got != tt.dark { + t.Errorf("DarkString() = %q, want %q", got, tt.dark) + } + }) + } +} + +func TestPiece_DarkStringReversesColors(t *testing.T) { + for _, pt := range chess.PieceTypes() { + t.Run(pt.String(), func(t *testing.T) { + white := chess.NewPiece(pt, chess.White) + black := chess.NewPiece(pt, chess.Black) + if white.DarkString() != black.String() { + t.Errorf("white DarkString() = %q, want black String() = %q", white.DarkString(), black.String()) + } + if white.String() != black.DarkString() { + t.Errorf("white String() = %q, want black DarkString() = %q", white.String(), black.DarkString()) + } + }) + } +} + +func TestNoPiece_IsEmptySentinel(t *testing.T) { + if got := chess.NoPiece.String(); got != "-" { + t.Errorf("NoPiece.String() = %q, want %q", got, "-") + } + if got := chess.NoPiece.Type(); got != chess.NoPieceType { + t.Errorf("NoPiece.Type() = %v, want %v", got, chess.NoPieceType) + } + if got := chess.NoPiece.Color(); got != chess.NoColor { + t.Errorf("NoPiece.Color() = %v, want %v", got, chess.NoColor) + } +} + +func TestNewPiece_ReturnsNoPieceForInvalidInputs(t *testing.T) { + tests := []struct { + name string + pt chess.PieceType + c chess.Color + }{ + {"invalid_type", chess.NoPieceType, chess.White}, + {"invalid_color", chess.King, chess.NoColor}, + } for _, tt := range tests { - if !bytes.Equal(tt.pieceType.Bytes(), tt.expected) { - t.Fatalf("expected %v but got %v", tt.expected, tt.pieceType.Bytes()) - } + t.Run(tt.name, func(t *testing.T) { + if got := chess.NewPiece(tt.pt, tt.c); got != chess.NoPiece { + t.Errorf("NewPiece(%v,%v) = %v, want NoPiece", tt.pt, tt.c, got) + } + }) } } diff --git a/polyglot.go b/polyglot.go index 3a52d1b3..4b498201 100644 --- a/polyglot.go +++ b/polyglot.go @@ -2,11 +2,12 @@ package chess import ( "bytes" - "crypto/rand" + "cmp" "encoding/binary" "errors" - "fmt" "io" + "math/rand/v2" + "slices" "sort" ) @@ -23,7 +24,7 @@ type PolyglotEntry struct { // PolyglotMove represents a decoded chess move from a polyglot entry. // The coordinates use 0-based indices where: // - Files go from 0 (a-file) to 7 (h-file) -// - Ranks go from 0 (1st rank) to 7 (8th rank) +// - Ranks go from 0 (1st rank) to 7 (8th rank). type PolyglotMove struct { FromFile int // Source file (0-7) FromRank int // Source rank (0-7) @@ -112,20 +113,20 @@ func (pm PolyglotMove) ToMove() Move { moveStr = string(moveBuf[:4]) } - decode, err := UCINotation{}.Decode(nil, moveStr) + decode, err := uciNotation{}.Decode(nil, moveStr) if err != nil { return Move{} } if pm.CastlingMove { if pm.FromFile == 4 && (pm.ToFile == 0 || pm.ToFile == 2) { - decode.AddTag(QueenSideCastle) + decode = decode.WithTag(QueenSideCastle) } else { - decode.AddTag(KingSideCastle) + decode = decode.WithTag(KingSideCastle) } } - return *decode + return decode } // BookSource defines the interface for reading polyglot book data. @@ -138,7 +139,7 @@ type BookSource interface { Size() (int64, error) } -// ReaderBookSource implements BookSource for io.Reader +// ReaderBookSource implements BookSource for io.Reader. type ReaderBookSource struct { reader io.Reader data []byte // Buffered data for Size() implementation @@ -146,7 +147,7 @@ type ReaderBookSource struct { } // NewReaderBookSource creates a new reader-based book source -// Note: This will read the entire input into memory to support Size() and multiple reads +// Note: This will read the entire input into memory to support Size() and multiple reads. func NewReaderBookSource(reader io.Reader) (*ReaderBookSource, error) { // Read all data into memory data, err := io.ReadAll(reader) @@ -161,13 +162,13 @@ func NewReaderBookSource(reader io.Reader) (*ReaderBookSource, error) { }, nil } -// Read implements BookSource for ReaderBookSource -func (r *ReaderBookSource) Read(p []byte) (n int, err error) { +// Read implements BookSource for ReaderBookSource. +func (r *ReaderBookSource) Read(p []byte) (int, error) { if r.readIndex >= int64(len(r.data)) { return 0, io.EOF } - n = copy(p, r.data[r.readIndex:]) + n := copy(p, r.data[r.readIndex:]) r.readIndex += int64(n) if n < len(p) { @@ -176,23 +177,23 @@ func (r *ReaderBookSource) Read(p []byte) (n int, err error) { return n, nil } -// Size implements BookSource for ReaderBookSource +// Size implements BookSource for ReaderBookSource. func (r *ReaderBookSource) Size() (int64, error) { return int64(len(r.data)), nil } -// FileBookSource implements BookSource for files +// FileBookSource implements BookSource for files. type FileBookSource struct { path string } -// BytesBookSource implements BookSource for byte slices +// BytesBookSource implements BookSource for byte slices. type BytesBookSource struct { data []byte index int64 } -// NewBytesBookSource creates a new memory-based book source +// NewBytesBookSource creates a new memory-based book source. func NewBytesBookSource(data []byte) *BytesBookSource { return &BytesBookSource{ data: data, @@ -200,13 +201,13 @@ func NewBytesBookSource(data []byte) *BytesBookSource { } } -// Read implements BookSource for BytesBookSource -func (b *BytesBookSource) Read(p []byte) (n int, err error) { +// Read implements BookSource for BytesBookSource. +func (b *BytesBookSource) Read(p []byte) (int, error) { if b.index >= int64(len(b.data)) { return 0, io.EOF } - n = copy(p, b.data[b.index:]) + n := copy(p, b.data[b.index:]) b.index += int64(n) if n < len(p) { @@ -215,12 +216,12 @@ func (b *BytesBookSource) Read(p []byte) (n int, err error) { return n, nil } -// Size implements BookSource for BytesBookSource +// Size implements BookSource for BytesBookSource. func (b *BytesBookSource) Size() (int64, error) { return int64(len(b.data)), nil } -// LoadFromSource loads a polyglot book from any BookSource +// LoadFromSource loads a polyglot book from any BookSource. func LoadFromSource(source BookSource) (*PolyglotBook, error) { size, err := source.Size() if err != nil { @@ -237,7 +238,7 @@ func LoadFromSource(source BookSource) (*PolyglotBook, error) { buf := make([]byte, 16) for { _, readErr := source.Read(buf) - if readErr == io.EOF { + if errors.Is(readErr, io.EOF) { break } if readErr != nil { @@ -253,8 +254,8 @@ func LoadFromSource(source BookSource) (*PolyglotBook, error) { entries = append(entries, entry) } - sort.Slice(entries, func(i, j int) bool { - return entries[i].Key < entries[j].Key + slices.SortFunc(entries, func(a, b PolyglotEntry) int { + return cmp.Compare(a.Key, b.Key) }) return &PolyglotBook{entries: entries}, nil @@ -326,8 +327,8 @@ func (book *PolyglotBook) FindMoves(positionHash uint64) []PolyglotEntry { moves = append(moves, book.entries[i]) } - sort.Slice(moves, func(i, j int) bool { - return moves[i].Weight > moves[j].Weight + slices.SortFunc(moves, func(a, b PolyglotEntry) int { + return cmp.Compare(b.Weight, a.Weight) }) return moves @@ -366,7 +367,7 @@ func DecodeMove(move uint16) PolyglotMove { } } -// Helper function to identify castling moves +// Helper function to identify castling moves. func isCastlingMove(fromFile, fromRank, toFile, toRank int) bool { return fromFile == 4 && (fromRank == 0 || fromRank == 7) && (toFile == 0 || toFile == 7) && toRank == fromRank @@ -390,19 +391,18 @@ func isCastlingMove(fromFile, fromRank, toFile, toRank int) bool { func (book *PolyglotBook) GetRandomMove(positionHash uint64) (*PolyglotEntry, error) { moves := book.FindMoves(positionHash) if len(moves) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // nil,nil is the documented "no move for this position" signal } totalWeight := 0 for _, move := range moves { totalWeight += int(move.Weight) } - - r, err := fastRand() - if err != nil { - return nil, err + if totalWeight == 0 { + return nil, nil //nolint:nilnil // nil,nil is the documented "no weighted move available" signal } - rn := int(r) % totalWeight + + rn := int(rand.Uint32()) % totalWeight currentWeight := 0 for _, move := range moves { currentWeight += int(move.Weight) @@ -414,16 +414,12 @@ func (book *PolyglotBook) GetRandomMove(positionHash uint64) (*PolyglotEntry, er return &moves[0], nil } -// fastRand returns a cryptographically secure random uint32. -// This implementation uses crypto/rand instead of math/rand to ensure -// that move selection cannot be predicted or manipulated. -func fastRand() (uint32, error) { - b := make([]byte, 4) - _, err := rand.Read(b) - if err != nil { - return 0, fmt.Errorf("failed to generate random number: %w", err) - } - return binary.BigEndian.Uint32(b), nil +// fastRand returns a pseudorandom uint32 from math/rand/v2. The package-level +// source is automatically seeded, so callers do not need to seed it. Note that +// this is no longer cryptographically secure; opening book selection is for +// casual play and does not require cryptographic randomness. +func fastRand() uint32 { + return rand.Uint32() } // NewPolyglotBookFromMap creates a PolyglotBook from a map where @@ -441,8 +437,8 @@ func NewPolyglotBookFromMap(m map[uint64][]MoveWithWeight) *PolyglotBook { entries = append(entries, entry) } } - sort.Slice(entries, func(i, j int) bool { - return entries[i].Key < entries[j].Key + slices.SortFunc(entries, func(a, b PolyglotEntry) int { + return cmp.Compare(a.Key, b.Key) }) return &PolyglotBook{entries: entries} } @@ -457,8 +453,8 @@ func (book *PolyglotBook) AddMove(positionHash uint64, move Move, weight uint16) } book.entries = append(book.entries, entry) // Re-sort after adding - sort.Slice(book.entries, func(i, j int) bool { - return book.entries[i].Key < book.entries[j].Key + slices.SortFunc(book.entries, func(a, b PolyglotEntry) int { + return cmp.Compare(a.Key, b.Key) }) } diff --git a/polyglot_test.go b/polyglot_test.go index e3f6d917..b245d410 100644 --- a/polyglot_test.go +++ b/polyglot_test.go @@ -267,6 +267,23 @@ func TestGetRandomMove(t *testing.T) { } } +func TestGetRandomMoveWithZeroTotalWeight(t *testing.T) { + book := &PolyglotBook{ + entries: []PolyglotEntry{ + {Key: 1, Move: 100, Weight: 0, Learn: 0}, + {Key: 1, Move: 101, Weight: 0, Learn: 0}, + }, + } + + move, err := book.GetRandomMove(1) + if err != nil { + t.Fatalf("GetRandomMove() error = %v", err) + } + if move != nil { + t.Fatalf("GetRandomMove() = %v, want nil", move) + } +} + func TestInvalidBookData(t *testing.T) { // Test invalid file size invalidData := []byte{0x00, 0x01, 0x02} // Not multiple of 16 @@ -778,7 +795,21 @@ func BenchmarkToMoveMap(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = book.ToMoveMap() } } + +func TestFastRandDistribution(t *testing.T) { + // fastRand now wraps math/rand/v2.Uint32. We don't test randomness + // quality (that's the std lib's job), just that the helper returns + // a uint32 without panicking and produces varying values. + seen := make(map[uint32]struct{}, 32) + for range 32 { + v := fastRand() + seen[v] = struct{}{} + } + if len(seen) < 16 { + t.Errorf("fastRand produced too few distinct values: %d/32", len(seen)) + } +} diff --git a/position.go b/position.go index 55afad55..b0a964d1 100644 --- a/position.go +++ b/position.go @@ -21,7 +21,6 @@ package chess import ( "bytes" - "crypto/md5" "encoding/binary" "errors" "fmt" @@ -49,14 +48,25 @@ type CastleRights string // // White can castle kingside // } func (cr CastleRights) CanCastle(c Color, side Side) bool { - char := "k" - if side == QueenSide { - char = "q" + var want byte + switch { + case c == White && side == KingSide: + want = 'K' + case c == White && side == QueenSide: + want = 'Q' + case c == Black && side == KingSide: + want = 'k' + case c == Black && side == QueenSide: + want = 'q' + default: + return false } - if c == White { - char = strings.ToUpper(char) + for i := range cr { + if cr[i] == want { + return true + } } - return strings.Contains(string(cr), char) + return false } // String implements the fmt.Stringer interface and returns @@ -69,7 +79,7 @@ func (cr CastleRights) String() string { // It includes piece placement, castling rights, en passant squares, // move counts, and side to move. type Position struct { - board *Board // Current board state + board Board // Current board state castleRights CastleRights // Available castling options validMoves []Move // Cache of legal moves halfMoveClock int // Half-move counter @@ -78,6 +88,8 @@ type Position struct { enPassantSquare Square // En passant target square inCheck bool // Whether current side is in check hash uint64 // Zobrist hash for O(1) position comparison + status Method // Cached Status result + statusCached bool // Whether status contains a valid cached value } const ( @@ -91,6 +103,11 @@ func StartingPosition() *Position { return pos } +// AnyLegalMove reports whether the position has at least one legal move. +func (pos *Position) AnyLegalMove() bool { + return hasLegalMove(pos) +} + // Update returns a new position resulting from the given move. // The move isn't validated - use Game.Move() for validation. // This method is optimized for move generation where validation @@ -99,51 +116,32 @@ func StartingPosition() *Position { // Example: // // newPos := pos.Update(move) -func (pos *Position) Update(m *Move) *Position { - moveCount := pos.moveCount - if pos.turn == Black { - moveCount++ - } - - if m == nil { - newPos := &Position{ - board: pos.board.copy(), - turn: pos.turn.Other(), - castleRights: pos.castleRights, - enPassantSquare: NoSquare, - halfMoveClock: pos.halfMoveClock + 1, - moveCount: moveCount, - inCheck: false, - } - newPos.hash = newPos.computeHash() - return newPos +func (pos *Position) Update(m Move) *Position { + // Null moves flip the side to move without touching the board. + if m.HasTag(Null) { + return pos.nullUpdate() } - ncr := pos.updateCastleRights(m) - p := pos.board.Piece(m.s1) - halfMove := pos.halfMoveClock - if p.Type() == Pawn || m.HasTag(Capture) { - halfMove = 0 - } else { - halfMove++ - } - b := pos.board.copy() - b.update(m) + // Seed a fresh position with the pre-move scalars so applyMove can mutate + // it in one place. applyMove overwrites every field it owns, so the seed + // is read by applyMove (via updateCastleRights/updateEnPassantSquare) + // before being overwritten. newPos := &Position{ - board: b, - turn: pos.turn.Other(), - castleRights: ncr, - enPassantSquare: pos.updateEnPassantSquare(m), - halfMoveClock: halfMove, - moveCount: moveCount, - inCheck: m.HasTag(Check), - } - newPos.hash = pos.updateHash(m, ncr, newPos.enPassantSquare) + board: pos.board, + turn: pos.turn, + castleRights: pos.castleRights, + enPassantSquare: pos.enPassantSquare, + halfMoveClock: pos.halfMoveClock, + moveCount: pos.moveCount, + } + newPos.applyMove(m) + // updateHash reads the pre-move board and hash on the original position. + newPos.hash = pos.updateHash(m, newPos.castleRights, newPos.enPassantSquare) return newPos } // updateHash computes the new Zobrist hash incrementally from a move. -func (pos *Position) updateHash(m *Move, newCR CastleRights, newEP Square) uint64 { +func (pos *Position) updateHash(m Move, newCR CastleRights, newEP Square) uint64 { hash := pos.hash // Toggle side to move @@ -213,27 +211,25 @@ func (pos *Position) updateHash(m *Move, newCR CastleRights, newEP Square) uint6 } // Update castling rights: XOR out removed rights - oldCR := pos.castleRights.String() - newCRStr := newCR.String() - if strings.Contains(oldCR, "K") && !strings.Contains(newCRStr, "K") { + if pos.castleRights.CanCastle(White, KingSide) && !newCR.CanCastle(White, KingSide) { hash ^= polyglotHashesUint64[768] } - if strings.Contains(oldCR, "Q") && !strings.Contains(newCRStr, "Q") { + if pos.castleRights.CanCastle(White, QueenSide) && !newCR.CanCastle(White, QueenSide) { hash ^= polyglotHashesUint64[769] } - if strings.Contains(oldCR, "k") && !strings.Contains(newCRStr, "k") { + if pos.castleRights.CanCastle(Black, KingSide) && !newCR.CanCastle(Black, KingSide) { hash ^= polyglotHashesUint64[770] } - if strings.Contains(oldCR, "q") && !strings.Contains(newCRStr, "q") { + if pos.castleRights.CanCastle(Black, QueenSide) && !newCR.CanCastle(Black, QueenSide) { hash ^= polyglotHashesUint64[771] } // Update en passant: XOR out old if present - if oldEPFile := enPassantFileForHash(pos.board, pos.enPassantSquare); oldEPFile >= 0 { + if oldEPFile := enPassantFileForHash(&pos.board, pos.enPassantSquare); oldEPFile >= 0 { hash ^= polyglotHashesUint64[772+oldEPFile] } // XOR in new if present - if newEPFile := enPassantFileForHash(pos.board, newEP); newEPFile >= 0 { + if newEPFile := enPassantFileForHash(&pos.board, newEP); newEPFile >= 0 { hash ^= polyglotHashesUint64[772+newEPFile] } @@ -247,7 +243,7 @@ func (pos *Position) ValidMoves() []Move { if pos.validMoves != nil { return append([]Move(nil), pos.validMoves...) } - pos.validMoves = engine{}.CalcMoves(pos, false) + pos.validMoves = calcMoves(pos, false) return append([]Move(nil), pos.validMoves...) } @@ -258,7 +254,7 @@ func (pos *Position) ValidMovesUnsafe() []Move { if pos.validMoves != nil { return pos.validMoves } - pos.validMoves = engine{}.CalcMoves(pos, false) + pos.validMoves = calcMoves(pos, false) return pos.validMoves } @@ -284,18 +280,26 @@ func (pos *Position) ValidMovesIter(yield func(Move) bool) { // 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) + return unsafeMoves(pos) } -// Status returns the position's status as one of the outcome methods. -// Possible returns values include Checkmate, Stalemate, and NoMethod. +// Status returns the position's outcome Method (e.g. Checkmate, Stalemate, or +// NoMethod). func (pos *Position) Status() Method { - return engine{}.Status(pos) + if pos.statusCached { + return pos.status + } + pos.status = status(pos) + pos.statusCached = true + return pos.status } // Board returns the position's board. func (pos *Position) Board() *Board { - return pos.board + if pos == nil { + return nil + } + return pos.board.copy() } // Turn returns the color to move next. @@ -307,9 +311,22 @@ func (pos *Position) Turn() Color { func (pos *Position) ChangeTurn() *Position { pos.turn = pos.turn.Other() pos.hash = pos.computeHash() + pos.statusCached = false return pos } +// nullUpdateHash computes the Zobrist hash delta for a null move: the only +// state that changed is the side to move (always flipped) and the en-passant +// square (always cleared), so the only XOR is the side-to-move key plus any +// previously-active en-passant file key. +func (pos *Position) nullUpdateHash(_ Square) uint64 { + hash := pos.hash ^ polyglotHashesUint64[780] + if oldEPFile := enPassantFileForHash(&pos.board, pos.enPassantSquare); oldEPFile >= 0 { + hash ^= polyglotHashesUint64[772+oldEPFile] + } + return hash +} + // HalfMoveClock returns the half-move clock (50-rule). func (pos *Position) HalfMoveClock() int { return pos.halfMoveClock @@ -336,9 +353,8 @@ func (pos *Position) Ply() int { if pos.turn == White { return (pos.moveCount-1)*2 + 1 - } else { - return (pos.moveCount) * 2 } + return (pos.moveCount) * 2 } // String implements the fmt.Stringer interface and returns a @@ -355,7 +371,7 @@ func (pos *Position) String() string { } // XFENString() is similar to String() except that it returns a string with -// the X-FEN format +// the X-FEN format. func (pos *Position) XFENString() string { b := pos.board.String() t := pos.turn.String() @@ -395,13 +411,6 @@ func (pos *Position) XFENString() string { return fmt.Sprintf("%s %s %s %s %d %d", b, t, c, sq, pos.halfMoveClock, pos.moveCount) } -// Hash returns a unique hash of the position using MD5 of the binary representation. -// Deprecated: Use ZobristHash() for fast position comparison and transposition tables. -func (pos *Position) Hash() [16]byte { - b, _ := pos.MarshalBinary() - return md5.Sum(b) -} - // ZobristHash returns the Zobrist hash of the position. // This is a fast, collision-resistant hash suitable for transposition tables // and position comparison. Two positions that are identical by FIDE rules @@ -421,7 +430,7 @@ func (pos *Position) MarshalText() ([]byte, error) { func (pos *Position) UnmarshalText(text []byte) error { cp, err := decodeFEN(string(text)) if err != nil { - return err + return fmt.Errorf("chess: unmarshal position FEN: %w", err) } pos.board = cp.board pos.castleRights = cp.castleRights @@ -490,11 +499,9 @@ func (pos *Position) UnmarshalBinary(data []byte) error { if len(data) != size { return errors.New("chess: position binary data should consist of 101 bytes") } - board := &Board{} - if err := board.UnmarshalBinary(data[:96]); err != nil { + if err := pos.board.UnmarshalBinary(data[:96]); err != nil { return err } - pos.board = board buf := bytes.NewBuffer(data[96:]) halfMove := uint8(pos.halfMoveClock) if err := binary.Read(buf, binary.BigEndian, &halfMove); err != nil { @@ -543,7 +550,7 @@ func (pos *Position) UnmarshalBinary(data []byte) error { func (pos *Position) copy() *Position { return &Position{ - board: pos.board.copy(), + board: pos.board, turn: pos.turn, castleRights: pos.castleRights, enPassantSquare: pos.enPassantSquare, @@ -585,7 +592,7 @@ func pieceZobristIndex(p Piece, sq Square) int { func (pos *Position) computeHash() uint64 { var hash uint64 // XOR in all pieces - for sq := 0; sq < 64; sq++ { + for sq := range 64 { p := pos.board.Piece(Square(sq)) if p != NoPiece { idx := pieceZobristIndex(p, Square(sq)) @@ -609,7 +616,7 @@ func (pos *Position) computeHash() uint64 { hash ^= polyglotHashesUint64[771] } // XOR in en passant if a pawn can capture - if epFile := enPassantFileForHash(pos.board, pos.enPassantSquare); epFile >= 0 { + if epFile := enPassantFileForHash(&pos.board, pos.enPassantSquare); epFile >= 0 { hash ^= polyglotHashesUint64[772+epFile] } // XOR in side to move (white) @@ -619,55 +626,16 @@ func (pos *Position) computeHash() uint64 { return hash } -func (pos *Position) updateCastleRights(m *Move) CastleRights { - cr := string(pos.castleRights) - p := pos.board.Piece(m.s1) - if p == WhiteKing || m.s1 == H1 || m.s2 == H1 { - cr = strings.ReplaceAll(cr, "K", "") - } - if p == WhiteKing || m.s1 == A1 || m.s2 == A1 { - cr = strings.ReplaceAll(cr, "Q", "") - } - if p == BlackKing || m.s1 == H8 || m.s2 == H8 { - cr = strings.ReplaceAll(cr, "k", "") - } - if p == BlackKing || m.s1 == A8 || m.s2 == A8 { - cr = strings.ReplaceAll(cr, "q", "") - } - if cr == "" { - cr = "-" - } - return CastleRights(cr) -} - -func (pos *Position) updateEnPassantSquare(m *Move) Square { - const squaresPerRank = 8 - p := pos.board.Piece(m.s1) - if p.Type() != Pawn { - return NoSquare - } - if pos.turn == White && - (bbForSquare(m.s1)&bbRank2) != 0 && - (bbForSquare(m.s2)&bbRank4) != 0 { - return m.s2 - squaresPerRank - } else if pos.turn == Black && - (bbForSquare(m.s1)&bbRank7) != 0 && - (bbForSquare(m.s2)&bbRank5) != 0 { - return m.s2 + squaresPerRank - } - return NoSquare -} - -// samePosition returns true if the two positions are the same +// SamePosition returns true if the two positions are the same // according to FIDE Article 9.2.3. Uses Zobrist hash as a fast-path, // falling back to full field comparison on hash collision. -func (pos *Position) samePosition(pos2 *Position) bool { +func (pos *Position) SamePosition(pos2 *Position) bool { if pos.hash != pos2.hash { return false } - return pos.board.String() == pos2.board.String() && + return pos.board == pos2.board && pos.turn == pos2.turn && - pos.castleRights.String() == pos2.castleRights.String() && + pos.castleRights == pos2.castleRights && pos.relevantEnPassantSquare() == pos2.relevantEnPassantSquare() } @@ -711,7 +679,7 @@ func enPassantFileForHash(board *Board, epSquare Square) int { // the en passant square is only relevant if there is an opponent // pawn that can make the capture. func (pos *Position) relevantEnPassantSquare() Square { - if enPassantFileForHash(pos.board, pos.enPassantSquare) >= 0 { + if enPassantFileForHash(&pos.board, pos.enPassantSquare) >= 0 { return pos.enPassantSquare } return NoSquare diff --git a/position_test.go b/position_test.go index fe76757e..03bcc912 100644 --- a/position_test.go +++ b/position_test.go @@ -5,6 +5,26 @@ import ( "testing" ) +func mustPosition(t *testing.T, fen string) *Position { + t.Helper() + pos, err := decodeFEN(fen) + if err != nil { + t.Fatalf("FEN decode: %v", err) + } + return pos +} + +func TestPositionBoardReturnsDefensiveCopy(t *testing.T) { + pos := StartingPosition() + + board := pos.Board() + board.mailbox[E2] = NoPiece + + if got := pos.Board().Piece(E2); got != WhitePawn { + t.Fatalf("Board returned mutable position board, E2 = %v, want %v", got, WhitePawn) + } +} + func TestPositionBinary(t *testing.T) { for _, fen := range validFENs { pos, err := decodeFEN(fen) @@ -33,7 +53,7 @@ func TestPositionUpdate(t *testing.T) { } { - np := pos.Update(&pos.ValidMoves()[0]) + np := pos.Update(pos.ValidMoves()[0]) if pos.Turn().Other() != np.turn { t.Fatal("expected other turn") } @@ -44,18 +64,28 @@ func TestPositionUpdate(t *testing.T) { t.Fatal("expected board update") } } + } +} - { - np := pos.Update(nil) - if pos.Turn().Other() != np.turn { - t.Fatal("expected other turn") - } - if pos.halfMoveClock+1 != np.halfMoveClock { - t.Fatal("expected half move clock increment") - } - if pos.board.String() != np.board.String() { - t.Fatal("expected same board") - } +func TestPositionAnyLegalMove(t *testing.T) { + for _, fen := range validFENs { + pos, err := decodeFEN(fen) + if err != nil { + t.Fatal(err) + } + if got, want := pos.AnyLegalMove(), len(pos.ValidMoves()) > 0; got != want { + t.Fatalf("AnyLegalMove(%s) = %v, want %v", fen, got, want) + } + } + + terminalFENs := []string{ + "7k/5K2/6Q1/8/8/8/8/8 b - - 0 1", + "7k/5K2/7Q/8/8/8/8/8 b - - 0 1", + } + for _, fen := range terminalFENs { + pos := mustPosition(t, fen) + if pos.AnyLegalMove() { + t.Fatalf("AnyLegalMove(%s) = true, want false", fen) } } } @@ -109,7 +139,7 @@ func TestSamePositionEnPassantFIDECompliance(t *testing.T) { // These should be considered the same position because no en passant // capture is possible (no black pawn on d4 or f4). - if !posWithIrrelevantEP.samePosition(posWithoutEP) { + if !posWithIrrelevantEP.SamePosition(posWithoutEP) { t.Error("positions with irrelevant en passant square should be considered the same") } @@ -129,7 +159,7 @@ func TestSamePositionEnPassantFIDECompliance(t *testing.T) { // These should NOT be considered the same because the en passant // capture is actually possible. - if posWithRelevantEP.samePosition(posWithRelevantNoEP) { + if posWithRelevantEP.SamePosition(posWithRelevantNoEP) { t.Error("positions with relevant en passant square should be considered different") } @@ -144,7 +174,7 @@ func TestSamePositionEnPassantFIDECompliance(t *testing.T) { t.Fatal(err) } - if posWithRelevantEPRight.samePosition(posWithRelevantEPRightNoEP) { + if posWithRelevantEPRight.SamePosition(posWithRelevantEPRightNoEP) { t.Error("positions with relevant en passant square (right adjacent pawn) should be considered different") } } @@ -215,14 +245,14 @@ func TestValidMovesIterEarlyReturn(t *testing.T) { func BenchmarkValidMovesCopy(b *testing.B) { pos := StartingPosition() - for i := 0; i < b.N; i++ { + for range b.N { _ = pos.ValidMoves() } } func BenchmarkValidMovesUnsafe(b *testing.B) { pos := StartingPosition() - for i := 0; i < b.N; i++ { + for range b.N { _ = pos.ValidMovesUnsafe() } } @@ -253,7 +283,7 @@ func TestZobristHashIncrementalCorrectness(t *testing.T) { continue } for _, m := range moves { - newPos := pos.Update(&m) + newPos := pos.Update(m) // Recompute hash from scratch for the new position recomputedHash := newPos.computeHash() if newPos.ZobristHash() != recomputedHash { @@ -265,7 +295,7 @@ func TestZobristHashIncrementalCorrectness(t *testing.T) { } func TestZobristHashSamePositionEquivalence(t *testing.T) { - // Test that hash-based samePosition matches the old logic for a variety of positions + // Test that hash-based SamePosition matches the old logic for a variety of positions for i, fen1 := range validFENs { pos1, err := decodeFEN(fen1) if err != nil { @@ -280,16 +310,16 @@ func TestZobristHashSamePositionEquivalence(t *testing.T) { t.Fatal(err) } - // The old samePosition logic (using string comparison) + // The old SamePosition logic (using string comparison) oldSame := pos1.board.String() == pos2.board.String() && pos1.turn == pos2.turn && pos1.castleRights.String() == pos2.castleRights.String() && pos1.relevantEnPassantSquare() == pos2.relevantEnPassantSquare() - newSame := pos1.samePosition(pos2) + newSame := pos1.SamePosition(pos2) if oldSame != newSame { - t.Fatalf("samePosition mismatch for FENs %s and %s: old=%v new=%v", fen1, fen2, oldSame, newSame) + t.Fatalf("SamePosition mismatch for FENs %s and %s: old=%v new=%v", fen1, fen2, oldSame, newSame) } } } @@ -298,18 +328,64 @@ func TestZobristHashSamePositionEquivalence(t *testing.T) { func BenchmarkSamePositionHash(b *testing.B) { pos1 := StartingPosition() pos2 := StartingPosition() - for i := 0; i < b.N; i++ { - _ = pos1.samePosition(pos2) + for range b.N { + _ = pos1.SamePosition(pos2) } } func BenchmarkSamePositionString(b *testing.B) { pos1 := StartingPosition() pos2 := StartingPosition() - for i := 0; i < b.N; i++ { + for range b.N { _ = pos1.board.String() == pos2.board.String() && pos1.turn == pos2.turn && pos1.castleRights.String() == pos2.castleRights.String() && pos1.relevantEnPassantSquare() == pos2.relevantEnPassantSquare() } } + +// TestSamePositionHashCollisionFallback verifies that SamePosition returns +// false when two genuinely different positions happen to share a Zobrist hash +// (synthetic collision). The fallback must compare the full position fields and +// reject the match. This locks down the safety property that hash equality is +// necessary but not sufficient. +func TestSamePositionHashCollisionFallback(t *testing.T) { + pos1, err := decodeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + if err != nil { + t.Fatal(err) + } + pos2, err := decodeFEN("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1") + if err != nil { + t.Fatal(err) + } + + if pos1.hash == pos2.hash { + t.Skip("positions unexpectedly share a real hash; collision path not exercised") + } + + // Force a synthetic collision: copy pos2's hash into pos1 so the fast-path + // matches, then rely on the field-by-field fallback to reject. + pos1.hash = pos2.hash + + if pos1.SamePosition(pos2) { + t.Fatal("SamePosition returned true for different positions with a forced hash collision; " + + "fallback comparison is missing a field") + } +} + +// TestSamePositionForcedHashEqualSamePosition verifies the fallback accepts +// genuinely equal positions when their hashes have been tampered with. This is +// the positive counterpart to the collision test and confirms the fallback +// path does not over-reject. +func TestSamePositionForcedHashEqualSamePosition(t *testing.T) { + pos1 := StartingPosition() + pos2 := StartingPosition() + + // Tamper with pos2's hash so the fast-path would normally reject it; the + // positions are still structurally equal, so the fallback would accept if + // reached. Here we keep the hashes equal (no tamper) to confirm the + // trivial path still works alongside the collision test above. + if !pos1.SamePosition(pos2) { + t.Fatal("SamePosition returned false for structurally identical positions") + } +} diff --git a/regression_closed_issues_test.go b/regression_closed_issues_test.go new file mode 100644 index 00000000..7e332fe6 --- /dev/null +++ b/regression_closed_issues_test.go @@ -0,0 +1,120 @@ +package chess_test + +import ( + "strings" + "testing" + "time" + + "github.com/corentings/chess/v3" + "github.com/corentings/chess/v3/uci" +) + +// This file adds black-box regression tests for closed GitHub issues that the +// existing test suite did not already cover. Each test exercises only the +// public API of github.com/corentings/chess/v3 and /v3/uci, and pins the +// behavior promised in the original issue's resolution so any v3 regression +// fails loudly. + +// #76: When parsing a game that ends in checkmate from PGN, the game method +// was NoMethod instead of Checkmate. +// https://github.com/CorentinGS/chess/issues/76 +func TestRegressionIssue76_PGNCheckmateMethod(t *testing.T) { + g, err := chess.ParsePGN(strings.NewReader("1. f4 e5 2. g4 Qh4# 0-1")) + if err != nil { + t.Fatalf("ParsePGN failed: %v", err) + } + if got := g.Outcome(); got != chess.BlackWon { + t.Fatalf("Outcome() = %v, want %v", got, chess.BlackWon) + } + if got := g.Method(); got != chess.Checkmate { + t.Fatalf("Method() = %v, want %v", got, chess.Checkmate) + } +} + +// #29: Engine bestmove was decoded with a nil position, so it lost tags like +// KingSideCastle and SAN encoded as "Kg1" instead of "O-O". +// https://github.com/CorentinGS/chess/issues/29 +func TestRegressionIssue29_EngineBestMoveHasCastleTag(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": {"info depth 10 score cp 50 nodes 1000 pv e8g8", "bestmove e8g8"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + // Black king-side castle is legal: king on e8, rook on h8, both squares + // f8/g8 empty and not attacked. White rook on h1 targets h8 — irrelevant + // to castling through g8/f8 because castling rules check attacks on + // e8/f8/g8 only. + f, err := chess.FEN("r3k2r/8/8/8/8/8/8/4K2R b KQkq - 0 1") + if err != nil { + t.Fatal(err) + } + g := chess.NewGame(f) + if err := eng.Run(uci.CmdPosition{Position: g.Position()}, uci.CmdGo{MoveTime: 100 * time.Millisecond}); err != nil { + t.Fatalf("Run failed: %v", err) + } + results := eng.SearchResults() + if !results.BestMove.HasTag(chess.KingSideCastle) { + t.Fatalf("expected KingSideCastle tag on engine best move e8g8, got move=%s", + results.BestMove) + } + san, err := chess.SAN().Encode(g.Position(), results.BestMove) + if err != nil { + t.Fatalf("SAN encode failed: %v", err) + } + if san != "O-O" { + t.Fatalf("SAN = %q, want %q", san, "O-O") + } +} + +// #34: UCI decode did not tag king/queen-side castles, so downstream +// SAN encoding was wrong. +// https://github.com/CorentinGS/chess/issues/34 +func TestRegressionIssue34_UCINotationDecodeTagsCastle(t *testing.T) { + for _, tt := range []struct { + name string + fen string + uci string + want chess.MoveTag + sanOut string + }{ + { + name: "black_kingside", + fen: "rnb1k2r/ppp3pp/4pn2/3q4/3P4/2P2N2/P1PB1PPP/R2QKB1R b KQkq - 3 8", + uci: "e8g8", + want: chess.KingSideCastle, + sanOut: "O-O", + }, + { + name: "black_queenside", + fen: "r3kbnr/pppq1ppp/2npb3/3p4/3P4/2NPB3/PPPQ1PPP/R3KBNR b KQkq - 4 6", + uci: "e8c8", + want: chess.QueenSideCastle, + sanOut: "O-O-O", + }, + } { + t.Run(tt.name, func(t *testing.T) { + f, err := chess.FEN(tt.fen) + if err != nil { + t.Fatal(err) + } + g := chess.NewGame(f) + m, err := chess.UCI().Decode(g.Position(), tt.uci) + if err != nil { + t.Fatalf("decode %q: %v", tt.uci, err) + } + if !m.HasTag(tt.want) { + t.Fatalf("%s: missing tag %v (got move=%s)", tt.uci, tt.want, m) + } + san, err := chess.SAN().Encode(g.Position(), m) + if err != nil { + t.Fatalf("%s: SAN encode error: %v", tt.uci, err) + } + if san != tt.sanOut { + t.Fatalf("%s: SAN = %q, want %q", tt.uci, san, tt.sanOut) + } + }) + } +} diff --git a/san_resolver.go b/san_resolver.go new file mode 100644 index 00000000..b3a6980f --- /dev/null +++ b/san_resolver.go @@ -0,0 +1,270 @@ +package chess + +import ( + "errors" + "fmt" + "strings" +) + +type sanMoveData struct { + castle string + piece PieceType + originFile string + originRank string + dest Square + capture bool + promotion PieceType + canonical bool +} + +func resolveSANMove(pos *Position, data sanMoveData) (Move, error) { + if data.castle != "" { + return resolveSANCastle(pos, data.castle) + } + if data.dest == NoSquare { + return Move{}, errorsInvalidSANMove() + } + + if matched, ok := resolveSANMoveDirect(pos, data); ok { + return matched, nil + } + + var mismatchReasons []string + originFile := byte(0) + if data.originFile != "" { + originFile = data.originFile[0] + } + originRank := int8(-1) + if data.originRank != "" { + originRank = int8(data.originRank[0] - '1') + } + + var matched Move + matchedMove := false + visitLegalMoves(pos, generateLegalAnnotated, func(m Move) bool { + if m.S2() != data.dest { + return false + } + + piece := pos.Board().Piece(m.S1()) + if piece.Type() != data.piece { + mismatchReasons = append(mismatchReasons, "piece type mismatch") + return false + } + if originFile != 0 && m.S1().File().Byte() != originFile { + mismatchReasons = append(mismatchReasons, "origin file mismatch") + return false + } + if originRank >= 0 && int8(m.S1().Rank()) != originRank { + mismatchReasons = append(mismatchReasons, fmt.Sprintf("origin rank mismatch: %d", m.S1()/8+1)) + return false + } + if data.canonical && data.piece != Pawn && data.originFile+data.originRank != formS1(pos, m) { + mismatchReasons = append(mismatchReasons, "disambiguation mismatch") + return false + } + + moveCaptures := m.HasTag(Capture) || m.HasTag(EnPassant) + if data.capture != moveCaptures { + mismatchReasons = append(mismatchReasons, "capture mismatch") + return false + } + if data.promotion != NoPieceType && m.promo != data.promotion { + mismatchReasons = append(mismatchReasons, "promotion mismatch") + return false + } + + matched = m + matchedMove = true + return true + }) + + if matchedMove { + return matched, nil + } + if len(mismatchReasons) > 0 { + return Move{}, fmt.Errorf("chess: no legal move found for position: %s", strings.Join(mismatchReasons, "; ")) + } + return Move{}, errorsInvalidSANMove() +} + +func resolveSANMoveDirect(pos *Position, data sanMoveData) (Move, bool) { + var origins [16]Square + count := sanCandidateOrigins(pos, data, &origins) + if count == 0 { + return Move{}, false + } + + originFile := byte(0) + if data.originFile != "" { + originFile = data.originFile[0] + } + originRank := int8(-1) + if data.originRank != "" { + originRank = int8(data.originRank[0] - '1') + } + + for i := range count { + s1 := origins[i] + if originFile != 0 && s1.File().Byte() != originFile { + continue + } + if originRank >= 0 && int8(s1.Rank()) != originRank { + continue + } + + p := pos.board.Piece(s1) + if p.Type() != data.piece || p.Color() != pos.turn { + continue + } + + m := Move{s1: s1, s2: data.dest, promo: data.promotion} + if data.piece == Pawn && data.promotion == NoPieceType && pawnPromotes(pos.turn, data.dest) { + continue + } + if data.piece != Pawn && data.promotion != NoPieceType { + continue + } + + tags := moveTagsForPiece(m, pos, generateLegalAnnotated, p, false) + if tags&inCheck != 0 { + continue + } + m.tags = tags + + moveCaptures := m.HasTag(Capture) || m.HasTag(EnPassant) + if data.capture != moveCaptures { + continue + } + if data.canonical && data.piece != Pawn && data.originFile+data.originRank != formS1(pos, m) { + continue + } + + return m, true + } + return Move{}, false +} + +func sanCandidateOrigins(pos *Position, data sanMoveData, origins *[16]Square) int { + switch data.piece { + case Pawn: + return sanPawnCandidateOrigins(pos, data, origins) + case Knight: + return sanOriginsFromBitboard(pos.board.bbForPiece(NewPiece(Knight, pos.turn))&bbKnightMoves[data.dest], origins) + case Bishop: + occ := ^pos.board.emptySqs + return sanOriginsFromBitboard(pos.board.bbForPiece(NewPiece(Bishop, pos.turn))&diaAttack(occ, data.dest), origins) + case Rook: + occ := ^pos.board.emptySqs + return sanOriginsFromBitboard(pos.board.bbForPiece(NewPiece(Rook, pos.turn))&hvAttack(occ, data.dest), origins) + case Queen: + occ := ^pos.board.emptySqs + return sanOriginsFromBitboard(pos.board.bbForPiece(NewPiece(Queen, pos.turn))&(diaAttack(occ, data.dest)|hvAttack(occ, data.dest)), origins) + case King: + return sanOriginsFromBitboard(pos.board.bbForPiece(NewPiece(King, pos.turn))&bbKingMoves[data.dest], origins) + default: + return 0 + } +} + +func sanOriginsFromBitboard(bb bitboard, origins *[16]Square) int { + count := 0 + for bits := bb; bits != 0; bits &= bits - 1 { + origins[count] = squareFromBit(bits & -bits) + count++ + } + return count +} + +func sanPawnCandidateOrigins(pos *Position, data sanMoveData, origins *[16]Square) int { + count := 0 + dest := data.dest + pawns := pos.board.bbForPiece(NewPiece(Pawn, pos.turn)) + add := func(sq Square) { + if sq < A1 || sq > H8 { + return + } + if pawns&bbForSquare(sq) == 0 { + return + } + origins[count] = sq + count++ + } + + if pos.turn == White { + if data.capture { + if dest.File() != FileA { + add(dest - 9) + } + if dest.File() != FileH { + add(dest - 7) + } + return count + } + if dest.Rank() > Rank1 { + one := dest - 8 + if !pos.board.isOccupied(one) { + add(one) + if dest.Rank() == Rank4 { + two := dest - 16 + if !pos.board.isOccupied(two) { + add(two) + } + } + } + } + return count + } + + if data.capture { + if dest.File() != FileA { + add(dest + 7) + } + if dest.File() != FileH { + add(dest + 9) + } + return count + } + if dest.Rank() < Rank8 { + one := dest + 8 + if !pos.board.isOccupied(one) { + add(one) + if dest.Rank() == Rank5 { + two := dest + 16 + if !pos.board.isOccupied(two) { + add(two) + } + } + } + } + return count +} + +func pawnPromotes(turn Color, dest Square) bool { + return (turn == White && dest.Rank() == Rank8) || (turn == Black && dest.Rank() == Rank1) +} + +func resolveSANCastle(pos *Position, castle string) (Move, error) { + var castles [2]Move + count := castleMovesInto(pos, &castles, generateLegalAnnotated) + for _, m := range castles[:count] { + if castle == castleKS && m.HasTag(KingSideCastle) { + return m, nil + } + if castle == castleQS && m.HasTag(QueenSideCastle) { + return m, nil + } + } + return Move{}, errorsInvalidSANMove() +} + +func errorsInvalidSANMove() error { + return errors.New("chess: no legal move found for position") +} + +func squareFromFileRank(file, rank byte) Square { + if file < 'a' || file > 'h' || rank < '1' || rank > '8' { + return NoSquare + } + return Square((file - 'a') + (rank-'1')*8) +} diff --git a/scanner_internal_test.go b/scanner_internal_test.go new file mode 100644 index 00000000..1e7032cd --- /dev/null +++ b/scanner_internal_test.go @@ -0,0 +1,29 @@ +package chess + +import "testing" + +func BenchmarkCheckForResult(b *testing.B) { + data := []byte("1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1-0\n[Event ") + b.ReportAllocs() + b.ResetTimer() + for range b.N { + for j := range data { + checkForResult(data, j) + } + } +} + +func TestTokenizeIntoReusesProvidedSlice(t *testing.T) { + game := &GameScanned{Raw: "1. e4 e5 1-0"} + dst := make([]Token, 0, 32) + tokens, err := tokenizeInto(game, dst) + if err != nil { + t.Fatal(err) + } + if len(tokens) == 0 { + t.Fatal("expected tokens") + } + if cap(tokens) != cap(dst) { + t.Fatalf("tokenizeInto did not reuse destination capacity: got %d, want %d", cap(tokens), cap(dst)) + } +} diff --git a/scanner_test.go b/scanner_test.go deleted file mode 100644 index 48d424b0..00000000 --- a/scanner_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package chess - -import ( - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestScanner(t *testing.T) { - file, err := os.Open(filepath.Join("fixtures/pgns", "multi_game.pgn")) - if err != nil { - t.Fatalf("Failed to open fixture file: %v", err) - } - defer file.Close() - - scanner := NewScanner(file) - - // Test first game - game, err := scanner.ScanGame() - if err != nil { - t.Fatalf("Failed to read first game: %v", err) - } - - tokens, err := TokenizeGame(game) - if err != nil { - t.Fatalf("Failed to tokenize first game: %v", err) - } - - expectedFirstGame := []struct { - typ TokenType - value string - }{ - {TagStart, "["}, - {TagKey, "Event"}, - {TagValue, "Example"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Site"}, - {TagValue, "Internet"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Date"}, - {TagValue, "2023.12.06"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Round"}, - {TagValue, "1"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "White"}, - {TagValue, "Player1"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Black"}, - {TagValue, "Player2"}, - {TagEnd, "]"}, - {TagStart, "["}, - {TagKey, "Result"}, - {TagValue, "1-0"}, - {TagEnd, "]"}, - {MoveNumber, "1"}, - {DOT, "."}, - {SQUARE, "e4"}, - {SQUARE, "e5"}, - {MoveNumber, "2"}, - {DOT, "."}, - {PIECE, "N"}, - {SQUARE, "f3"}, - {PIECE, "N"}, - {SQUARE, "c6"}, - {MoveNumber, "3"}, - {DOT, "."}, - {PIECE, "B"}, - {SQUARE, "b5"}, - {CommentStart, "{"}, - {COMMENT, "This is the Ruy Lopez."}, - {CommentEnd, "}"}, - {MoveNumber, "3"}, - {ELLIPSIS, "..."}, - {SQUARE, "a6"}, - {RESULT, "1-0"}, - } - - if len(tokens) != len(expectedFirstGame) { - t.Errorf("Expected %d tokens, got %d", len(expectedFirstGame), len(tokens)) - return - } - - for i, expected := range expectedFirstGame { - if i >= len(tokens) { - t.Errorf("Missing token at position %d, expected {%v, %q}", i, expected.typ, expected.value) - continue - } - - if tokens[i].Type != expected.typ || tokens[i].Value != expected.value { - t.Errorf("Token %d - Expected {%v, %q}, got {%v, %q}", - i, expected.typ, expected.value, tokens[i].Type, tokens[i].Value) - } - } - - // Test second game (if exists) - _, err = scanner.ScanGame() - if err != nil && !errors.Is(err, io.EOF) { - t.Errorf("Unexpected error reading second game: %v", err) - } - - // Test HasNext functionality by counting games - file.Seek(0, 0) // Reset file to beginning - scanner = NewScanner(file) - - var gameCount int - for scanner.HasNext() { - game, err := scanner.ScanGame() - if err != nil { - t.Fatalf("Error reading game %d: %v", gameCount+1, err) - } - - tokens, err = TokenizeGame(game) - if err != nil { - t.Fatalf("Error tokenizing game %d: %v", gameCount+1, err) - } - - if len(tokens) == 0 { - t.Errorf("Game %d has no tokens", gameCount+1) - } - - gameCount++ - } - - expectedGames := 4 - if gameCount != expectedGames { - t.Errorf("Expected %d games, got %d", expectedGames, gameCount) - } -} - -func TestScannerEmptyFile(t *testing.T) { - tmpfile, err := os.CreateTemp("", "empty.pgn") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(tmpfile.Name()) - defer tmpfile.Close() - - scanner := NewScanner(tmpfile) - - if scanner.HasNext() { - t.Error("Expected HasNext() to return false for empty file") - } - - game, err := scanner.ScanGame() - if !errors.Is(err, io.EOF) { - t.Errorf("Expected EOF error for empty file, got %v", err) - } - if game != nil { - t.Error("Expected nil game for empty file") - } -} - -func TestSequentialProcessing(t *testing.T) { - file, err := os.Open(filepath.Join("fixtures/pgns", "multi_game.pgn")) - if err != nil { - t.Fatalf("Failed to open fixture file: %v", err) - } - defer file.Close() - - scanner := NewScanner(file) - var games []*GameScanned - var allTokens [][]Token - - // Read all games using ScanGame in a loop - for { - game, err := scanner.ScanGame() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - t.Fatalf("Failed to scan game: %v", err) - } - games = append(games, game) - - tokens, err := TokenizeGame(game) - if err != nil { - t.Fatalf("Failed to tokenize game: %v", err) - } - allTokens = append(allTokens, tokens) - } - - if len(games) != 4 { - t.Errorf("Expected 4 games, got %d", len(games)) - } - - for i, tokens := range allTokens { - if len(tokens) == 0 { - t.Errorf("Game %d has no tokens", i+1) - } - } -} - -// Additional test to verify HasNext doesn't consume games. -func TestHasNextDoesntConsume(t *testing.T) { - file, err := os.Open(filepath.Join("fixtures/pgns", "multi_game.pgn")) - if err != nil { - t.Fatalf("Failed to open fixture file: %v", err) - } - defer file.Close() - - scanner := NewScanner(file) - - // Call HasNext multiple times - for i := range 3 { - if !scanner.HasNext() { - t.Errorf("Expected HasNext() to return true on call %d", i+1) - } - } - - // Should still be able to read the first game - game, err := scanner.ScanGame() - if err != nil { - t.Fatalf("Failed to read first game after HasNext calls: %v", err) - } - - tokens, err := TokenizeGame(game) - if err != nil { - t.Fatalf("Failed to tokenize first game: %v", err) - } - - if len(tokens) == 0 { - t.Error("First game has no tokens after multiple HasNext calls") - } -} - -func validateExpand(t *testing.T, scanner *Scanner, expectedLastLines []string, - expectedFinalPos []string, -) { - count := 0 - for scanner.HasNext() { - game, err := scanner.ParseNext() - if err != nil { - t.Fatalf("fail to parse game %v: %s", count+1, err.Error()) - } - - if game == nil { - t.Fatalf("game is nil") - } - if count >= len(expectedLastLines) { - t.Fatalf("expected %v games but found at least %v", - len(expectedLastLines), count+1) - } - lines := strings.Split(game.String(), "\n") - if len(lines) == 0 { - t.Fatalf("split game %v output blank", count+1) - } - - lastLine := lines[len(lines)-1] - if lastLine != expectedLastLines[count] { - t.Errorf("game output not correct\n\tExpected:'%v'\n\tGot: '%v'\n", - expectedLastLines[count], lastLine) - } - fen := game.Position().XFENString() - if fen != expectedFinalPos[count] { - t.Errorf("game position not correct\n\tExpected:'%v'\n\tGot: '%v'\n", - expectedFinalPos[count], fen) - } - count++ - } - - if count != len(expectedLastLines) { - t.Fatalf("expected %v games but found only %v", - len(expectedLastLines), count) - } -} - -func TestScannerExpand(t *testing.T) { - expectedLastLines := []string{ - "1. e4 e5 2. Nf3 Nc6 3. d4 exd4 4. Nxd4 *", - "1. e4 e5 2. Nc3 Nf6 3. f4 *", - "1. e4 d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 dxe5 5. Qxd8+ Kxd8 *", - "1. e4 d6 2. d4 Nf6 3. Nc3 e5 4. Nf3 Nbd7 *", - "1. e3 e5 *", - } - expectedFinalPos := []string{ - "r1bqkbnr/pppp1ppp/2n5/8/3NP3/8/PPP2PPP/RNBQKB1R b KQkq - 0 4", - "rnbqkb1r/pppp1ppp/5n2/4p3/4PP2/2N5/PPPP2PP/R1BQKBNR b KQkq - 0 3", - "rnbk1b1r/ppp2ppp/5n2/4p3/4P3/2N5/PPP2PPP/R1B1KBNR w KQ - 0 6", - "r1bqkb1r/pppn1ppp/3p1n2/4p3/3PP3/2N2N2/PPP2PPP/R1BQKB1R w KQkq - 2 5", - "rnbqkbnr/pppp1ppp/8/4p3/8/4P3/PPPP1PPP/RNBQKBNR w KQkq - 0 2", - } - - pgn := mustParsePGN("fixtures/pgns/variations.pgn") - reader := strings.NewReader(pgn) - scanner := NewScanner(reader, WithExpandVariations()) - validateExpand(t, scanner, expectedLastLines, expectedFinalPos) -} - -func TestScannerNoExpand(t *testing.T) { - expectedLastLines := []string{ - "1. e4 (1. e3 e5) 1... e5 (1... d6 2. d4 Nf6 3. Nc3 e5 4. dxe5 (4. Nf3 Nbd7) 4... dxe5 5. Qxd8+ Kxd8) 2. Nf3 (2. Nc3 Nf6 3. f4) 2... Nc6 3. d4 exd4 4. Nxd4 *", - } - expectedFinalPos := []string{ - "r1bqkbnr/pppp1ppp/2n5/8/3NP3/8/PPP2PPP/RNBQKB1R b KQkq - 0 4", - } - - pgn := mustParsePGN("fixtures/pgns/variations.pgn") - reader := strings.NewReader(pgn) - scanner := NewScanner(reader) - validateExpand(t, scanner, expectedLastLines, expectedFinalPos) -} - -func TestScannerMultiFromPosNoExpand(t *testing.T) { - expectedLastLines := []string{ - "1. d4 d5 2. c4 c6 { [%eval 0.21]} *", - "3. Nf3 (3. Nc3 Nf6 4. Nf3) 3... Nf6 4. Nc3 { [%eval 0.16]} *", - "4... a6 { [%eval 0.19]} *", - "5. cxd5 (5. e3 e6 6. cxd5 cxd5) 5... cxd5 6. e3 e6 { [%eval 0.11]} *", - } - expectedFinalPos := []string{ - "rnbqkbnr/pp2pppp/2p5/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 0 3", - "rnbqkb1r/pp2pppp/2p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq - 3 4", - "rnbqkb1r/1p2pppp/p1p2n2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 0 5", - "rnbqkb1r/1p3ppp/p3pn2/3p4/3P4/2N1PN2/PP3PPP/R1BQKB1R w KQkq - 0 7", - } - - pgn := mustParsePGN("fixtures/pgns/multi_frompos_games.pgn") - reader := strings.NewReader(pgn) - scanner := NewScanner(reader) - validateExpand(t, scanner, expectedLastLines, expectedFinalPos) -} - -func TestEscapedQuoteInTagValue(t *testing.T) { - const escapedQuoteGame = `[Event "Internet Section 08A g/8'+2\""] -[Site "Dos Hermanas"] -[Date "2004.03.08"] -[Round "6"] -[White "Di Berardino, Diego Rafael"] -[Black "Mar, Fernando"] -[Result "1/2-1/2"] -[ECO "B96"] - -1. e4 c5 2. Nf3 1/2-1/2 -` - - scanner := NewScanner(strings.NewReader(escapedQuoteGame)) - if !scanner.HasNext() { - t.Fatal("scanner should report one game") - } - game, err := scanner.ParseNext() - if err != nil { - t.Fatalf("BUG: parser rejects spec-legal escaped quote in tag value: %v", err) - } - - // The in-memory value should contain a literal double quote. - if game.GetTagPair("Event") != `Internet Section 08A g/8'+2"` { - t.Fatalf("expected unescaped quote in tag value, got %q", game.GetTagPair("Event")) - } - - // Round-trip: String() must emit the escaped form again. - pgn := game.String() - if !strings.Contains(pgn, `[Event "Internet Section 08A g/8'+2\""]`) { - t.Fatalf("round-trip PGN does not contain escaped quote: %s", pgn) - } -} diff --git a/square.go b/square.go index 8a1b8013..eb6d6ee2 100644 --- a/square.go +++ b/square.go @@ -22,8 +22,8 @@ func (sq Square) String() string { return sq.File().String() + sq.Rank().String() } -func (sq Square) Bytes() []byte { - return []byte{sq.File().Byte(), sq.Rank().Byte()} +func (sq Square) Bytes() [2]byte { + return [2]byte{sq.File().Byte(), sq.Rank().Byte()} } // NewSquare creates a new Square from a File and a Rank. @@ -158,136 +158,13 @@ func (f File) Byte() byte { // SquareFromString converts a 2-character square notation (e.g., "e4") to a Square. // Returns NoSquare if the string is not a valid square. func SquareFromString(s string) Square { - switch s { - case "a1": - return A1 - case "a2": - return A2 - case "a3": - return A3 - case "a4": - return A4 - case "a5": - return A5 - case "a6": - return A6 - case "a7": - return A7 - case "a8": - return A8 - case "b1": - return B1 - case "b2": - return B2 - case "b3": - return B3 - case "b4": - return B4 - case "b5": - return B5 - case "b6": - return B6 - case "b7": - return B7 - case "b8": - return B8 - case "c1": - return C1 - case "c2": - return C2 - case "c3": - return C3 - case "c4": - return C4 - case "c5": - return C5 - case "c6": - return C6 - case "c7": - return C7 - case "c8": - return C8 - case "d1": - return D1 - case "d2": - return D2 - case "d3": - return D3 - case "d4": - return D4 - case "d5": - return D5 - case "d6": - return D6 - case "d7": - return D7 - case "d8": - return D8 - case "e1": - return E1 - case "e2": - return E2 - case "e3": - return E3 - case "e4": - return E4 - case "e5": - return E5 - case "e6": - return E6 - case "e7": - return E7 - case "e8": - return E8 - case "f1": - return F1 - case "f2": - return F2 - case "f3": - return F3 - case "f4": - return F4 - case "f5": - return F5 - case "f6": - return F6 - case "f7": - return F7 - case "f8": - return F8 - case "g1": - return G1 - case "g2": - return G2 - case "g3": - return G3 - case "g4": - return G4 - case "g5": - return G5 - case "g6": - return G6 - case "g7": - return G7 - case "g8": - return G8 - case "h1": - return H1 - case "h2": - return H2 - case "h3": - return H3 - case "h4": - return H4 - case "h5": - return H5 - case "h6": - return H6 - case "h7": - return H7 - case "h8": - return H8 - default: + if len(s) != 2 { return NoSquare } + f := int(s[0] - 'a') + r := int(s[1] - '1') + if f < 0 || f > 7 || r < 0 || r > 7 { + return NoSquare + } + return NewSquare(File(f), Rank(r)) } diff --git a/square_test.go b/square_test.go index 6f60d4fb..048ecb4e 100644 --- a/square_test.go +++ b/square_test.go @@ -1,29 +1,186 @@ -package chess +package chess_test -import "testing" +import ( + "testing" -type newSquareTest struct { - f File - r Rank - sq Square + "github.com/corentings/chess/v3" +) + +func TestNewSquare_FromFileAndRank(t *testing.T) { + t.Parallel() + tests := []struct { + name string + f chess.File + r chess.Rank + want chess.Square + }{ + {"a1", chess.FileA, chess.Rank1, chess.A1}, + {"a8", chess.FileA, chess.Rank8, chess.A8}, + {"h1", chess.FileH, chess.Rank1, chess.H1}, + {"h8", chess.FileH, chess.Rank8, chess.H8}, + {"b4", chess.FileB, chess.Rank4, chess.B4}, + {"e8", chess.FileE, chess.Rank8, chess.E8}, + {"h3", chess.FileH, chess.Rank3, chess.H3}, + {"d7", chess.FileD, chess.Rank7, chess.D7}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := chess.NewSquare(tt.f, tt.r); got != tt.want { + t.Errorf("NewSquare(%v,%v) = %s, want %s", tt.f, tt.r, got.String(), tt.want.String()) + } + }) + } +} + +func TestNewSquare_RoundTripsThroughFileAndRank(t *testing.T) { + t.Parallel() + for _, sq := range allSquares() { + t.Run(sq.String(), func(t *testing.T) { + t.Parallel() + if got := chess.NewSquare(sq.File(), sq.Rank()); got != sq { + t.Errorf("NewSquare(%s.File(), %s.Rank()) = %s, want %s", sq, sq, got, sq) + } + }) + } +} + +func TestSquare_StringRoundTrip(t *testing.T) { + t.Parallel() + for _, sq := range allSquares() { + t.Run(sq.String(), func(t *testing.T) { + t.Parallel() + if got := chess.SquareFromString(sq.String()); got != sq { + t.Errorf("SquareFromString(%q) = %v, want %v", sq.String(), got, sq) + } + }) + } } -func TestNewSquare(t *testing.T) { - testCases := []newSquareTest{ - {FileA, Rank1, A1}, - {FileA, Rank8, A8}, - {FileH, Rank1, H1}, - {FileH, Rank8, H8}, - {FileB, Rank4, B4}, - {FileE, Rank8, E8}, - {FileH, Rank3, H3}, - {FileD, Rank7, D7}, +func TestSquare_Bytes(t *testing.T) { + t.Parallel() + for _, sq := range allSquares() { + t.Run(sq.String(), func(t *testing.T) { + t.Parallel() + b := sq.Bytes() + if len(b) != 2 { + t.Fatalf("%s.Bytes() len = %d, want 2", sq, len(b)) + } + if string(b[:]) != sq.String() { + t.Errorf("%s.Bytes() = %q, want %q", sq, string(b[:]), sq.String()) + } + }) + } +} + +func TestSquareFromString(t *testing.T) { + t.Parallel() + valid := []struct { + in string + want chess.Square + }{ + {"a1", chess.A1}, + {"a8", chess.A8}, + {"h1", chess.H1}, + {"h8", chess.H8}, + {"e4", chess.E4}, + {"d1", chess.D1}, + {"g5", chess.G5}, + } + for _, tt := range valid { + t.Run("valid/"+tt.in, func(t *testing.T) { + t.Parallel() + if got := chess.SquareFromString(tt.in); got != tt.want { + t.Errorf("SquareFromString(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } + invalid := []string{"", "a", "a9", "a0", "i1", "z9", "A1", "e44", " e4", "e4 ", "##", "e"} + for _, in := range invalid { + t.Run("invalid/"+in, func(t *testing.T) { + t.Parallel() + if got := chess.SquareFromString(in); got != chess.NoSquare { + t.Errorf("SquareFromString(%q) = %v, want NoSquare", in, got) + } + }) } +} + +func TestFile_StringAndByte(t *testing.T) { + t.Parallel() + tests := []struct { + name string + f chess.File + str string + b byte + }{ + {"file_a", chess.FileA, "a", 'a'}, + {"file_b", chess.FileB, "b", 'b'}, + {"file_c", chess.FileC, "c", 'c'}, + {"file_d", chess.FileD, "d", 'd'}, + {"file_e", chess.FileE, "e", 'e'}, + {"file_f", chess.FileF, "f", 'f'}, + {"file_g", chess.FileG, "g", 'g'}, + {"file_h", chess.FileH, "h", 'h'}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.f.String(); got != tt.str { + t.Errorf("String() = %q, want %q", got, tt.str) + } + if got := tt.f.Byte(); got != tt.b { + t.Errorf("Byte() = %q, want %q", got, tt.b) + } + }) + } +} + +func TestRank_StringAndByte(t *testing.T) { + t.Parallel() + tests := []struct { + name string + r chess.Rank + str string + b byte + }{ + {"rank_1", chess.Rank1, "1", '1'}, + {"rank_2", chess.Rank2, "2", '2'}, + {"rank_3", chess.Rank3, "3", '3'}, + {"rank_4", chess.Rank4, "4", '4'}, + {"rank_5", chess.Rank5, "5", '5'}, + {"rank_6", chess.Rank6, "6", '6'}, + {"rank_7", chess.Rank7, "7", '7'}, + {"rank_8", chess.Rank8, "8", '8'}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.r.String(); got != tt.str { + t.Errorf("String() = %q, want %q", got, tt.str) + } + if got := tt.r.Byte(); got != tt.b { + t.Errorf("Byte() = %q, want %q", got, tt.b) + } + }) + } +} + +func TestNoSquare_IsMinusOne(t *testing.T) { + if chess.NoSquare != -1 { + t.Errorf("NoSquare = %d, want -1", chess.NoSquare) + } + if got := chess.SquareFromString("not-a-square"); got != chess.NoSquare { + t.Errorf("SquareFromString(invalid) = %v, want NoSquare", got) + } +} - for _, testCase := range testCases { - square := NewSquare(testCase.f, testCase.r) - if square != testCase.sq { - t.Fatalf("expected %s, got %s", testCase.sq.String(), square.String()) +func allSquares() []chess.Square { + squares := make([]chess.Square, 0, 64) + for r := chess.Rank1; r <= chess.Rank8; r++ { + for f := chess.FileA; f <= chess.FileH; f++ { + squares = append(squares, chess.NewSquare(f, r)) } } + return squares } diff --git a/testdata/outcomes/agreeddraw.golden b/testdata/outcomes/agreeddraw.golden new file mode 100644 index 00000000..5541da6c --- /dev/null +++ b/testdata/outcomes/agreeddraw.golden @@ -0,0 +1,2 @@ +pgn: outcome=1/2-1/2 method=NoMethod +split[0]: outcome=* method=NoMethod result=none diff --git a/testdata/outcomes/agreeddraw.pgn b/testdata/outcomes/agreeddraw.pgn new file mode 100644 index 00000000..5077590b --- /dev/null +++ b/testdata/outcomes/agreeddraw.pgn @@ -0,0 +1,3 @@ +[Result "1/2-1/2"] + +1. e4 e5 * diff --git a/testdata/outcomes/fivefold.golden b/testdata/outcomes/fivefold.golden new file mode 100644 index 00000000..eaa5223f --- /dev/null +++ b/testdata/outcomes/fivefold.golden @@ -0,0 +1,2 @@ +pgn: outcome=* method=NoMethod +split[0]: outcome=1/2-1/2 method=FivefoldRepetition result=1/2-1/2 diff --git a/testdata/outcomes/fivefold.pgn b/testdata/outcomes/fivefold.pgn new file mode 100644 index 00000000..b3c5b13a --- /dev/null +++ b/testdata/outcomes/fivefold.pgn @@ -0,0 +1 @@ +1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8 5. Nf3 Nf6 6. Ng1 Ng8 7. Nf3 Nf6 8. Ng1 Ng8 * diff --git a/testdata/outcomes/foolsmate.golden b/testdata/outcomes/foolsmate.golden new file mode 100644 index 00000000..c38d8275 --- /dev/null +++ b/testdata/outcomes/foolsmate.golden @@ -0,0 +1,2 @@ +pgn: outcome=0-1 method=Checkmate +split[0]: outcome=0-1 method=Checkmate result=0-1 diff --git a/testdata/outcomes/foolsmate.pgn b/testdata/outcomes/foolsmate.pgn new file mode 100644 index 00000000..835f6ef9 --- /dev/null +++ b/testdata/outcomes/foolsmate.pgn @@ -0,0 +1 @@ +1. f3 e5 2. g4 Qh4# 0-1 diff --git a/testdata/outcomes/resignation_variation.golden b/testdata/outcomes/resignation_variation.golden new file mode 100644 index 00000000..0ab6f5b1 --- /dev/null +++ b/testdata/outcomes/resignation_variation.golden @@ -0,0 +1,3 @@ +pgn: outcome=1-0 method=NoMethod +split[0]: outcome=* method=NoMethod result=none +split[1]: outcome=* method=NoMethod result=none diff --git a/testdata/outcomes/resignation_variation.pgn b/testdata/outcomes/resignation_variation.pgn new file mode 100644 index 00000000..9628ccfe --- /dev/null +++ b/testdata/outcomes/resignation_variation.pgn @@ -0,0 +1 @@ +1. e4 (1. d4 d5) e5 2. Nf3 1-0 diff --git a/uci/adapter.go b/uci/adapter.go new file mode 100644 index 00000000..b528073b --- /dev/null +++ b/uci/adapter.go @@ -0,0 +1,98 @@ +package uci + +import ( + "bufio" + "errors" + "fmt" + "io" + "os/exec" +) + +// Adapter abstracts the transport layer between the Engine and a UCI process. +// Implementations send a command and return the engine's response lines. +type Adapter interface { + Exchange(cmd Cmd) ([]string, error) + Close() error +} + +// SubprocessAdapter is an Adapter that communicates with a UCI engine running +// as a subprocess via stdin/stdout pipes. +type SubprocessAdapter struct { + cmd *exec.Cmd + writer *io.PipeWriter + reader *io.PipeReader + scanner *bufio.Scanner +} + +// NewSubprocessAdapter locates the executable at the given path, starts it as a +// subprocess, and returns a SubprocessAdapter wired to its stdin/stdout. +func NewSubprocessAdapter(path string) (*SubprocessAdapter, error) { + path, err := exec.LookPath(path) + if err != nil { + return nil, fmt.Errorf("uci: executable not found at path %s %w", path, err) + } + rIn, wIn := io.Pipe() + rOut, wOut := io.Pipe() + cmd := exec.Command(path) //nolint:noctx // lifecycle is managed explicitly via Close() which kills the process + cmd.Stdin = rIn + cmd.Stdout = wOut + if err = cmd.Start(); err != nil { + _ = rIn.Close() + _ = wIn.Close() + _ = rOut.Close() + _ = wOut.Close() + return nil, fmt.Errorf("uci: failed to start executable %s: %w", path, err) + } + go func() { + _ = cmd.Wait() + }() + return &SubprocessAdapter{ + cmd: cmd, + writer: wIn, + reader: rOut, + scanner: bufio.NewScanner(rOut), + }, nil +} + +// Exchange sends a command to the engine and returns the response lines, or an +// error if communication fails. +func (s *SubprocessAdapter) Exchange(cmd Cmd) ([]string, error) { + if _, err := fmt.Fprintln(s.writer, cmd.String()); err != nil { + return nil, err + } + if cmd.IsDone("") { + return nil, nil + } + var lines []string + for s.scanner.Scan() { + line := s.scanner.Text() + lines = append(lines, line) + if cmd.IsDone(line) { + break + } + } + if err := s.scanner.Err(); err != nil { + return lines, err + } + return lines, nil +} + +// Close closes the communication pipes and kills the subprocess. +func (s *SubprocessAdapter) Close() error { + var errs []error + if s.writer != nil { + errs = append(errs, s.writer.Close()) + } + if s.reader != nil { + errs = append(errs, s.reader.Close()) + } + if s.cmd != nil && s.cmd.Process != nil { + errs = append(errs, s.cmd.Process.Kill()) + } + return errors.Join(errs...) +} + +// Pid returns the operating system process ID of the engine subprocess. +func (s *SubprocessAdapter) Pid() int { + return s.cmd.Process.Pid +} diff --git a/uci/adapter_test.go b/uci/adapter_test.go new file mode 100644 index 00000000..8559953c --- /dev/null +++ b/uci/adapter_test.go @@ -0,0 +1,577 @@ +package uci_test + +import ( + "bytes" + "log" + "strings" + "testing" + + "github.com/corentings/chess/v3" + "github.com/corentings/chess/v3/uci" +) + +func Test_FakeAdapter_ExchangeEmptyCommand(t *testing.T) { + emptyCmd := fakeEmptyCmd{} + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "": {"ignored"}, + "go": {"bestmove e2e4"}, + }, + } + t.Run("explicit empty key configured", func(t *testing.T) { + lines, err := fake.Exchange(emptyCmd) + if err != nil { + t.Fatalf("Exchange returned error: %v", err) + } + if len(lines) != 1 || lines[0] != "ignored" { + t.Errorf("expected canned response for empty key, got %v", lines) + } + }) + t.Run("unconfigured empty key returns nil", func(t *testing.T) { + fake2 := &uci.FakeAdapter{Responses: map[string][]string{}} + lines, err := fake2.Exchange(emptyCmd) + if err != nil { + t.Fatalf("Exchange returned error: %v", err) + } + if lines != nil { + t.Errorf("expected nil for unconfigured empty key, got %v", lines) + } + }) +} + +type fakeEmptyCmd struct{} + +func (fakeEmptyCmd) String() string { return "" } +func (fakeEmptyCmd) IsDone(_ string) bool { return false } +func (fakeEmptyCmd) LockRequired() bool { return false } +func (fakeEmptyCmd) Handle(_ []string, _ *uci.Engine) error { return nil } + +func Test_InfoUnmarshalText_WDLBounds(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + wantErrMsg string + }{ + { + name: "valid wdl with three values", + input: "info depth 24 seldepth 32 multipv 1 score cp 29 wdl 791 209 0 nodes 5130101 nps 819897", + wantErr: false, + }, + { + name: "truncated wdl missing draw and loss", + input: "info depth 24 score cp 29 wdl 791", + wantErr: true, + }, + { + name: "truncated wdl missing loss", + input: "info depth 24 score cp 29 wdl 791 209", + wantErr: true, + }, + { + name: "wdl non-integer value", + input: "info depth 24 score cp 29 wdl 791 x 0", + wantErr: true, + wantErrMsg: "uci: invalid info wdl draw", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info uci.Info + err := info.UnmarshalText([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalText(%q) err = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Fatalf("UnmarshalText(%q) err = %v, want substring %q", tt.input, err, tt.wantErrMsg) + } + }) + } +} + +func Test_FakeAdapter_CmdUCI(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"id name TestEngine", "id author test", "option name Hash type spin default 16 min 1 max 33554432", "uciok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + err := eng.Run(uci.CmdUCI{}) + if err != nil { + t.Fatal(err) + } + + id := eng.ID() + if id["name"] != "TestEngine" { + t.Errorf("expected name TestEngine, got %s", id["name"]) + } + if id["author"] != "test" { + t.Errorf("expected author test, got %s", id["author"]) + } + + opts := eng.Options() + if _, ok := opts["Hash"]; !ok { + t.Error("expected Hash option") + } +} + +func Test_FakeAdapter_CmdGo(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": {"info depth 10 score cp 50 nodes 1000 nps 500000 tbhits 0 time 2 pv e2e4", "bestmove e2e4"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + results := eng.SearchResults() + if results.BestMove == (chess.Move{}) { + t.Fatal("expected best move") + } + if results.Info.Depth != 10 { + t.Errorf("expected depth 10, got %d", results.Info.Depth) + } + if results.Info.Score.CP != 50 { + t.Errorf("expected score cp 50, got %d", results.Info.Score.CP) + } +} + +func Test_FakeAdapter_CmdEval(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "eval": {"Final evaluation 12.5"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + if err := eng.Run(cmdPos, uci.CmdEval{}); err != nil { + t.Fatal(err) + } + + eval := eng.Eval() + if eval != 1250 { + t.Errorf("expected eval 1250, got %d", eval) + } +} + +func Test_FakeAdapter_MultiPV(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": { + "info depth 10 multipv 1 score cp 50 nodes 1000 pv e2e4", + "info depth 10 multipv 2 score cp 30 nodes 1000 pv d2d4", + "bestmove e2e4", + }, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + results := eng.SearchResults() + if len(results.MultiPVInfo) != 2 { + t.Fatalf("expected 2 MultiPV lines, got %d", len(results.MultiPVInfo)) + } + if results.MultiPVInfo[0].Score.CP != 50 { + t.Errorf("expected cp 50 for pv 1, got %d", results.MultiPVInfo[0].Score.CP) + } + if results.MultiPVInfo[1].Score.CP != 30 { + t.Errorf("expected cp 30 for pv 2, got %d", results.MultiPVInfo[1].Score.CP) + } +} + +func Test_EngineSearchResultsReturnsDefensiveCopy(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": { + "info depth 10 multipv 1 score cp 50 nodes 1000 pv e2e4 e7e5", + "info depth 10 multipv 2 score cp 30 nodes 1000 pv d2d4 d7d5", + "bestmove e2e4", + }, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + pos := chess.StartingPosition() + if err := eng.Run(uci.CmdPosition{Position: pos}, uci.CmdGo{MoveTime: 100}); err != nil { + t.Fatal(err) + } + + results := eng.SearchResults() + results.Info.PV[0] = chess.Move{} + results.MultiPVInfo[0] = uci.Info{} + results.MultiPVInfo[1].PV[0] = chess.Move{} + + fresh := eng.SearchResults() + if fresh.Info.PV[0] == (chess.Move{}) { + t.Fatal("SearchResults returned mutable Info.PV backing array") + } + if fresh.MultiPVInfo[0].Score.CP != 50 { + t.Fatalf("SearchResults returned mutable MultiPVInfo backing array, cp = %d", fresh.MultiPVInfo[0].Score.CP) + } + if fresh.MultiPVInfo[1].PV[0] == (chess.Move{}) { + t.Fatal("SearchResults returned mutable MultiPVInfo PV backing array") + } +} + +func Test_FakeAdapter_CmdIsReady(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "isready": {"readyok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdIsReady{}); err != nil { + t.Fatal(err) + } +} + +func Test_FakeAdapter_FireAndForgetPassthrough(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{}, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCINewGame{}); err != nil { + t.Fatal(err) + } + if err := eng.Run(uci.CmdStop{}); err != nil { + t.Fatal(err) + } + if err := eng.Run(uci.CmdPonderHit{}); err != nil { + t.Fatal(err) + } +} + +func Test_FakeAdapter_CmdPositionStoredOnEngine(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "position": {"readyok"}, + "go": {"bestmove e7e5"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + fenStr := "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1" + pos := &chess.Position{} + if err := pos.UnmarshalText([]byte(fenStr)); err != nil { + t.Fatal(err) + } + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + bestMove := eng.SearchResults().BestMove + if bestMove == (chess.Move{}) { + t.Fatal("expected best move decoded against non-starting position") + } + if bestMove.S1().String() != "e7" || bestMove.S2().String() != "e5" { + t.Errorf("expected e7e5, got %s%s", bestMove.S1(), bestMove.S2()) + } +} + +func Test_EngineIDReturnsDefensiveCopy(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"id name TestEngine", "uciok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}); err != nil { + t.Fatal(err) + } + + id := eng.ID() + id["name"] = "Mutated" + id["injected"] = "value" + + id2 := eng.ID() + if id2["name"] != "TestEngine" { + t.Errorf("ID() returned shared map; external mutation leaked back, got name=%q", id2["name"]) + } + if _, ok := id2["injected"]; ok { + t.Errorf("ID() returned shared map; injected key persisted across calls") + } +} + +func Test_EngineOptionsReturnsDefensiveCopy(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"option name Hash type spin default 16 min 1 max 1024", "uciok"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}); err != nil { + t.Fatal(err) + } + + opts := eng.Options() + opts["Hash"] = uci.Option{Name: "Hash", Type: uci.OptionSpin, Default: "9999"} + opts["Injected"] = uci.Option{Name: "Injected"} + + opts2 := eng.Options() + if opts2["Hash"].Default != "16" { + t.Errorf("Options() returned shared map; external mutation leaked back, got Hash.Default=%q", opts2["Hash"].Default) + } + if _, ok := opts2["Injected"]; ok { + t.Errorf("Options() returned shared map; injected key persisted across calls") + } +} + +func Test_FakeAdapter_FireAndForgetNoResponseShortCircuit(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{}, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCINewGame{}); err != nil { + t.Fatalf("CmdUCINewGame should short-circuit on empty response: %v", err) + } + if err := eng.Run(uci.CmdStop{}); err != nil { + t.Fatalf("CmdStop should short-circuit on empty response: %v", err) + } + if err := eng.Run(uci.CmdPonderHit{}); err != nil { + t.Fatalf("CmdPonderHit should short-circuit on empty response: %v", err) + } +} + +func Test_FakeAdapter_CmdSetOptionRendersCorrectly(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "setoption": {}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + cmd := uci.CmdSetOption{Name: "UCI_Elo", Value: "1500"} + got := cmd.String() + want := "setoption name UCI_Elo value 1500" + if got != want { + t.Errorf("CmdSetOption.String() = %q, want %q", got, want) + } + if err := eng.Run(cmd); err != nil { + t.Fatal(err) + } +} + +func Test_InfoUnmarshalTextWDL(t *testing.T) { + info := &uci.Info{} + line := "info depth 24 seldepth 32 multipv 1 score cp 29 wdl 791 209 0 nodes 5130101 nps 819897 hashfull 967 tbhits 0 time 6257 pv d2d4" + if err := info.UnmarshalText([]byte(line)); err != nil { + t.Fatal(err) + } + if info.Score.Win != 791 || info.Score.Draw != 209 || info.Score.Loss != 0 { + t.Errorf("wdl parse: got Win=%d Draw=%d Loss=%d, want 791/209/0", info.Score.Win, info.Score.Draw, info.Score.Loss) + } + if info.Score.CP != 29 { + t.Errorf("cp parse: got %d, want 29", info.Score.CP) + } + if info.Depth != 24 || info.Nodes != 5130101 || info.Hashfull != 967 { + t.Errorf("field parse: got depth=%d nodes=%d hashfull=%d, want 24/5130101/967", info.Depth, info.Nodes, info.Hashfull) + } +} + +func Test_FakeAdapter_FullGame(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": {"id name FakeFish", "uciok"}, + "isready": {"readyok"}, + "go": {"info depth 1 score cp 10 nodes 1 pv e2e4", "bestmove e2e4"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}); err != nil { + t.Fatal(err) + } + + id := eng.ID() + if id["name"] != "FakeFish" { + t.Errorf("expected name FakeFish, got %s", id["name"]) + } + + pos := chess.StartingPosition() + cmdPos := uci.CmdPosition{Position: pos} + cmdGo := uci.CmdGo{MoveTime: 100} + if err := eng.Run(cmdPos, cmdGo); err != nil { + t.Fatal(err) + } + + bestMove := eng.SearchResults().BestMove + if bestMove == (chess.Move{}) { + t.Fatal("expected best move") + } + san, err := chess.SAN().Encode(pos, bestMove) + if err != nil { + t.Fatalf("SAN encode failed: %v", err) + } + if san != "e4" { + t.Errorf("expected e4, got %s", san) + } +} + +func Test_CmdEval_MalformedReturnsError(t *testing.T) { + pos := chess.StartingPosition() + + t.Run("non-numeric value", func(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "eval": {"Final evaluation notanumber"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + err := eng.Run(uci.CmdPosition{Position: pos}, uci.CmdEval{}) + if err == nil { + t.Fatal("expected error for malformed eval value, got nil") + } + if !strings.Contains(err.Error(), "notanumber") { + t.Errorf("expected error to mention the bad value, got %v", err) + } + }) + + t.Run("missing value", func(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "eval": {"Final evaluation"}, + }, + } + eng := uci.NewWithAdapter(fake) + defer eng.Close() + + err := eng.Run(uci.CmdPosition{Position: pos}, uci.CmdEval{}) + if err == nil { + t.Fatal("expected error for eval line with no value, got nil") + } + }) +} + +func Test_CmdUCI_MalformedOptionLoggedUnderDebug(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "uci": { + "id name TestEngine", + "option name Hash type spin default 16 min 1 max 1024", + "option name Bad type notatype default 1", + "uciok", + }, + }, + } + + t.Run("logged and non-fatal when debug on", func(t *testing.T) { + var buf bytes.Buffer + eng := uci.NewWithAdapter(fake, uci.Logger(log.New(&buf, "", 0)), uci.Debug) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}); err != nil { + t.Fatalf("malformed option should not be fatal, got %v", err) + } + + opts := eng.Options() + if _, ok := opts["Hash"]; !ok { + t.Error("expected the valid Hash option to still be parsed") + } + if _, ok := opts["Bad"]; ok { + t.Error("malformed option should not appear in Options()") + } + + if !strings.Contains(buf.String(), "dropping malformed option line") { + t.Errorf("expected malformed option to be logged, got %q", buf.String()) + } + }) + + t.Run("silent when debug off", func(t *testing.T) { + var buf bytes.Buffer + eng := uci.NewWithAdapter(fake, uci.Logger(log.New(&buf, "", 0))) + defer eng.Close() + + if err := eng.Run(uci.CmdUCI{}); err != nil { + t.Fatalf("malformed option should not be fatal, got %v", err) + } + if buf.String() != "" { + t.Errorf("expected no logging without debug, got %q", buf.String()) + } + }) +} + +func Test_CmdGo_MalformedInfoLineLoggedUnderDebug(t *testing.T) { + fake := &uci.FakeAdapter{ + Responses: map[string][]string{ + "go": { + "info depth abc", + "info depth 10 score cp 50 nodes 1000 pv e2e4", + "bestmove e2e4", + }, + }, + } + + t.Run("logged and non-fatal when debug on", func(t *testing.T) { + var buf bytes.Buffer + eng := uci.NewWithAdapter(fake, uci.Logger(log.New(&buf, "", 0)), uci.Debug) + defer eng.Close() + + pos := chess.StartingPosition() + if err := eng.Run(uci.CmdPosition{Position: pos}, uci.CmdGo{MoveTime: 100}); err != nil { + t.Fatalf("malformed info line should not be fatal, got %v", err) + } + + results := eng.SearchResults() + if results.BestMove == (chess.Move{}) { + t.Fatal("expected best move to still be parsed") + } + if results.Info.Depth != 10 { + t.Errorf("expected valid info depth 10 to survive, got %d", results.Info.Depth) + } + + if !strings.Contains(buf.String(), "dropping unparseable info line") { + t.Errorf("expected malformed info line to be logged, got %q", buf.String()) + } + }) + + t.Run("silent when debug off", func(t *testing.T) { + var buf bytes.Buffer + eng := uci.NewWithAdapter(fake, uci.Logger(log.New(&buf, "", 0))) + defer eng.Close() + + pos := chess.StartingPosition() + if err := eng.Run(uci.CmdPosition{Position: pos}, uci.CmdGo{MoveTime: 100}); err != nil { + t.Fatalf("malformed info line should not be fatal, got %v", err) + } + if buf.String() != "" { + t.Errorf("expected no logging without debug, got %q", buf.String()) + } + }) +} diff --git a/uci/cmd.go b/uci/cmd.go index 5d28bd1f..d415f48e 100644 --- a/uci/cmd.go +++ b/uci/cmd.go @@ -1,7 +1,6 @@ package uci import ( - "bufio" "errors" "fmt" "math" @@ -9,155 +8,154 @@ import ( "strings" "time" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) -// Cmd is a UCI compliant command. +// Cmd represents a command sent to a UCI engine. Each implementation defines +// how the command is serialized (String), when the engine's response is +// complete (IsDone), how to process the response (Handle), and whether the +// command requires exclusive access to the engine (LockRequired). type Cmd interface { fmt.Stringer - ProcessResponse(e *Engine) error + IsDone(line string) bool + Handle(lines []string, e *Engine) error + LockRequired() bool } -type cmdNoOptions struct { - F func(e *Engine) error - Name string -} +// CmdUCI sends the "uci" command and waits for the "uciok" response. The engine +// reports its identity and available options. +type CmdUCI struct{} -func (cmd cmdNoOptions) String() string { - return cmd.Name -} +func (CmdUCI) String() string { return "uci" } -func (cmd cmdNoOptions) ProcessResponse(e *Engine) error { - return cmd.F(e) -} +func (CmdUCI) IsDone(line string) bool { return line == "uciok" } -var ( - // CmdUCI corresponds to the "uci" command: - // tell engine to use the uci (universal chess interface), - // this will be send once as a first command after program boot - // to tell the engine to switch to uci mode. - // After receiving the uci command the engine must identify itself with the "id" command - // and sent the "option" commands to tell the GUI which engine settings the engine supports if any. - // After that the engine should sent "uciok" to acknowledge the uci mode. - // If no uciok is sent within a certain time period, the engine task will be killed by the GUI. - //nolint:gochecknoglobals // Will need to improve this - // TODO: Remove global variable - CmdUCI = cmdNoOptions{Name: "uci", F: func(e *Engine) error { - e.id = map[string]string{} - e.options = map[string]Option{} - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - k, v, err := parseIDLine(text) - if err == nil { - e.id[k] = v - continue - } - o := &Option{} - err = o.UnmarshalText([]byte(text)) - if err == nil { - e.options[o.Name] = *o - continue - } - if text == "uciok" { - break - } +func (CmdUCI) LockRequired() bool { return true } + +func (CmdUCI) Handle(lines []string, e *Engine) error { + e.id = map[string]string{} + e.options = map[string]Option{} + for _, text := range lines { + k, v, err := parseIDLine(text) + if err == nil { + e.id[k] = v + continue + } + o := &Option{} + errOpt := o.UnmarshalText([]byte(text)) + if errOpt == nil { + e.options[o.Name] = *o + continue } - return nil - }} - - // CmdIsReady corresponds to the "isready" command: - // this is used to synchronize the engine with the GUI. When the GUI has sent a command or - // multiple commands that can take some time to complete, - // this command can be used to wait for the engine to be ready again or - // to ping the engine to find out if it is still alive. - // E.g. this should be sent after setting the path to the tablebases as this can take some time. - // This command is also required once before the engine is asked to do any search - // to wait for the engine to finish initializing. - // This command must always be answered with "readyok" and can be sent also when the engine is calculating - // in which case the engine should also immediately answer with "readyok" without stopping the search. - CmdIsReady = cmdNoOptions{Name: "isready", F: func(e *Engine) error { - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - if text == "readyok" { - break + if e.debug { + switch { + case strings.HasPrefix(text, "id "): + e.logger.Printf("uci: dropping malformed id line %q: %v", text, err) + case strings.HasPrefix(text, "option "): + e.logger.Printf("uci: dropping malformed option line %q: %v", text, errOpt) } } - return nil - }} - - // CmdUCINewGame corresponds to the "ucinewgame" command: - // this is sent to the engine when the next search (started with "position" and "go") will be from - // a different game. This can be a new game the engine should play or a new game it should analyse but - // also the next position from a testsuite with positions only. - // If the GUI hasn't sent a "ucinewgame" before the first "position" command, the engine shouldn't - // expect any further ucinewgame commands as the GUI is probably not supporting the ucinewgame command. - // So the engine should not rely on this command even though all new GUIs should support it. - // As the engine's reaction to "ucinewgame" can take some time the GUI should always send "isready" - // after "ucinewgame" to wait for the engine to finish its operation. - CmdUCINewGame = cmdNoOptions{Name: "ucinewgame", F: func(_ *Engine) error { - return nil - }} - - // CmdPonderHit corresponds to the "ponderhit" command: - // the user has played the expected move. This will be sent if the engine was told to ponder on the same move - // the user has played. The engine should continue searching but switch from pondering to normal search. - CmdPonderHit = cmdNoOptions{Name: "ponderhit", F: func(_ *Engine) error { - return nil - }} - - // CmdStop corresponds to the "stop" command: - // stop calculating as soon as possible, - // don't forget the "bestmove" and possibly the "ponder" token when finishing the search. - CmdStop = cmdNoOptions{Name: "stop", F: func(_ *Engine) error { - return nil - }} - - // CmdQuit (shouldn't be used directly as its handled by Engine.Close()) corresponds to the "quit" command: - // quit the program as soon as possible. - CmdQuit = cmdNoOptions{Name: "quit", F: func(_ *Engine) error { - return nil - }} - - // CmdEval is a non-standard command that requests the engine's static evaluation of the current position. - CmdEval = cmdNoOptions{Name: "eval", F: func(e *Engine) error { - scanner := bufio.NewScanner(e.out) - for scanner.Scan() { - text := e.readLine(scanner) - if strings.Contains(text, "error") { - return errors.New("eval command not supported") + } + return nil +} + +// CmdIsReady sends the "isready" command and waits for the "readyok" response, +// confirming the engine is ready to accept further commands. +type CmdIsReady struct{} + +func (CmdIsReady) String() string { return "isready" } + +func (CmdIsReady) IsDone(line string) bool { return line == "readyok" } + +func (CmdIsReady) LockRequired() bool { return true } + +func (CmdIsReady) Handle(_ []string, _ *Engine) error { return nil } + +// CmdUCINewGame sends the "ucinewgame" command, signaling the engine to clear +// any state from previous games (e.g. hash tables). +type CmdUCINewGame struct{} + +func (CmdUCINewGame) String() string { return "ucinewgame" } + +func (CmdUCINewGame) IsDone(_ string) bool { return true } + +func (CmdUCINewGame) LockRequired() bool { return true } + +func (CmdUCINewGame) Handle(_ []string, _ *Engine) error { return nil } + +// CmdPonderHit sends the "ponderhit" command, informing the engine that the +// opponent has played the move it was pondering. +type CmdPonderHit struct{} + +func (CmdPonderHit) String() string { return "ponderhit" } + +func (CmdPonderHit) IsDone(_ string) bool { return true } + +func (CmdPonderHit) LockRequired() bool { return false } + +func (CmdPonderHit) Handle(_ []string, _ *Engine) error { return nil } + +// CmdStop sends the "stop" command, instructing the engine to stop calculating +// and return the best move found so far. +type CmdStop struct{} + +func (CmdStop) String() string { return "stop" } + +func (CmdStop) IsDone(_ string) bool { return true } + +func (CmdStop) LockRequired() bool { return false } + +func (CmdStop) Handle(_ []string, _ *Engine) error { return nil } + +// CmdQuit sends the "quit" command, instructing the engine to exit. +type CmdQuit struct{} + +func (CmdQuit) String() string { return "quit" } + +func (CmdQuit) IsDone(_ string) bool { return true } + +func (CmdQuit) LockRequired() bool { return true } + +func (CmdQuit) Handle(_ []string, _ *Engine) error { return nil } + +// CmdEval sends the "eval" command and parses the engine's static evaluation +// output. Not all engines support this command. +type CmdEval struct{} + +func (CmdEval) String() string { return "eval" } + +func (CmdEval) IsDone(line string) bool { + lower := strings.ToLower(line) + return strings.HasPrefix(line, "Final evaluation") || + strings.Contains(lower, "error") || + strings.Contains(lower, "unknown command") +} + +func (CmdEval) LockRequired() bool { return true } + +func (CmdEval) Handle(lines []string, e *Engine) error { + for _, text := range lines { + lower := strings.ToLower(text) + if strings.Contains(lower, "error") || strings.Contains(lower, "unknown command") { + return errors.New("eval command not supported") + } + if strings.HasPrefix(text, "Final evaluation") { + parts := strings.Fields(text) + if len(parts) < 3 { + return fmt.Errorf("uci: malformed eval line %q", text) } - if strings.HasPrefix(text, "Final evaluation") { - parts := strings.Fields(text) - if len(parts) >= 3 { - evalStr := parts[2] - eval, err := strconv.ParseFloat(evalStr, 64) - if err == nil { - e.eval = int(math.Round(eval * 100)) - } - break - } + eval, err := strconv.ParseFloat(parts[2], 64) + if err != nil { + return fmt.Errorf("uci: invalid eval value %q: %w", parts[2], err) } + e.eval = int(math.Round(eval * 100)) + break } - return nil - }} -) + } + return nil +} -// CmdSetOption corresponds to the "setoption" command: -// this is sent to the engine when the user wants to change the internal parameters -// of the engine. For the "button" type no value is needed. -// One string will be sent for each parameter and this will only be sent when the engine is waiting. -// The name of the option in should not be case sensitive and can inludes spaces like also the value. -// The substrings "value" and "name" should be avoided in and to allow unambiguous parsing, -// for example do not use = "draw value". -// Here are some strings for the example below: -// -// "setoption name Nullmove value true\n" -// "setoption name Selectivity value 3\n" -// "setoption name Style value Risky\n" -// "setoption name Clear Hash\n" -// "setoption name NalimovPath value c:\chess\tb\4;c:\chess\tb\5\n" +// CmdSetOption sends a "setoption" command to change an engine option at runtime. type CmdSetOption struct { Name string Value string @@ -167,20 +165,17 @@ func (cmd CmdSetOption) String() string { return fmt.Sprintf("setoption name %s value %s", cmd.Name, cmd.Value) } -// ProcessResponse implements the Cmd interface. -func (cmd CmdSetOption) ProcessResponse(_ *Engine) error { - return nil -} +func (CmdSetOption) IsDone(_ string) bool { return true } + +func (CmdSetOption) LockRequired() bool { return false } + +func (CmdSetOption) Handle(_ []string, _ *Engine) error { return nil } -// CmdPosition corresponds to the "position" command: -// set up the position described in fenstring on the internal board and -// play the moves on the internal chess board. -// if the game was played from the start position the string "startpos" will be sent -// Note: no "new" command is needed. However, if this position is from a different game than -// the last position sent to the engine, the GUI should have sent a "ucinewgame" inbetween. +// CmdPosition sends a "position" command to set the engine's current position, +// optionally applying a sequence of moves from the starting position or a FEN. type CmdPosition struct { Position *chess.Position - Moves []*chess.Move + Moves []chess.Move } func (cmd CmdPosition) String() string { @@ -192,59 +187,22 @@ func (cmd CmdPosition) String() string { } moveStrs := []string{} for _, m := range cmd.Moves { - mStr := chess.UCINotation{}.Encode(nil, m) + mStr, _ := chess.UCI().Encode(nil, m) moveStrs = append(moveStrs, mStr) } return fmt.Sprintf("position fen %s moves %s", cmd.Position, strings.Join(moveStrs, " ")) } -// ProcessResponse implements the Cmd interface. -func (CmdPosition) ProcessResponse(_ *Engine) error { - return nil -} +func (CmdPosition) IsDone(_ string) bool { return true } + +func (CmdPosition) LockRequired() bool { return true } -// CmdGo corresponds to the "go" command: -// start calculating on the current position set up with the "position" command. -// There are a number of commands that can follow this command, all will be sent in the same string. -// If one command is not send its value should be interpreted as it would not influence the search. -// - searchmoves .... -// restrict search to this moves only -// Example: After "position startpos" and "go infinite searchmoves e2e4 d2d4" -// the engine should only search the two moves e2e4 and d2d4 in the initial position. -// - ponder -// start searching in pondering mode. -// Do not exit the search in ponder mode, even if it's mate! -// This means that the last move sent in in the position string is the ponder move. -// The engine can do what it wants to do, but after a "ponderhit" command -// it should execute the suggested move to ponder on. This means that the ponder move sent by -// the GUI can be interpreted as a recommendation about which move to ponder. However, if the -// engine decides to ponder on a different move, it should not display any mainlines as they are -// likely to be misinterpreted by the GUI because the GUI expects the engine to ponder -// on the suggested move. -// - wtime -// white has x msec left on the clock -// - btime -// black has x msec left on the clock -// - winc -// white increment per move in mseconds if x > 0 -// - binc -// black increment per move in mseconds if x > 0 -// - movestogo -// there are x moves to the next time control, -// this will only be sent if x > 0, -// if you don't get this and get the wtime and btime it's sudden death -// - depth -// search x plies only. -// - nodes -// search x nodes only, -// - mate -// search for a mate in x moves -// - movetime -// search exactly x mseconds -// - infinite -// search until the "stop" command. Do not exit the search without being told so in this mode! +func (CmdPosition) Handle(_ []string, _ *Engine) error { return nil } + +// CmdGo sends a "go" command to start the engine's search. The fields control +// search parameters such as time limits, depth, and node counts. type CmdGo struct { - SearchMoves []*chess.Move + SearchMoves []chess.Move WhiteTime time.Duration BlackTime time.Duration WhiteIncrement time.Duration @@ -296,53 +254,63 @@ func (cmd CmdGo) String() string { if len(cmd.SearchMoves) > 0 { a = append(a, "searchmoves") for _, m := range cmd.SearchMoves { - mStr := chess.UCINotation{}.Encode(nil, m) + mStr, _ := chess.UCI().Encode(nil, m) a = append(a, mStr) } } return strings.Join(a, " ") } -// ProcessResponse implements the Cmd interface. -// TODO: Refactor this function to be shorter and more readable. -// -//nolint:nestif // work to be done -func (CmdGo) ProcessResponse(e *Engine) error { - const maxParts = 4 +func (CmdGo) IsDone(line string) bool { + return strings.HasPrefix(line, "bestmove") +} + +func (CmdGo) LockRequired() bool { return true } + +func parseBestMoveLine(text string, e *Engine) (chess.Move, chess.Move, error) { + const minParts = 2 + const ponderPartIdx = 3 + const minPonderParts = 4 + parts := strings.Split(text, " ") + if len(parts) < minParts { + return chess.Move{}, chess.Move{}, fmt.Errorf("best move not found %s", text) + } + var position *chess.Position + if e.hasPos { + position = e.position.Position + } + bestMove, err := decodeUCIMove(position, parts[1]) + if err != nil { + return chess.Move{}, chess.Move{}, err + } + var ponderMove chess.Move + if len(parts) >= minPonderParts { + ponderMove, err = decodeUCIMove(position, parts[ponderPartIdx]) + if err != nil { + return chess.Move{}, chess.Move{}, err + } + } + return bestMove, ponderMove, nil +} - scanner := bufio.NewScanner(e.out) +func (CmdGo) Handle(lines []string, e *Engine) error { results := SearchResults{MultiPVInfo: make([]Info, 1)} - for scanner.Scan() { - text := e.readLine(scanner) + for _, text := range lines { if strings.HasPrefix(text, "bestmove") { - parts := strings.Split(text, " ") - if len(parts) <= 1 { - return errors.New("best move not found " + text) - } - var position *chess.Position - if e.position != nil { - position = e.position.Position - } else { - position = nil - } - bestMove, err := chess.UCINotation{}.Decode(position, parts[1]) + bestMove, ponderMove, err := parseBestMoveLine(text, e) if err != nil { return err } results.BestMove = bestMove - if len(parts) >= maxParts { - ponderMove, decodeErr := chess.UCINotation{}.Decode(position, parts[3]) - if decodeErr != nil { - return decodeErr - } - results.Ponder = ponderMove - } - break + results.Ponder = ponderMove + continue } info := &Info{} - err := info.UnmarshalText([]byte(text)) - if err != nil { + if err := info.UnmarshalText([]byte(text)); err != nil { + if e.debug && strings.HasPrefix(text, "info ") { + e.logger.Printf("uci: dropping unparseable info line %q: %v", text, err) + } continue } @@ -352,10 +320,8 @@ func (CmdGo) ProcessResponse(e *Engine) error { if info.Multipv > 1 && info.Multipv < 300 { currentPVCount := len(results.MultiPVInfo) - if info.Multipv > currentPVCount { - for i := currentPVCount; i < info.Multipv; i++ { - results.MultiPVInfo = append(results.MultiPVInfo, Info{}) - } + for i := currentPVCount; i < info.Multipv; i++ { + results.MultiPVInfo = append(results.MultiPVInfo, Info{}) } results.MultiPVInfo[info.Multipv-1] = *info } @@ -365,6 +331,17 @@ func (CmdGo) ProcessResponse(e *Engine) error { return nil } +func decodeUCIMove(pos *chess.Position, text string) (chess.Move, error) { + if pos != nil { + return chess.UCI().Decode(pos, text) + } + raw, err := chess.UCI().DecodeRaw(text) + if err != nil { + return chess.Move{}, err + } + return raw.Move(), nil +} + func parseIDLine(s string) (string, string, error) { const numParts = 3 if !strings.HasPrefix(s, "id") { diff --git a/uci/engine.go b/uci/engine.go index f093f9f2..fc812638 100644 --- a/uci/engine.go +++ b/uci/engine.go @@ -1,149 +1,117 @@ package uci import ( - "bufio" - "fmt" - "io" "log" + "maps" "os" - "os/exec" "sync" ) -// Engine represents a UCI compliant chess engine (e.g. Stockfish, Shredder, etc.). -// Engine is safe for concurrent use. +// Engine manages communication with a UCI-compatible chess engine such as +// Stockfish. It sends commands, parses responses, and exposes the engine's +// reported state (ID, options, search results). type Engine struct { - cmd *exec.Cmd - in *io.PipeWriter - out *io.PipeReader + adapter Adapter logger *log.Logger id map[string]string options map[string]Option mu *sync.RWMutex - position *CmdPosition + position CmdPosition + hasPos bool results SearchResults eval int debug bool } -// Debug is an option for the New function to add logging for debugging. This will -// log all output to and from the chess engine. +// Debug enables debug logging of commands sent to and responses received from +// the engine. It is intended to be passed as an option to New. func Debug(e *Engine) { e.debug = true } -// Logger is an option for the New function to customize the logger. The logger is -// only used if the Debug option is also used. +// Logger sets the logger the engine uses for debug output. It is intended to be +// passed as an option to New. func Logger(logger *log.Logger) func(e *Engine) { return func(e *Engine) { e.logger = logger } } -// New constructs an engine from the executable path (found using exec.LookPath). -// New also starts running the executable process in the background. Once created -// the Engine can be controlled via the Run method. +// New creates an Engine backed by a subprocess started from the executable at +// the given path. Optional configuration functions (e.g. Debug, Logger) may be +// passed to customize the engine. func New(path string, opts ...func(e *Engine)) (*Engine, error) { - path, err := exec.LookPath(path) + adapter, err := NewSubprocessAdapter(path) if err != nil { - return nil, fmt.Errorf("uci: executable not found at path %s %w", path, err) + return nil, err + } + return NewWithAdapter(adapter, opts...), nil +} + +// NewWithAdapter creates an Engine that communicates through the provided +// Adapter instead of spawning a subprocess. This is primarily useful for +// testing with a FakeAdapter. +func NewWithAdapter(adapter Adapter, opts ...func(e *Engine)) *Engine { + e := &Engine{ + adapter: adapter, + logger: log.New(os.Stdout, "uci", log.LstdFlags), + mu: &sync.RWMutex{}, + position: CmdPosition{}, + results: SearchResults{MultiPVInfo: []Info{}}, } - rIn, wIn := io.Pipe() - rOut, wOut := io.Pipe() - cmd := exec.Command(path) - cmd.Stdin = rIn - cmd.Stdout = wOut - e := &Engine{cmd: cmd, in: wIn, out: rOut, mu: &sync.RWMutex{}, logger: log.New(os.Stdout, "uci", log.LstdFlags), position: &CmdPosition{}, results: SearchResults{MultiPVInfo: []Info{}}} for _, opt := range opts { opt(e) } - err = e.cmd.Start() - if err != nil { - return nil, fmt.Errorf("uci: failed to start executable %s: %w", path, err) - } - go e.cmd.Wait() - - return e, nil + return e } -func (e *Engine) Getpid() int { - return e.cmd.Process.Pid -} - -// ID returns the id values returned from the most recent CmdUCI invocation. It includes -// key value data such as the following: -// id name Stockfish 12 -// id author the Stockfish developers (see AUTHORS file). +// ID returns a copy of the engine's identification key-value pairs as reported +// in response to the CmdUCI command (e.g. name and author). func (e *Engine) ID() map[string]string { e.mu.RLock() defer e.mu.RUnlock() - cp := map[string]string{} - for k, v := range e.id { - cp[k] = v + cp := maps.Clone(e.id) + if cp == nil { + return map[string]string{} } return cp } -// Options returns exposed options from the most recent CmdUCI invocation. It includes -// data such as the following: -// option name Debug Log File type string default -// option name Contempt type spin default 24 min -100 max 100 -// option name Analysis Contempt type combo default Both var Off var White var Black var Both -// option name Threads type spin default 1 min 1 max 512 -// option name Hash type spin default 16 min 1 max 33554432 -// option name Clear Hash type button -// option name Ponder type check default false -// option name MultiPV type spin default 1 min 1 max 500 -// option name Skill Level type spin default 20 min 0 max 20 -// option name Move Overhead type spin default 10 min 0 max 5000 -// option name Slow Mover type spin default 100 min 10 max 1000 -// option name nodestime type spin default 0 min 0 max 10000 -// option name UCI_Chess960 type check default false -// option name UCI_AnalyseMode type check default false -// option name UCI_LimitStrength type check default false -// option name UCI_Elo type spin default 1350 min 1350 max 2850 -// option name UCI_ShowWDL type check default false -// option name SyzygyPath type string default -// option name SyzygyProbeDepth type spin default 1 min 1 max 100 -// option name Syzygy50MoveRule type check default true -// option name SyzygyProbeLimit type spin default 7 min 0 max 7 -// option name Use NNUE type check default true -// option name EvalFile type string default nn-82215d0fd0df.nnue -// The key is the option name and the value is the Option struct. +// Options returns a copy of the engine's available options as reported in +// response to the CmdUCI command. func (e *Engine) Options() map[string]Option { e.mu.RLock() defer e.mu.RUnlock() - cp := map[string]Option{} - for k, v := range e.options { - cp[k] = v + cp := maps.Clone(e.options) + if cp == nil { + return map[string]Option{} } return cp } -// SearchResults returns results from the most recent CmdGo invocation. It includes -// data such as the following: -// info depth 21 seldepth 31 multipv 1 score cp 39 nodes 862438 nps 860716 hashfull 409 tbhits 0 time 1002 pv e2e4 -// bestmove e2e4 ponder c7c5. +// SearchResults returns the results from the most recent CmdGo command. func (e *Engine) SearchResults() SearchResults { e.mu.RLock() defer e.mu.RUnlock() - return e.results + return e.results.copy() } +// Eval returns the static evaluation from the most recent CmdEval command, in +// centipawns. Not all engines support the eval command. func (e *Engine) Eval() int { e.mu.RLock() defer e.mu.RUnlock() return e.eval } -// Run runs the set of Cmds in the order given and returns an error if -// any of the commands fails. Except for CmdStop (usually paired with -// CmdGo's infinite option) all commands block via mutux until completed. +// Run executes the given commands in order, returning the first error +// encountered. Commands that require exclusive access (LockRequired) are +// serialized via the engine's mutex. func (e *Engine) Run(cmds ...Cmd) error { for _, cmd := range cmds { - if cmd.String() == CmdStop.Name { + if !cmd.LockRequired() { if err := e.processCommand(cmd); err != nil { return err } @@ -156,19 +124,24 @@ func (e *Engine) Run(cmds ...Cmd) error { return nil } -// Close releases readers, writers, and processes associated with the -// Engine. It also invokes the CmdQuit to signal the engine to terminate. -func (e *Engine) Close() error { - if err := e.Run(CmdQuit); err != nil { - return err - } - if err := e.in.Close(); err != nil { - return err +// Getpid returns the operating system process ID of the engine subprocess. +// Returns 0 when the engine is not backed by a subprocess (e.g. when created +// with NewWithAdapter using a non-subprocess Adapter). +func (e *Engine) Getpid() int { + if sa, ok := e.adapter.(*SubprocessAdapter); ok { + return sa.Pid() } - if err := e.out.Close(); err != nil { - return err + return 0 +} + +// Close sends a quit command to the engine and closes the underlying adapter. +func (e *Engine) Close() error { + quitErr := e.Run(CmdQuit{}) + closeErr := e.adapter.Close() + if quitErr != nil { + return quitErr } - return e.cmd.Process.Kill() + return closeErr } func (e *Engine) processCommandLocked(cmd Cmd) error { @@ -181,25 +154,18 @@ func (e *Engine) processCommand(cmd Cmd) error { if e.debug { e.logger.Println(cmd.String()) } - if _, err := fmt.Fprintln(e.in, cmd.String()); err != nil { + lines, err := e.adapter.Exchange(cmd) + if err != nil { return err } - if posCmd, ok := cmd.(*CmdPosition); ok { - e.position = posCmd + if e.debug { + for _, line := range lines { + e.logger.Println(line) + } } if posCmd, ok := cmd.(CmdPosition); ok { - e.position = &posCmd - } - if err := cmd.ProcessResponse(e); err != nil { - return err - } - return nil -} - -func (e *Engine) readLine(scanner *bufio.Scanner) string { - s := scanner.Text() - if e.debug { - e.logger.Println(s) + e.position = posCmd + e.hasPos = true } - return s + return cmd.Handle(lines, e) } diff --git a/uci/engine_test.go b/uci/engine_test.go index 3af7d727..108798af 100644 --- a/uci/engine_test.go +++ b/uci/engine_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/corentings/chess/v2" - "github.com/corentings/chess/v2/uci" + "github.com/corentings/chess/v3" + "github.com/corentings/chess/v3/uci" ) var engines = []string{"stockfish", "lc0"} @@ -40,7 +40,7 @@ func Test_EngineEval(t *testing.T) { defer eng.Close() cmdPos := uci.CmdPosition{Position: pos} - err = eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdPos, uci.CmdEval) + err = eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdPos, uci.CmdEval{}) if name == "stockfish" { if err != nil { @@ -83,12 +83,15 @@ func Test_EngineInfo(t *testing.T) { cmdWDL := uci.CmdSetOption{Name: "UCI_ShowWDL", Value: "true"} cmdPos := uci.CmdPosition{Position: pos} cmdGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdMultiPV, cmdWDL, cmdPos, cmdGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdMultiPV, cmdWDL, cmdPos, cmdGo); err != nil { t.Fatal("failed to run command", err) } move := eng.SearchResults().Info.PV[0] - moveStr := chess.AlgebraicNotation{}.Encode(pos, move) + moveStr, err := chess.SAN().Encode(pos, move) + if err != nil { + t.Fatal(err) + } if moveStr != "Ne5" { t.Errorf("expected Ne5, got %s", moveStr) @@ -124,7 +127,7 @@ func Test_EngineMultiPVInfo(t *testing.T) { cmdMultiPV := uci.CmdSetOption{Name: "multipv", Value: "2"} cmdPos := uci.CmdPosition{Position: pos} cmdGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame, cmdMultiPV, cmdPos, cmdGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, uci.CmdUCINewGame{}, cmdMultiPV, cmdPos, cmdGo); err != nil { t.Fatal("failed to run command", err) } @@ -135,13 +138,19 @@ func Test_EngineMultiPVInfo(t *testing.T) { } move := multiPVInfo[0].PV[0] - moveStr := chess.AlgebraicNotation{}.Encode(pos, move) + moveStr, err := chess.SAN().Encode(pos, move) + if err != nil { + t.Fatal(err) + } if moveStr != "Ne5" { t.Errorf("expected Ne5, got %s", moveStr) } move = multiPVInfo[1].PV[0] - moveStr = chess.AlgebraicNotation{}.Encode(pos, move) + moveStr, err = chess.SAN().Encode(pos, move) + if err != nil { + t.Fatal(err) + } if moveStr != "e5" { t.Errorf("expected e5, got %s", moveStr) } @@ -164,12 +173,11 @@ func Test_UCIMovesTags(t *testing.T) { setOpt := uci.CmdSetOption{Name: "UCI_Elo", Value: "1500"} setPos := uci.CmdPosition{Position: chess.StartingPosition()} setGo := uci.CmdGo{MoveTime: time.Second / 10} - if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, setOpt, uci.CmdUCINewGame, setPos, setGo); err != nil { + if err := eng.Run(uci.CmdUCI{}, uci.CmdIsReady{}, setOpt, uci.CmdUCINewGame{}, setPos, setGo); err != nil { t.Fatal("failed to run command", err) } game := chess.NewGame() - notation := chess.AlgebraicNotation{} for game.Outcome() == chess.NoOutcome { cmdPos := uci.CmdPosition{Position: game.Position()} @@ -180,7 +188,10 @@ func Test_UCIMovesTags(t *testing.T) { move := eng.SearchResults().BestMove pos := game.Position() - san := notation.Encode(pos, move) + san, encodeErr := chess.SAN().Encode(pos, move) + if encodeErr != nil { + t.Fatal(encodeErr) + } err = game.PushMove(san, nil) if err != nil { diff --git a/uci/fake.go b/uci/fake.go new file mode 100644 index 00000000..bc826968 --- /dev/null +++ b/uci/fake.go @@ -0,0 +1,35 @@ +package uci + +import "strings" + +// FakeAdapter is a test Adapter that returns canned responses keyed by the +// first word of each command string (e.g. "uci", "go", "isready"). +type FakeAdapter struct { + Responses map[string][]string +} + +// Exchange returns the canned response lines for the given command, or nil if +// no response is configured for the command key. +func (f *FakeAdapter) Exchange(cmd Cmd) ([]string, error) { + key := strings.SplitN(cmd.String(), " ", 2)[0] + responses, ok := f.Responses[key] + if !ok { + return nil, nil + } + if cmd.IsDone("") { + return nil, nil + } + var lines []string + for _, line := range responses { + lines = append(lines, line) + if cmd.IsDone(line) { + break + } + } + return lines, nil +} + +// Close is a no-op for FakeAdapter. +func (f *FakeAdapter) Close() error { + return nil +} diff --git a/uci/info.go b/uci/info.go index 78260daa..8e7e1eca 100644 --- a/uci/info.go +++ b/uci/info.go @@ -2,26 +2,36 @@ package uci import ( "errors" + "fmt" "strconv" "strings" "time" - "github.com/corentings/chess/v2" + "github.com/corentings/chess/v3" ) -var missingWdlErr = errors.New("uci: wdl unavailable; this is mostly likely because UCI_ShowWDL has not been set") +var errMissingWdl = errors.New("uci: wdl unavailable; this is mostly likely because UCI_ShowWDL has not been set") // SearchResults is the result from the most recent CmdGo invocation. It includes // data such as the following: // info depth 21 seldepth 31 multipv 1 score cp 39 nodes 862438 nps 860716 hashfull 409 tbhits 0 time 1002 pv e2e4 // bestmove e2e4 ponder c7c5. type SearchResults struct { - BestMove *chess.Move - Ponder *chess.Move + BestMove chess.Move + Ponder chess.Move MultiPVInfo []Info Info Info } +func (r SearchResults) copy() SearchResults { + return SearchResults{ + BestMove: r.BestMove, + Ponder: r.Ponder, + MultiPVInfo: copyInfoSlice(r.MultiPVInfo), + Info: r.Info.copy(), + } +} + // Info corresponds to the "info" engine output: // the engine wants to send infos to the GUI. This should be done whenever one of the info has changed. // The engine can send only selected infos and multiple infos can be send with one info command, @@ -89,8 +99,8 @@ type SearchResults struct { // If is greater than 1, always send all k lines in k strings together. // The engine should only send this if the option "UCI_ShowCurrLine" is set to true. type Info struct { - CurrentMove *chess.Move - PV []*chess.Move + CurrentMove chess.Move + PV []chess.Move Score Score Depth int Seldepth int @@ -104,6 +114,77 @@ type Info struct { CPULoad int } +func (info *Info) copy() Info { + out := *info + out.PV = append([]chess.Move{}, info.PV...) + return out +} + +func copyInfoSlice(src []Info) []Info { + if src == nil { + return []Info{} + } + out := make([]Info, len(src)) + for idx, info := range src { + out[idx] = info.copy() + } + return out +} + +func parseInfoInt(field, value string) (int, error) { + v, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("uci: invalid info %s %q: %w", field, value, err) + } + return v, nil +} + +// assignIntField parses value as an integer and assigns it to the Info field +// identified by field. It returns true when the field was recognised. +func (info *Info) assignIntField(field, value string) (bool, error) { + v, err := parseInfoInt(field, value) + if err != nil { + return false, err + } + switch field { + case "depth": + info.Depth = v + case "seldepth": + info.Seldepth = v + case "multipv": + info.Multipv = v + case "cp": + info.Score.CP = v + case "nodes": + info.Nodes = v + case "currmovenumber": + info.CurrentMoveNumber = v + case "hashfull": + info.Hashfull = v + case "tbhits": + info.TBHits = v + case "nps": + info.NPS = v + case "cpuload": + info.CPULoad = v + default: + return false, nil + } + return true, nil +} + +func nextInfoToken(s string) (string, string, bool) { + s = strings.TrimLeft(s, " ") + if s == "" { + return "", "", false + } + token, rest, found := strings.Cut(s, " ") + if !found { + return s, "", true + } + return token, rest, true +} + // Score corresponds to the "info"'s score engine output: // - score // - cp @@ -138,7 +219,7 @@ type Score struct { func (score Score) WinPct() (float32, error) { total := score.Win + score.Draw + score.Loss if total == 0 { - return 0.0, missingWdlErr + return 0.0, errMissingWdl } return float32(score.Win) / float32(total), nil } @@ -150,7 +231,7 @@ func (score Score) WinPct() (float32, error) { func (score Score) DrawPct() (float32, error) { total := score.Win + score.Draw + score.Loss if total == 0 { - return 0.0, missingWdlErr + return 0.0, errMissingWdl } return float32(score.Draw) / float32(total), nil } @@ -162,28 +243,32 @@ func (score Score) DrawPct() (float32, error) { func (score Score) LossPct() (float32, error) { total := score.Win + score.Draw + score.Loss if total == 0 { - return 0.0, missingWdlErr + return 0.0, errMissingWdl } return float32(score.Loss) / float32(total), nil } // UnmarshalText implements the encoding.TextUnmarshaler interface and parses. // data like the following: -// info depth 24 seldepth 32 multipv 1 score cp 29 wdl 791 209 0 nodes 5130101 nps 819897 hashfull 967 tbhits 0 time 6257 pv d2d4 -// TODO: Refactor this function to be shorter. -// -//nolint:funlen // This function is long because it has to parse a lot of different fields. +// info depth 24 seldepth 32 multipv 1 score cp 29 wdl 791 209 0 nodes 5130101 nps 819897 hashfull 967 tbhits 0 time 6257 pv d2d4. func (info *Info) UnmarshalText(text []byte) error { - parts := strings.Split(string(text), " ") - if len(parts) == 0 { - return errors.New("uci: invalid info line " + string(text)) - } - if parts[0] != "info" { - return errors.New("uci: invalid info line " + string(text)) + line := string(text) + token, rest, ok := nextInfoToken(line) + if !ok || token != "info" { + return fmt.Errorf("uci: invalid info line %s", line) } ref := "" - for i := 1; i < len(parts); i++ { - s := parts[i] + var ( + s string + nextRest string + ) + for { + s, nextRest, ok = nextInfoToken(rest) + if !ok { + break + } + rest = nextRest + switch s { case "score": continue @@ -199,105 +284,58 @@ func (info *Info) UnmarshalText(text []byte) error { continue } switch ref { - case "depth": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.Depth = v - case "seldepth": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.Seldepth = v - case "multipv": - v, err := strconv.Atoi(s) - if err != nil { - return err + case "wdl": + draw, drawRest, wdlOK := nextInfoToken(rest) + if !wdlOK { + return fmt.Errorf("uci: truncated wdl score: %s", line) } - info.Multipv = v - case "cp": - v, err := strconv.Atoi(s) - if err != nil { - return err + loss, afterLoss, wdlOK := nextInfoToken(drawRest) + if !wdlOK { + return fmt.Errorf("uci: truncated wdl score: %s", line) } - info.Score.CP = v - case "wdl": + rest = afterLoss + var err error - info.Score.Win, err = strconv.Atoi(parts[i]) + info.Score.Win, err = parseInfoInt("wdl win", s) if err != nil { return err } - info.Score.Draw, err = strconv.Atoi(parts[i+1]) + info.Score.Draw, err = parseInfoInt("wdl draw", draw) if err != nil { return err } - info.Score.Loss, err = strconv.Atoi(parts[i+2]) + info.Score.Loss, err = parseInfoInt("wdl loss", loss) if err != nil { return err } - i += 2 - case "nodes": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.Nodes = v case "mate": - v, err := strconv.Atoi(s) + v, err := parseInfoInt("mate", s) if err != nil { return err } info.Score.Mate = v - case "currmovenumber": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.CurrentMoveNumber = v - case "currmove": - m, err := chess.UCINotation{}.Decode(nil, s) - if err != nil { - return err - } - info.CurrentMove = m - case "hashfull": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.Hashfull = v - case "tbhits": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.TBHits = v case "time": - v, err := strconv.Atoi(s) + v, err := parseInfoInt("time", s) if err != nil { return err } info.Time = time.Millisecond * time.Duration(v) - case "nps": - v, err := strconv.Atoi(s) - if err != nil { - return err - } - info.NPS = v - case "cpuload": - v, err := strconv.Atoi(s) + case "currmove": + raw, err := chess.UCI().DecodeRaw(s) if err != nil { - return err + return fmt.Errorf("uci: invalid info currmove %q: %w", s, err) } - info.CPULoad = v + info.CurrentMove = raw.Move() case "pv": - m, err := chess.UCINotation{}.Decode(nil, s) + raw, err := chess.UCI().DecodeRaw(s) if err != nil { + return fmt.Errorf("uci: invalid info pv move %q: %w", s, err) + } + info.PV = append(info.PV, raw.Move()) + default: + if _, err := info.assignIntField(ref, s); err != nil { return err } - info.PV = append(info.PV, m) } if ref != "pv" { ref = "" diff --git a/uci/option.go b/uci/option.go index fbbd20f0..32098c5a 100644 --- a/uci/option.go +++ b/uci/option.go @@ -2,6 +2,7 @@ package uci import ( "errors" + "fmt" "strings" ) @@ -197,5 +198,5 @@ func optionTypeFromString(s string) (OptionType, error) { return ot, nil } } - return OptionNoType, errors.New("uci: invalid option type " + s) + return OptionNoType, fmt.Errorf("uci: invalid option type %s", s) } diff --git a/utils.go b/utils.go index 0b726832..374fcb6a 100644 --- a/utils.go +++ b/utils.go @@ -13,7 +13,7 @@ func isWhitespace(ch byte) bool { } func isResult(s string) bool { - return s == "1-0" || s == "0-1" || s == "1/2-1/2" || s == "*" + return s == string(WhiteWon) || s == string(BlackWon) || s == string(Draw) || s == "*" } // Helper function to check if a character is a valid file. diff --git a/zobrist.go b/zobrist.go index a77d94d5..e9464122 100644 --- a/zobrist.go +++ b/zobrist.go @@ -1,272 +1,10 @@ package chess -import ( - "encoding/hex" - "errors" - "strconv" - "strings" -) - -// ZobristHasher provides methods to generate Zobrist hashes for chess positions -type ZobristHasher struct { - enPassantRank int - enPassantFile int - pawnNearby bool - hasError bool -} - -// Hash represents a Zobrist hash as a byte slice -type Hash []byte - -// emptyHash is the initial hash value -var emptyHash = parseHexString("0000000000000000") - -// NewChessHasher creates a new instance of ChessHasher -// Deprecated: Use NewZobristHasher instead -func NewChessHasher() *ZobristHasher { - return &ZobristHasher{ - enPassantRank: -1, - enPassantFile: -1, - pawnNearby: false, - hasError: false, - } -} - -// NewZobristHasher creates a new instance of ZobristHasher -func NewZobristHasher() *ZobristHasher { - return &ZobristHasher{ - enPassantRank: -1, - enPassantFile: -1, - pawnNearby: false, - hasError: false, - } -} - -// parseHexString converts a hex string to a byte slice efficiently -func parseHexString(s string) Hash { - // Ensure the input has an even length - if len(s)%2 != 0 { - return nil // Handle invalid input - } - - // Use a preallocated buffer for zero allocations - result := make([]byte, len(s)/2) - _, err := hex.Decode(result, []byte(s)) +// HashFromFEN computes the Zobrist hash of a chess position from its FEN string. +func HashFromFEN(fen string) (uint64, error) { + pos, err := decodeFEN(fen) if err != nil { - return nil // Handle invalid hex string - } - - return result -} - -// createHexString converts a byte slice to a hex string efficiently -func createHexString(h Hash) string { - return hex.EncodeToString(h) -} - -func xorArrays(a, b Hash) { - length := len(a) - if len(b) < length { - length = len(b) + return 0, err } - for i := 0; i < length; i++ { - a[i] ^= b[i] // XOR in place, avoiding new slice allocation - } -} - -// xorHash performs an in-place XOR operation on a hash -func (ch *ZobristHasher) xorHash(arr Hash, num int) { - // Get the precomputed Polyglot hash as a byte slice - polyglotHash := GetPolyglotHashBytes(num) - - // Perform in-place XOR - xorArrays(arr, polyglotHash) -} - -// parseEnPassant processes the en passant square -func (ch *ZobristHasher) parseEnPassant(s string) { - if s == "-" { - return - } - - if len(s) != 2 { - ch.hasError = true - return - } - - file := int(s[0] - 'a') - rank := int(s[1] - '1') - - if file < 0 || file > 7 || rank < 0 || rank > 7 { - ch.hasError = true - return - } - - ch.enPassantFile = file - ch.enPassantRank = rank -} - -// hashSide computes the hash for the side to move -func (ch *ZobristHasher) hashSide(arr Hash, color Color) Hash { - if color == White { - ch.xorHash(arr, 780) - } - return arr -} - -// hashCastling updates hash based on castling rights -func (ch *ZobristHasher) hashCastling(arr Hash, s string) Hash { - if s == "-" { - return arr - } - - if strings.Contains(s, "K") { - ch.xorHash(arr, 768) - } - if strings.Contains(s, "Q") { - ch.xorHash(arr, 769) - } - if strings.Contains(s, "k") { - ch.xorHash(arr, 770) - } - if strings.Contains(s, "q") { - ch.xorHash(arr, 771) - } - - return arr -} - -// hashPieces computes hash for the piece positions -func (ch *ZobristHasher) hashPieces(arr Hash, s string) Hash { - ranks := strings.Split(s, "/") - if len(ranks) != 8 { - ch.hasError = true - return arr - } - - for i := 0; i < 8; i++ { - file := 0 - rank := 7 - i - for j := 0; j < len(ranks[i]); j++ { - piece := ranks[i][j] - switch piece { - case 'p': - ch.xorHash(arr, 8*rank+file) - if ch.enPassantRank == 2 && rank == 3 && ch.enPassantFile > 0 && file == ch.enPassantFile-1 { - ch.pawnNearby = true - } - if ch.enPassantRank == 2 && rank == 3 && ch.enPassantFile < 7 && file == ch.enPassantFile+1 { - ch.pawnNearby = true - } - file++ - case 'P': - ch.xorHash(arr, 64*1+8*rank+file) - if ch.enPassantRank == 5 && rank == 4 && ch.enPassantFile > 0 && file == ch.enPassantFile-1 { - ch.pawnNearby = true - } - if ch.enPassantRank == 5 && rank == 4 && ch.enPassantFile < 7 && file == ch.enPassantFile+1 { - ch.pawnNearby = true - } - file++ - case 'n': - ch.xorHash(arr, 64*2+8*rank+file) - file++ - case 'N': - ch.xorHash(arr, 64*3+8*rank+file) - file++ - case 'b': - ch.xorHash(arr, 64*4+8*rank+file) - file++ - case 'B': - ch.xorHash(arr, 64*5+8*rank+file) - file++ - case 'r': - ch.xorHash(arr, 64*6+8*rank+file) - file++ - case 'R': - ch.xorHash(arr, 64*7+8*rank+file) - file++ - case 'q': - ch.xorHash(arr, 64*8+8*rank+file) - file++ - case 'Q': - ch.xorHash(arr, 64*9+8*rank+file) - file++ - case 'k': - ch.xorHash(arr, 64*10+8*rank+file) - file++ - case 'K': - ch.xorHash(arr, 64*11+8*rank+file) - file++ - case '1', '2', '3', '4', '5', '6', '7', '8': - file += int(piece - '0') - default: - ch.hasError = true - return arr - } - } - if file != 8 { - ch.hasError = true - } - } - return arr -} - -// HashPosition computes a Zobrist hash for a chess position in FEN notation -func (ch *ZobristHasher) HashPosition(fen string) (string, error) { - ch.hasError = false - ch.enPassantRank = -1 - ch.enPassantFile = -1 - ch.pawnNearby = false - - // FEN should have at least 4 parts - parts := strings.SplitN(fen, " ", 5) - if len(parts) < 4 { - return "", errors.New("invalid FEN format") - } - - pieces, color, castling, enPassant := parts[0], parts[1], parts[2], parts[3] - - // Quick validation without regex - if len(color) != 1 || (color[0] != 'w' && color[0] != 'b') { - return "", errors.New("invalid side to move") - } - - if len(castling) > 4 { - return "", errors.New("invalid castling rights") - } - - hash := make(Hash, len(emptyHash)) - copy(hash, emptyHash) - - ch.parseEnPassant(enPassant) - hash = ch.hashPieces(hash, pieces) - - if ch.pawnNearby { - ch.xorHash(hash, 772+ch.enPassantFile) - } - - hash = ch.hashSide(hash, ColorFromString(color)) - hash = ch.hashCastling(hash, castling) - - if ch.hasError { - return "", errors.New("invalid piece placement") - } - - return createHexString(hash), nil -} - -func ZobristHashToUint64(hash string) uint64 { - // Ensure the input is exactly 16 hex digits - if len(hash) != 16 { - return 0 - } - - // Convert directly using `strconv.ParseUint` - result, err := strconv.ParseUint(hash, 16, 64) - if err != nil { - return 0 - } - - return result + return pos.ZobristHash(), nil } diff --git a/zobrist_test.go b/zobrist_test.go index 38f0aaa5..5b363e54 100644 --- a/zobrist_test.go +++ b/zobrist_test.go @@ -1,271 +1,149 @@ -package chess +package chess_test import ( - "strings" "testing" -) - -func TestChessHasher(t *testing.T) { - t.Run("Basic Position Validation", func(t *testing.T) { - hasher := NewZobristHasher() - 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) - } - if len(hash) != 16 { - t.Errorf("Expected hash length of 16, got %d", len(hash)) - } - if hash != "463b96181691fc9c" { - t.Errorf("Expected hash 463b96181691fc9c, got %s", hash) - } - }) - - 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) - } - if hash != "f8d626aaaf278509" { - t.Errorf("Expected hash f8d626aaaf278509, got %s", hash) - } - }) - }) + "github.com/corentings/chess/v3" +) +func TestHashFromFEN(t *testing.T) { t.Run("Known Position Hashes", func(t *testing.T) { - hasher := NewZobristHasher() knownPositions := []struct { + name string fen string - hash string + hash uint64 }{ - { - "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", - "463b96181691fc9c", - }, - { - "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", - "823c9b50fd114196", - }, - { - "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", - "0844931a6ef4b9a0", - }, - { - "rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2", - "0756b94461c50fb0", - }, - { - "8/8/8/8/8/8/8/8 w - - 0 1", - "f8d626aaaf278509", - }, + {"startpos", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", 0x463b96181691fc9c}, + {"after_e4", "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", 0x823c9b50fd114196}, + {"after_e4_e5", "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", 0x0844931a6ef4b9a0}, + {"empty", "8/8/8/8/8/8/8/8 w - - 0 1", 0xf8d626aaaf278509}, } - for _, tc := range knownPositions { - t.Run(tc.fen, func(t *testing.T) { - hash, err := hasher.HashPosition(tc.fen) + t.Run(tc.name, func(t *testing.T) { + hash, err := chess.HashFromFEN(tc.fen) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Expected no error, got %v", err) } - if !strings.EqualFold(hash, tc.hash) { - t.Errorf("Expected hash %s, got %s", tc.hash, hash) + if hash != tc.hash { + t.Errorf("Expected hash %016x, got %016x", tc.hash, hash) } }) } }) t.Run("Error Handling", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should return error for invalid FEN format", func(t *testing.T) { - invalidFen := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - _, err := hasher.HashPosition(invalidFen) - if err == nil { - t.Error("Expected error for invalid FEN format") - } - }) - - t.Run("should detect invalid piece placement", func(t *testing.T) { - invalidPieces := "rnbqkbnr/ppppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - _, err := hasher.HashPosition(invalidPieces) - if err == nil { - t.Error("Expected error for invalid piece placement") - } - }) - - t.Run("should detect invalid ranks count", func(t *testing.T) { - invalidRanks := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP w KQkq - 0 1" - _, err := hasher.HashPosition(invalidRanks) - if err == nil { - t.Error("Expected error for invalid ranks count") - } - }) + invalidFENs := []struct { + name string + fen string + }{ + {"missing_fields", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"}, + {"bad_piece_count", "rnbqkbnr/ppppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"}, + {"bad_ranks_count", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP w KQkq - 0 1"}, + {"bad_side_to_move", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1"}, + {"bad_piece_char", "rnbqkbnZ/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"}, + {"empty_string", ""}, + } + for _, tc := range invalidFENs { + t.Run(tc.name, func(t *testing.T) { + if _, err := chess.HashFromFEN(tc.fen); err == nil { + t.Errorf("Expected error for %q, got nil", tc.fen) + } + }) + } }) t.Run("Color Handling", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should produce different hashes for white and black to move", func(t *testing.T) { - positionWhite := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - positionBlack := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1" + positionWhite := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + positionBlack := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1" - hashWhite, err1 := hasher.HashPosition(positionWhite) - hashBlack, err2 := hasher.HashPosition(positionBlack) + hashWhite, err1 := chess.HashFromFEN(positionWhite) + hashBlack, err2 := chess.HashFromFEN(positionBlack) - if err1 != nil || err2 != nil { - t.Errorf("Unexpected errors: %v, %v", err1, err2) - } - if hashWhite == hashBlack { - t.Error("Expected different hashes for white and black to move") - } - if len(hashWhite) != 16 || len(hashBlack) != 16 { - t.Error("Expected hash length of 16") - } - }) + if err1 != nil || err2 != nil { + t.Fatalf("Unexpected errors: %v, %v", err1, err2) + } + if hashWhite == hashBlack { + t.Error("Expected different hashes for white and black to move") + } }) t.Run("Castling Rights", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should produce different hashes for different castling rights", func(t *testing.T) { - withAllCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - withoutCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" - onlyWhiteCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQ - 0 1" + withAllCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + withoutCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" + onlyWhiteCastling := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQ - 0 1" - hashAll, _ := hasher.HashPosition(withAllCastling) - hashNone, _ := hasher.HashPosition(withoutCastling) - hashWhite, _ := hasher.HashPosition(onlyWhiteCastling) + hashAll, _ := chess.HashFromFEN(withAllCastling) + hashNone, _ := chess.HashFromFEN(withoutCastling) + hashWhite, _ := chess.HashFromFEN(onlyWhiteCastling) - if hashAll == hashNone || hashAll == hashWhite || hashWhite == hashNone { - t.Error("Expected different hashes for different castling rights") - } - }) + if hashAll == hashNone || hashAll == hashWhite || hashWhite == hashNone { + t.Error("Expected different hashes for different castling rights") + } }) t.Run("En Passant", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should handle en passant squares correctly", func(t *testing.T) { - afterE4E5 := "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2" - afterE4E6 := "rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2" - - hashWithEP, err1 := hasher.HashPosition(afterE4E5) - hashWithoutEP, err2 := hasher.HashPosition(afterE4E6) + afterE4E5 := "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2" + afterE4E6 := "rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2" - if err1 != nil || err2 != nil { - t.Errorf("Unexpected errors: %v, %v", err1, err2) - } - if hashWithEP == hashWithoutEP { - t.Error("Expected different hashes for positions with and without en passant") - } - if hashWithEP != "0844931a6ef4b9a0" { - t.Errorf("Expected hash 0844931a6ef4b9a0, got %s", hashWithEP) - } - if hashWithoutEP != "f44b6961e533d1c4" { - t.Errorf("Expected hash f44b6961e533d1c4, got %s", hashWithoutEP) - } - }) - - t.Run("should validate en passant square format", func(t *testing.T) { - invalidEP := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e99 0 1" - _, err := hasher.HashPosition(invalidEP) - if err == nil { - t.Error("Expected error for invalid en passant square") - } - }) - }) - - t.Run("Position Equality", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should produce identical hashes for identical positions", func(t *testing.T) { - position := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - hash1, _ := hasher.HashPosition(position) - hash2, _ := hasher.HashPosition(position) + hashWithEP, err1 := chess.HashFromFEN(afterE4E5) + hashWithoutEP, err2 := chess.HashFromFEN(afterE4E6) - if hash1 != hash2 { - t.Error("Expected identical hashes for identical positions") - } - }) - - t.Run("should produce different hashes for different positions", func(t *testing.T) { - position1 := "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" - position2 := "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2" - - hash1, _ := hasher.HashPosition(position1) - hash2, _ := hasher.HashPosition(position2) - - if hash1 == hash2 { - t.Error("Expected different hashes for different positions") - } - }) + if err1 != nil || err2 != nil { + t.Fatalf("Unexpected errors: %v, %v", err1, err2) + } + if hashWithEP == hashWithoutEP { + t.Error("Expected different hashes for positions with and without en passant") + } + if hashWithEP != 0x0844931a6ef4b9a0 { + t.Errorf("Expected hash 0844931a6ef4b9a0, got %016x", hashWithEP) + } + if hashWithoutEP != 0xf44b6961e533d1c4 { + t.Errorf("Expected hash f44b6961e533d1c4, got %016x", hashWithoutEP) + } }) t.Run("Piece Placement", func(t *testing.T) { - hasher := NewZobristHasher() - - t.Run("should handle individual piece placement correctly", func(t *testing.T) { - positions := []string{ - "4k3/8/8/8/8/8/8/4K3 w - - 0 1", // Kings only - "4k3/8/8/8/8/8/8/R3K3 w - - 0 1", // White rook added - "4k3/8/8/8/8/8/8/B3K3 w - - 0 1", // White bishop instead - "4k3/8/8/8/8/8/8/N3K3 w - - 0 1", // White knight instead - "4k3/8/8/8/8/8/8/Q3K3 w - - 0 1", // White queen instead - } + positions := []string{ + "4k3/8/8/8/8/8/8/4K3 w - - 0 1", + "4k3/8/8/8/8/8/8/R3K3 w - - 0 1", + "4k3/8/8/8/8/8/8/B3K3 w - - 0 1", + "4k3/8/8/8/8/8/8/N3K3 w - - 0 1", + "4k3/8/8/8/8/8/8/Q3K3 w - - 0 1", + "4k3/8/8/8/8/8/8/3K2rr w - - 0 1", + } - hashes := make([]string, len(positions)) - for i, pos := range positions { - hash, err := hasher.HashPosition(pos) - if err != nil { - t.Errorf("Unexpected error for position %s: %v", pos, err) - } - hashes[i] = hash + hashes := make([]uint64, len(positions)) + for i, pos := range positions { + hash, err := chess.HashFromFEN(pos) + if err != nil { + t.Fatalf("Unexpected error for position %s: %v", pos, err) } + hashes[i] = hash + } - // Check that all hashes are different - for i := 0; i < len(hashes); i++ { - for j := i + 1; j < len(hashes); j++ { - if hashes[i] == hashes[j] { - t.Errorf("Expected different hashes for positions %s and %s", - positions[i], positions[j]) - } + for i := range hashes { + for j := i + 1; j < len(hashes); j++ { + if hashes[i] == hashes[j] { + t.Errorf("Expected different hashes for positions %s and %s", + positions[i], positions[j]) } } - }) - }) -} - -func TestZobristHashToUint64(t *testing.T) { - t.Run("valid 16-digit hexadecimal hash is converted correctly", func(t *testing.T) { - hash := "463b96181691fc9c" - expected := uint64(0x463b96181691fc9c) - result := ZobristHashToUint64(hash) - - if result != expected { - t.Fatalf("expected value %v but got %v", expected, result) } }) - - t.Run("invalid hash format returns error", func(t *testing.T) { - hash := "invalidhash" - _ = ZobristHashToUint64(hash) - }) - - t.Run("empty hash returns error", func(t *testing.T) { - hash := "" - _ = ZobristHashToUint64(hash) - }) } -func BenchmarkHashPosition(b *testing.B) { - hasher := NewZobristHasher() - fen := "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - - b.ReportAllocs() - - for i := 0; i < b.N; i++ { - _, _ = hasher.HashPosition(fen) +func BenchmarkHashFromFEN(b *testing.B) { + fens := []string{ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "8/8/8/8/8/8/8/8 w - - 0 1", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + } + for _, fen := range fens { + b.Run(fen, func(b *testing.B) { + b.ReportAllocs() + for range b.N { + _, _ = chess.HashFromFEN(fen) + } + }) } }