diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..923e20e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +bin +dist +docs +*.md +!README.md +!LICENSE +*.out +*.prof +.golangci.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6a463f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{yml,yaml,json,md,toml}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6eaf8e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Normalize line endings +* text=auto eol=lf + +*.go text diff=golang +*.md text +*.yml text +*.yaml text + +# Binary +*.png binary +*.bin binary + +# Keep vendored/generated files out of language stats and diffs +go.sum linguist-generated=true +/vendor/** linguist-vendored=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..daf44f7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Default owner for everything in the repo. +* @cjunius + +# Engine internals — require review from the engine owner. +/internal/engine/ @cjunius +/internal/uci/ @cjunius + +# Build, CI, and release configuration. +/.github/ @cjunius +/Makefile @cjunius +/Dockerfile @cjunius +/.goreleaser.yaml @cjunius diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a6a2028 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: Bug report +description: A crash, an illegal move, a wrong result, or other incorrect behavior +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + For security issues, do **not** file here — see SECURITY.md. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect, and what happened instead? + validations: + required: true + - type: input + id: fen + attributes: + label: Position (FEN) + placeholder: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The exact UCI commands or CLI invocation. + placeholder: | + position fen + go movetime 1000 + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Output of `gochess version` + validations: + required: true + - type: dropdown + id: os + attributes: + label: OS + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant output / logs + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..dd061c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question or discussion + url: https://github.com/cjunius/goChess/discussions + about: Ask how to use the engine, GUI setup, or general chess-programming questions. + - name: Security vulnerability + url: https://github.com/cjunius/goChess/security/advisories/new + about: Report a vulnerability privately (see SECURITY.md). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..313409e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Suggest a new feature or improvement +labels: ["enhancement", "triage"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do that is hard or impossible today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: contribute + attributes: + label: Contribution + options: + - label: I am willing to open a PR for this diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..735a592 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## What & why + + + +Closes # + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Chess strength change (search/eval) +- [ ] Refactor / internal +- [ ] Docs / CI / build + +## Checklist + +- [ ] `make check` passes locally (tidy + lint + vuln + test) +- [ ] Tests added or updated +- [ ] Move-generation changes include a passing perft test +- [ ] `CHANGELOG.md` updated under `## [Unreleased]` +- [ ] Commit messages follow Conventional Commits + +## Strength impact (search/eval changes only) + + + +N/A diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6a5492a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + go-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" + commit-message: + prefix: "chore(ci)" + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + commit-message: + prefix: "chore(docker)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e03cc3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + merge_group: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: ["1.22.x", "1.23.x"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + check-latest: true + - name: Verify modules are tidy + if: matrix.os == 'ubuntu-latest' && matrix.go == '1.23.x' + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + - name: Build + run: go build -v ./... + - name: Vet + run: go vet ./... + - name: Test (race, short) + run: go test -race -short -covermode atomic -coverprofile coverage.txt ./... + - name: Test (full, incl. deep perft) + if: matrix.os == 'ubuntu-latest' + run: go test -race ./... + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.go == '1.23.x' + uses: codecov/codecov-action@v4 + with: + files: coverage.txt + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + govulncheck ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..45099d5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,34 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "27 4 * * 1" + +permissions: + contents: read + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + queries: security-and-quality + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a92987a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + merge_group: + +permissions: + contents: read + +jobs: + golangci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.61.0 + + gofumpt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + - name: Check formatting + run: | + go install mvdan.cc/gofumpt@latest + fmt_out="$(gofumpt -l .)" + if [ -n "$fmt_out" ]; then + echo "These files are not gofumpt-formatted:" + echo "$fmt_out" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d8c6d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + goreleaser: + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub Release + id-token: write # keyless signing / provenance + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" + - name: Install Syft (SBOM generation) + uses: anchore/sbom-action/download-syft@v0 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3b735ec..6ab0152 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,29 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# Binaries *.exe *.exe~ *.dll *.so *.dylib +/bin/ +/dist/ +/gochess -# Test binary, built with `go test -c` +# Test / coverage output *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ +coverage.txt +coverage.html # Go workspace file go.work +go.work.sum + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp + +# Profiling +*.prof +*.pprof diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..72e95f0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,59 @@ +# golangci-lint configuration. Run: make lint (or: golangci-lint run) +run: + timeout: 5m + tests: true + +linters: + enable: + - asasalint + - bodyclose + - copyloopvar + - dupl + - errcheck + - errorlint + - gocritic + - gocyclo + - gofumpt + - goimports + - gosec + - govet + - ineffassign + - misspell + - nakedret + - nilerr + - nolintlint + - predeclared + - revive + - staticcheck + - unconvert + - unparam + - unused + - whitespace + +linters-settings: + gocyclo: + min-complexity: 20 + goimports: + local-prefixes: github.com/cjunius/goChess + govet: + enable-all: true + disable: + - fieldalignment + revive: + rules: + - name: exported + arguments: ["checkPrivateReceivers"] + dupl: + threshold: 150 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + # Test files: allow table-driven duplication and skip some strictness. + - path: _test\.go + linters: + - dupl + - gocyclo + - gosec + - unparam diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..015e21c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,65 @@ +# GoReleaser configuration — https://goreleaser.com +version: 2 + +project_name: gochess + +before: + hooks: + - go mod tidy + - go mod verify + +builds: + - id: gochess + main: ./cmd/gochess + binary: gochess + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.CommitDate}} + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - README.md + - LICENSE + - CHANGELOG.md + +checksum: + name_template: checksums.txt + +sboms: + - artifacts: archive + +changelog: + use: github + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Other + order: 999 + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "merge conflict" + +release: + draft: true + prerelease: auto diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72ae1ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Move generation built on `github.com/dylhunn/dragontoothmg`. +- `internal/engine`: perft (with start-position and Kiwipete regression tests), + static evaluation (material + piece-square tables + bishop pair), and an + iterative-deepening negamax alpha-beta search with quiescence search, + MVV-LVA move ordering, and a hard time budget. +- `internal/uci`: a UCI protocol loop (`uci`, `isready`, `ucinewgame`, + `position`, `go`, `stop`, `quit`) supporting `go depth`, `go movetime`, and + `go wtime/btime`. +- `cmd/gochess`: CLI with `uci`, `perft`, `bench`, and `version` subcommands. +- Repository scaffolding: CI, lint, CodeQL and release workflows; issue and PR + templates; `CODEOWNERS`; Dependabot; `Makefile`; `Dockerfile`; GoReleaser. + +### Changed + +- Replaced the `notnil/chess` prototype (random mover + perft) with the + `dragontoothmg`-based engine. + +[Unreleased]: https://github.com/cjunius/goChess/commits/main diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..741ae3c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,130 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**christopher.junius@gmail.com**. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1f9c23e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing to goChess + +Thanks for your interest in contributing. + +## Getting started + +1. Install Go (see `go.mod` for the minimum version) and `golangci-lint`. +2. Fork and clone the repository. +3. Run `make dev-tools` to install pinned development tooling. +4. Verify your setup: `make check` (build + lint + test). + +## Development workflow + +```bash +make build # compile ./bin/gochess +make test # go test -race ./... +make test-long # includes deep perft (perft 6, kiwipete depth 4) +make lint # golangci-lint run +make vuln # govulncheck ./... +make fmt # gofumpt + goimports +make check # everything CI runs +``` + +- Branch from `main`. Name branches `feature/…`, `fix/…`, or `chore/…`. +- Keep changes focused. One logical change per pull request. +- All code must pass `make check` before review. + +## Commit messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(search): add transposition table +fix(uci): handle "go infinite" without a clock +docs: expand perft section in README +``` + +The changelog and release notes are generated from commit history. + +## Pull requests + +- Fill out the pull request template. +- Add or update tests. Engine correctness changes must include a perft or + search regression test. +- Update `CHANGELOG.md` under the `## [Unreleased]` heading. +- Chess strength changes should include before/after results from a match + (e.g. `cutechess-cli`, ≥1000 games or an SPRT result). + +## Testing philosophy + +- **Move generation**: perft counts against published reference values are + authoritative. Never change move generation without a passing perft test. +- **Search**: assert on outcomes (finds mate, wins hanging material, does not + blunder) rather than exact node counts, which are implementation-sensitive. +- Run `go test -race ./...` locally; the race detector is required in CI. + +## Reporting bugs + +Use the issue templates. For a wrong move or illegal move, include the FEN, the +move list, and the `go` command (depth or movetime) that reproduces it. + +## Security + +Do not open public issues for security problems. See [SECURITY.md](SECURITY.md). + +## License + +By contributing you agree that your contributions are licensed under the +[GPL-3.0](LICENSE). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..205f8d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.22-alpine AS build +WORKDIR /src + +# Cache module downloads. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +ARG VERSION=dev +ARG COMMIT=none +ARG DATE=unknown +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o /out/gochess ./cmd/gochess + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/gochess /usr/local/bin/gochess +# UCI speaks over stdin/stdout; keep the container attached. +ENTRYPOINT ["/usr/local/bin/gochess"] diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..68fb8a0 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,26 @@ +# Maintainers + +| Maintainer | GitHub | Areas | +| ------------------- | ---------- | ------------------------------ | +| Christopher Junius | @cjunius | everything (search, eval, UCI) | + +## Responsibilities + +- Triage issues and review pull requests within a reasonable time. +- Keep CI green on `main`. +- Cut releases (see below). +- Respond to security reports per [SECURITY.md](SECURITY.md). + +## Release process + +1. Ensure `main` is green and `CHANGELOG.md` `## [Unreleased]` is complete. +2. Move the `## [Unreleased]` entries under a new `## [vX.Y.Z] - YYYY-MM-DD` + heading and add the compare link. +3. Tag: `git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`. +4. The `release` workflow runs GoReleaser, which builds cross-platform binaries, + generates checksums and an SBOM, and creates the GitHub Release. +5. Verify the release artifacts and announce in Discussions. + +## Adding a maintainer + +Open a PR editing this file. Requires sign-off from all current maintainers. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..429b2e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +# goChess — developer tasks +BINARY := gochess +PKG := github.com/cjunius/goChess +BIN_DIR := bin +CMD := ./cmd/gochess + +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) + +GO ?= go + +.DEFAULT_GOAL := help + +## build: compile the binary into ./bin +.PHONY: build +build: + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD) + +## install: go install the binary +.PHONY: install +install: + $(GO) install -trimpath -ldflags "$(LDFLAGS)" $(CMD) + +## run: build and start UCI mode +.PHONY: run +run: build + ./$(BIN_DIR)/$(BINARY) + +## test: race-enabled unit tests (short) +.PHONY: test +test: + $(GO) test -race -short ./... + +## test-long: full test suite including deep perft +.PHONY: test-long +test-long: + $(GO) test -race ./... + +## bench: run Go benchmarks +.PHONY: bench +bench: + $(GO) test -run '^$$' -bench . -benchmem ./... + +## cover: generate coverage report (coverage.html) +.PHONY: cover +cover: + $(GO) test -covermode atomic -coverprofile coverage.txt ./... + $(GO) tool cover -html coverage.txt -o coverage.html + @echo "wrote coverage.html" + +## lint: run golangci-lint +.PHONY: lint +lint: + golangci-lint run + +## fmt: format code +.PHONY: fmt +fmt: + gofumpt -w . + goimports -w -local $(PKG) . + +## vuln: scan for known vulnerabilities +.PHONY: vuln +vuln: + govulncheck ./... + +## tidy: sync go.mod / go.sum +.PHONY: tidy +tidy: + $(GO) mod tidy + $(GO) mod verify + +## check: everything CI runs +.PHONY: check +check: tidy lint vuln test + +## dev-tools: install development tooling (versions tracked here and in CI) +GOLANGCI_VERSION ?= v1.61.0 +.PHONY: dev-tools +dev-tools: + $(GO) install mvdan.cc/gofumpt@latest + $(GO) install golang.org/x/tools/cmd/goimports@latest + $(GO) install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_VERSION) + +## docker: build the container image +.PHONY: docker +docker: + docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) -t $(BINARY):$(VERSION) . + +## clean: remove build artifacts +.PHONY: clean +clean: + rm -rf $(BIN_DIR) dist coverage.txt coverage.html + +## help: list targets +.PHONY: help +help: + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## //' | awk -F ': ' '{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 3c71860..0e27014 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ # goChess -Chess Engine in Golang + +[![CI](https://github.com/cjunius/goChess/actions/workflows/ci.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/ci.yml) +[![Lint](https://github.com/cjunius/goChess/actions/workflows/lint.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/lint.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/cjunius/goChess.svg)](https://pkg.go.dev/github.com/cjunius/goChess) +[![Go Report Card](https://goreportcard.com/badge/github.com/cjunius/goChess)](https://goreportcard.com/report/github.com/cjunius/goChess) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) + +A UCI chess engine written in Go, built on the +[`dragontoothmg`](https://github.com/dylhunn/dragontoothmg) magic-bitboard legal +move generator. + +## Status + +Early. The engine plays legal chess via a UCI loop with: + +- **Move generation** — delegated to `dragontoothmg` (verified with perft). +- **Evaluation** — material + piece-square tables + bishop pair. +- **Search** — iterative deepening, negamax alpha-beta, quiescence search, + MVV-LVA move ordering, hard time limits. + +Not yet implemented: transposition table, killer/history heuristics, null-move +pruning, opening book, endgame tablebases. See the [roadmap](#roadmap). + +## Install + +```bash +go install github.com/cjunius/goChess/cmd/gochess@latest +``` + +Or build from source: + +```bash +git clone https://github.com/cjunius/goChess +cd goChess +make build # produces ./bin/gochess +``` + +## Usage + +```bash +gochess # UCI mode — point a GUI (CuteChess, Arena) at this +gochess perft 6 # timed perft series from the start position +gochess perft 4 "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" +gochess bench # fixed search benchmark (nodes/sec) +gochess version +``` + +### Playing a game + +Add the binary as an engine in any UCI GUI, or pipe commands directly: + +``` +position startpos moves e2e4 e7e5 +go movetime 1000 +``` + +## Development + +```bash +make test # go test -race ./... +make lint # golangci-lint +make vuln # govulncheck +make cover # coverage report +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/development.md](docs/development.md). + +## Project layout + +``` +cmd/gochess/ main entry point + CLI subcommands +internal/engine/ perft, evaluation, search +internal/uci/ UCI protocol loop +docs/ architecture notes and ADRs +``` + +## Roadmap + +- [x] Move generation on `dragontoothmg` + perft regression tests +- [x] `Evaluate(pos)` — material + piece-square tables +- [x] Negamax + alpha-beta + iterative deepening + time budget +- [x] UCI loop +- [x] Quiescence search + MVV-LVA move ordering +- [ ] Transposition table (Zobrist hash is already available from `dragontoothmg`) +- [ ] Killer moves + history heuristic +- [ ] Null-move pruning, late move reductions +- [ ] Opening book (Polyglot) and Syzygy tablebase probing +- [ ] Strength testing harness (SPRT via cutechess-cli) + +## License + +[GPL-3.0](LICENSE). `dragontoothmg` is also GPL-3.0. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..265314d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest minor release and the `main` branch. + +| Version | Supported | +| ------- | ------------------ | +| latest | :white_check_mark: | +| < latest| :x: | + +## Reporting a vulnerability + +**Do not open a public issue for security vulnerabilities.** + +Report privately through GitHub's +[Security Advisories](https://github.com/cjunius/goChess/security/advisories/new), +or email **christopher.junius@gmail.com** with: + +- a description of the issue and its impact, +- steps to reproduce (FEN / input sequence / command line), +- affected version or commit, +- any suggested fix. + +### What to expect + +| Stage | Target | +| ---------------------------- | -------------------------- | +| Acknowledgement | within 3 business days | +| Initial assessment | within 10 business days | +| Fix or mitigation plan | communicated after triage | +| Public disclosure | coordinated after a fix | + +We will credit reporters in the advisory unless you ask otherwise. + +## Scope + +This is a chess engine that parses untrusted input (FEN strings and UCI +commands, often from a GUI or tournament manager). In scope: + +- crashes, panics, or unbounded resource use from malformed FEN or UCI input, +- memory-safety issues in dependencies surfaced through this project, +- supply-chain issues in the build or release pipeline. + +Out of scope: the engine losing a game, weak play, or perft discrepancies +(report those as normal bugs). + +## Dependencies + +Dependencies are monitored by Dependabot and scanned with `govulncheck` in CI. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..04ecfa0 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,26 @@ +# Support + +## Questions and discussion + +- **How do I…? / Is this expected?** — open a + [GitHub Discussion](https://github.com/cjunius/goChess/discussions). +- **Engine setup in a GUI** — see the [README](README.md#playing-a-game); if + still stuck, start a Discussion with your GUI name and OS. + +## Bugs + +Open a [bug report](https://github.com/cjunius/goChess/issues/new/choose). +For a wrong or illegal move, always include: + +- the FEN of the position, +- the full move list (or `position …` command), +- the `go` command used (`go depth N` or `go movetime N`), +- `gochess version` output. + +## Security issues + +Do not use issues or discussions. Follow [SECURITY.md](SECURITY.md). + +## Commercial / priority support + +None is offered. This is a volunteer project. diff --git a/cmd/gochess/main.go b/cmd/gochess/main.go new file mode 100644 index 0000000..04aa124 --- /dev/null +++ b/cmd/gochess/main.go @@ -0,0 +1,112 @@ +// Command gochess is the entry point for the chess engine. With no arguments it +// speaks UCI on stdin/stdout; subcommands expose perft and a fixed benchmark. +// +// gochess # UCI mode (for a GUI or another engine) +// gochess uci # UCI mode, explicit +// gochess perft [fen] # timed perft series from a position +// gochess bench # fixed search benchmark (nodes/sec) +// gochess version # build metadata +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/dylhunn/dragontoothmg" + + "github.com/cjunius/goChess/internal/engine" + "github.com/cjunius/goChess/internal/uci" +) + +// Overridden at build time via -ldflags (see .goreleaser.yaml / Makefile). +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + args := os.Args[1:] + if len(args) == 0 { + mustUCI() + return + } + switch args[0] { + case "uci": + mustUCI() + case "perft": + runPerft(args[1:]) + case "bench": + runBench() + case "version", "-v", "--version": + fmt.Printf("gochess %s (commit %s, built %s)\n", version, commit, date) + case "help", "-h", "--help": + fmt.Print(usage) + default: + fmt.Fprintf(os.Stderr, "gochess: unknown command %q\n\n%s", args[0], usage) + os.Exit(2) + } +} + +const usage = `usage: gochess [command] + + (no command) speak UCI on stdin/stdout + uci speak UCI on stdin/stdout + perft [fen] print a timed perft series (default: start position) + bench run the fixed search benchmark + version print build metadata +` + +func mustUCI() { + if err := uci.Run(os.Stdin, os.Stdout, version); err != nil { + fmt.Fprintln(os.Stderr, "gochess:", err) + os.Exit(1) + } +} + +func runPerft(args []string) { + depth := 5 + fen := dragontoothmg.Startpos + if len(args) > 0 { + d, err := strconv.Atoi(args[0]) + if err != nil || d < 1 { + fmt.Fprintf(os.Stderr, "gochess: invalid depth %q\n", args[0]) + os.Exit(2) + } + depth = d + } + if len(args) > 1 { + fen = strings.Join(args[1:], " ") + } + + board := dragontoothmg.ParseFen(fen) + for _, row := range engine.PerftSeries(&board, depth) { + nps := float64(row.Nodes) / row.Elapsed.Seconds() + fmt.Printf("depth %2d nodes %13d %10s %12.0f nps\n", + row.Depth, row.Nodes, row.Elapsed.Round(time.Millisecond), nps) + } +} + +func runBench() { + positions := []string{ + dragontoothmg.Startpos, + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + } + var totalNodes int64 + start := time.Now() + for _, fen := range positions { + board := dragontoothmg.ParseFen(fen) + res := engine.Search(&board, engine.SearchParams{MaxDepth: 6}) + totalNodes += res.Nodes + fmt.Printf("%-74s bestmove %-6s score %6d depth %d\n", + fen, res.BestMove.String(), res.Score, res.Depth) + } + elapsed := time.Since(start) + fmt.Printf("\nbench: %d nodes in %s (%.0f nps)\n", + totalNodes, elapsed.Round(time.Millisecond), float64(totalNodes)/elapsed.Seconds()) +} diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md new file mode 100644 index 0000000..6be0f67 --- /dev/null +++ b/docs/adr/0001-record-architecture-decisions.md @@ -0,0 +1,27 @@ +# 1. Record architecture decisions + +Date: 2026-08-30 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project so that +future contributors understand why the code is the way it is. + +## Decision + +We will use Architecture Decision Records, as +[described by Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions). + +Each record is a short Markdown file in `docs/adr/`, numbered sequentially and +monotonically (`NNNN-title.md`). Records are immutable once accepted; a later +decision that reverses an earlier one is a new record that updates the status of +the old one. + +## Consequences + +- Decisions have a durable, reviewable home outside commit messages. +- The `docs/adr/` directory is the first stop for "why is this like this?" diff --git a/docs/adr/0002-use-dragontoothmg-for-move-generation.md b/docs/adr/0002-use-dragontoothmg-for-move-generation.md new file mode 100644 index 0000000..214c70b --- /dev/null +++ b/docs/adr/0002-use-dragontoothmg-for-move-generation.md @@ -0,0 +1,57 @@ +# 2. Use dragontoothmg for move generation + +Date: 2026-08-30 + +## Status + +Accepted + +## Context + +A chess engine needs fast, correct, legal move generation. The prototype used +`github.com/notnil/chess`, which is convenient but allocation-heavy and slow +(it builds move objects and validates via full position copies), making it +unsuitable for a search that visits millions of nodes per second. + +Writing a magic-bitboard generator from scratch is a large, error-prone effort +(sliding-piece attack tables, pin/check evasion, en passant edge cases). It is a +worthwhile project on its own but not the point of *this* project, which is +search and evaluation. + +Options considered: + +1. Keep `notnil/chess`. +2. Write our own bitboard move generator. +3. Build on an existing Go bitboard generator — `dylhunn/dragontoothmg`. + +## Decision + +Use `github.com/dylhunn/dragontoothmg`. + +- It is a dedicated magic-bitboard **legal** move generator (no + make/unmake-to-test needed for legality). +- Make/unmake via `Apply` returning an unapply closure — allocation-light and + exactly what alpha-beta wants. +- It exposes per-piece bitboards (for evaluation), an incrementally updated + Zobrist hash (for a future transposition table), FEN parsing, and a reference + `Perft`. +- It is GPL-3.0, the same license as this project. + +Trade-offs accepted: + +- The dependency is unmaintained (last commit 2022) and has no tagged releases; + we pin a specific pseudo-version and vendor-verify via `go.sum` + the Go + checksum database. If it ever needs fixing, forking is straightforward — it is + a small, single-package library. +- Its API uses exported-but-lightly-documented types; we wrap the pieces we use + behind `internal/engine` so a future swap (e.g. to our own generator) is + localized. + +## Consequences + +- `internal/engine` and `internal/uci` depend directly on `dragontoothmg` + types (`Board`, `Move`). A replacement would touch both packages. +- Move-generation correctness is asserted only through perft tests; we trust the + library otherwise. +- We inherit the library's board limits and its behavior on malformed FEN; + parsing robustness is covered in SECURITY.md scope. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ab8aa0f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,70 @@ +# Architecture + +## Overview + +``` + ┌────────────────────┐ + stdin/stdout ─► │ internal/uci │ UCI protocol loop + │ (session state) │ + └─────────┬──────────┘ + │ dragontoothmg.Board + ┌─────────▼──────────┐ + │ internal/engine │ perft · Evaluate · Search + └─────────┬──────────┘ + │ GenerateLegalMoves / Apply / OurKingInCheck + ┌─────────▼──────────┐ + │ dragontoothmg │ magic-bitboard legal move generator + │ (external, GPLv3) │ + └────────────────────┘ +``` + +`cmd/gochess` wires these together and adds the `perft` / `bench` CLI +subcommands. + +## Components + +### `dragontoothmg` (dependency) + +Provides the board representation (`Board`, bitboards per piece per color), +legal move generation (`GenerateLegalMoves`), make/unmake (`Apply` returns an +unapply closure), FEN parsing, an incrementally-updated Zobrist hash +(`Board.Hash()`), and a reference `Perft`. We do not reimplement any of this. + +### `internal/engine` + +- **perft.go** — thin wrappers over `dragontoothmg.Perft` plus timed series and + divide helpers. Backed by regression tests against published node counts. +- **eval.go** — `Evaluate(*Board) int`. Material values + Michniewski + piece-square tables + a bishop-pair bonus. Returns a score relative to the + side to move (negamax convention). Piece-square lookups use little-endian + rank-file indexing: white reads `pst[sq]`, black reads `pst[sq^56]`. +- **search.go** — `Search(*Board, SearchParams) SearchResult`. Iterative + deepening around a negamax alpha-beta core, with: + - quiescence search at the horizon (captures and promotions only), + - MVV-LVA move ordering, + - mate-distance-aware scoring (`mateScore - ply`), + - a hard wall-clock budget checked every 2048 nodes; the last fully completed + depth is returned. + +### `internal/uci` + +A line-oriented reader for `uci`, `isready`, `ucinewgame`, `position` +(`startpos` / `fen`, with `moves`), `go` (`depth`, `movetime`, `wtime`/`btime`), +`stop`, `d`, and `quit`. Search is synchronous, so `stop` is a no-op and +`bestmove` is emitted as soon as `go` returns. + +## Deliberately not here yet + +Transposition table, killer/history heuristics, null-move pruning, LMR, aspiration +windows, opening book, tablebases, pondering, `SearchMoves`/`MultiPV`. The Zobrist +hash needed for a TT is already exposed by `dragontoothmg`. + +## Key invariants + +1. **Move generation is never reimplemented.** Any bug there is a `dragontoothmg` + bug or a misuse of its API; fix it with a perft test. +2. **`Search` leaves the board unmodified** — every `Apply` is paired with its + unapply, including on early returns. +3. **Evaluation sign convention**: positive = good for the side to move. +4. **Untrusted input**: FEN and UCI strings come from GUIs and tournament + managers. Parsing must not panic (see SECURITY.md). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..e26216a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,78 @@ +# Development + +## Prerequisites + +- Go (see `go.mod` for the minimum version) +- `make` +- `golangci-lint`, `gofumpt`, `goimports`, `govulncheck` — `make dev-tools` + +## First-time setup + +```bash +git clone https://github.com/cjunius/goChess +cd goChess +go mod download +make dev-tools +make check # tidy + lint + vuln + test +``` + +## Common tasks + +| Command | What it does | +| ---------------- | --------------------------------------------------- | +| `make build` | compile `./bin/gochess` | +| `make run` | build and start UCI mode | +| `make test` | `go test -race -short ./...` | +| `make test-long` | full suite incl. perft 6 and Kiwipete depth 4 | +| `make bench` | Go micro-benchmarks | +| `make cover` | write `coverage.html` | +| `make lint` | `golangci-lint run` | +| `make vuln` | `govulncheck ./...` | +| `make fmt` | `gofumpt` + `goimports` | + +## Manual UCI session + +``` +$ ./bin/gochess +uci +id name goChess dev +id author Christopher Junius +uciok +position startpos moves e2e4 +go movetime 500 +info depth 6 score cp 24 nodes 41233 time measured pv e7e5 +bestmove e7e5 +quit +``` + +## Perft + +Perft counts leaf nodes of the move tree and is the definitive move-generation +test. + +```bash +./bin/gochess perft 6 +./bin/gochess perft 4 "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" +``` + +If a count is wrong, use `engine.PerftDivide` to find which move's subtree +diverges from a reference (e.g. Stockfish `go perft N`), then recurse. + +## Strength testing + +Search and evaluation changes must be validated with a match, not intuition. +Use `cutechess-cli` against the previous build: + +```bash +cutechess-cli \ + -engine cmd=./bin/gochess-new name=new \ + -engine cmd=./bin/gochess-old name=old \ + -each proto=uci tc=10+0.1 -games 2 -rounds 1000 -repeat \ + -openings file=book.epd format=epd order=random \ + -sprt elo0=0 elo1=5 alpha=0.05 beta=0.05 \ + -concurrency 8 +``` + +## Releasing + +See [../MAINTAINERS.md](../MAINTAINERS.md#release-process). diff --git a/go.mod b/go.mod index faa2a93..b9b0298 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module goChess +module github.com/cjunius/goChess go 1.22.3 -require github.com/notnil/chess v1.9.0 // indirect +require github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93 diff --git a/go.sum b/go.sum index bd1833d..aeebce0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,2 @@ -github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/notnil/chess v1.9.0 h1:YMxR5kUVjtwcuFptGU0/3q7eG3MSHQNbg0VUekvRKV0= -github.com/notnil/chess v1.9.0/go.mod h1:cRuJUIBFq9Xki05TWHJxHYkC+fFpq45IWwk94DdlCrA= +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/internal/engine/doc.go b/internal/engine/doc.go new file mode 100644 index 0000000..ce6b796 --- /dev/null +++ b/internal/engine/doc.go @@ -0,0 +1,4 @@ +// Package engine contains the goChess chess engine: perft, static evaluation, +// and an alpha-beta search. Move generation and board state are delegated to +// github.com/dylhunn/dragontoothmg, a magic-bitboard legal move generator. +package engine diff --git a/internal/engine/eval.go b/internal/engine/eval.go new file mode 100644 index 0000000..757807c --- /dev/null +++ b/internal/engine/eval.go @@ -0,0 +1,127 @@ +package engine + +import ( + "math/bits" + + "github.com/dylhunn/dragontoothmg" +) + +// Centipawn material values, indexed by the dragontoothmg piece constants +// (Pawn=1 .. King=6). The king value only matters for move ordering, never +// for the returned evaluation, since both sides always have exactly one. +var pieceValue = [7]int{ + dragontoothmg.Pawn: 100, + dragontoothmg.Knight: 320, + dragontoothmg.Bishop: 330, + dragontoothmg.Rook: 500, + dragontoothmg.Queen: 900, + dragontoothmg.King: 20000, +} + +const bishopPairBonus = 30 + +// Piece-square tables (Michniewski's "simplified evaluation function"), +// written in a1..h8 order (rank 1 first). dragontoothmg uses little-endian +// rank-file square numbering, so a white piece on square sq reads pst[sq] +// directly and a black piece reads the vertically mirrored pst[sq^56]. +var ( + pawnPST = [64]int{ + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, -20, -20, 10, 10, 5, + 5, -5, -10, 0, 0, -10, -5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, 5, 10, 25, 25, 10, 5, 5, + 10, 10, 20, 30, 30, 20, 10, 10, + 50, 50, 50, 50, 50, 50, 50, 50, + 0, 0, 0, 0, 0, 0, 0, 0, + } + knightPST = [64]int{ + -50, -40, -30, -30, -30, -30, -40, -50, + -40, -20, 0, 5, 5, 0, -20, -40, + -30, 5, 10, 15, 15, 10, 5, -30, + -30, 0, 15, 20, 20, 15, 0, -30, + -30, 5, 15, 20, 20, 15, 5, -30, + -30, 0, 10, 15, 15, 10, 0, -30, + -40, -20, 0, 0, 0, 0, -20, -40, + -50, -40, -30, -30, -30, -30, -40, -50, + } + bishopPST = [64]int{ + -20, -10, -10, -10, -10, -10, -10, -20, + -10, 5, 0, 0, 0, 0, 5, -10, + -10, 10, 10, 10, 10, 10, 10, -10, + -10, 0, 10, 10, 10, 10, 0, -10, + -10, 5, 5, 10, 10, 5, 5, -10, + -10, 0, 5, 10, 10, 5, 0, -10, + -10, 0, 0, 0, 0, 0, 0, -10, + -20, -10, -10, -10, -10, -10, -10, -20, + } + rookPST = [64]int{ + 0, 0, 0, 5, 5, 0, 0, 0, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 5, 10, 10, 10, 10, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0, + } + queenPST = [64]int{ + -20, -10, -10, -5, -5, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 5, 5, 5, 0, -10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0, -10, + -10, 0, 5, 0, 0, 0, 0, -10, + -20, -10, -10, -5, -5, -10, -10, -20, + } + kingPST = [64]int{ + 20, 30, 10, 0, 0, 10, 30, 20, + 20, 20, 0, 0, 0, 0, 20, 20, + -10, -20, -20, -20, -20, -20, -20, -10, + -20, -30, -30, -40, -40, -30, -30, -20, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + } +) + +// Evaluate returns a static score for the position in centipawns, from the +// point of view of the side to move (positive = better for that side). This is +// the sign convention negamax expects. +func Evaluate(b *dragontoothmg.Board) int { + score := evalSide(&b.White, true) - evalSide(&b.Black, false) + if b.Wtomove { + return score + } + return -score +} + +func evalSide(bb *dragontoothmg.Bitboards, white bool) int { + s := evalPiece(bb.Pawns, &pawnPST, pieceValue[dragontoothmg.Pawn], white) + + evalPiece(bb.Knights, &knightPST, pieceValue[dragontoothmg.Knight], white) + + evalPiece(bb.Bishops, &bishopPST, pieceValue[dragontoothmg.Bishop], white) + + evalPiece(bb.Rooks, &rookPST, pieceValue[dragontoothmg.Rook], white) + + evalPiece(bb.Queens, &queenPST, pieceValue[dragontoothmg.Queen], white) + + evalPiece(bb.Kings, &kingPST, pieceValue[dragontoothmg.King], white) + if bits.OnesCount64(bb.Bishops) >= 2 { + s += bishopPairBonus + } + return s +} + +func evalPiece(board uint64, pst *[64]int, value int, white bool) int { + s := 0 + for board != 0 { + sq := bits.TrailingZeros64(board) + board &= board - 1 + s += value + if white { + s += pst[sq] + } else { + s += pst[sq^56] + } + } + return s +} diff --git a/internal/engine/perft.go b/internal/engine/perft.go new file mode 100644 index 0000000..219feaf --- /dev/null +++ b/internal/engine/perft.go @@ -0,0 +1,44 @@ +package engine + +import ( + "time" + + "github.com/dylhunn/dragontoothmg" +) + +// Perft counts the number of leaf nodes in the move tree to the given depth. +// It is the canonical correctness test for a move generator: the counts for +// well-known positions are published and must match exactly. +func Perft(b *dragontoothmg.Board, depth int) int64 { + return dragontoothmg.Perft(b, depth) +} + +// PerftRow is a single timed perft result, used by the CLI. +type PerftRow struct { + Depth int + Nodes int64 + Elapsed time.Duration +} + +// PerftSeries runs perft for every depth in [1, depth] and returns timed rows. +func PerftSeries(b *dragontoothmg.Board, depth int) []PerftRow { + rows := make([]PerftRow, 0, depth) + for d := 1; d <= depth; d++ { + start := time.Now() + nodes := dragontoothmg.Perft(b, d) + rows = append(rows, PerftRow{Depth: d, Nodes: nodes, Elapsed: time.Since(start)}) + } + return rows +} + +// PerftDivide returns the per-move node counts one ply below the root. It is the +// standard tool for bisecting a move-generation discrepancy against a reference. +func PerftDivide(b *dragontoothmg.Board, depth int) map[string]int64 { + out := make(map[string]int64) + for _, m := range b.GenerateLegalMoves() { + unapply := b.Apply(m) + out[m.String()] = dragontoothmg.Perft(b, depth-1) + unapply() + } + return out +} diff --git a/internal/engine/perft_test.go b/internal/engine/perft_test.go new file mode 100644 index 0000000..c8a39e5 --- /dev/null +++ b/internal/engine/perft_test.go @@ -0,0 +1,67 @@ +package engine_test + +import ( + "testing" + + "github.com/dylhunn/dragontoothmg" + + "github.com/cjunius/goChess/internal/engine" +) + +// Reference node counts from the Chess Programming Wiki. These are exact and +// act as a regression net for the whole move-generation path. +func TestPerftStartpos(t *testing.T) { + cases := []struct { + depth int + want int64 + long bool + }{ + {1, 20, false}, + {2, 400, false}, + {3, 8902, false}, + {4, 197281, false}, + {5, 4865609, true}, + {6, 119060324, true}, + } + for _, c := range cases { + if c.long && testing.Short() { + continue + } + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + if got := engine.Perft(&b, c.depth); got != c.want { + t.Errorf("perft(startpos, %d) = %d, want %d", c.depth, got, c.want) + } + } +} + +// "Kiwipete" – a dense middlegame position that exercises castling, promotion, +// en passant and pins all at once. +func TestPerftKiwipete(t *testing.T) { + const fen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" + cases := []struct { + depth int + want int64 + long bool + }{ + {1, 48, false}, + {2, 2039, false}, + {3, 97862, false}, + {4, 4085603, true}, + } + for _, c := range cases { + if c.long && testing.Short() { + continue + } + b := dragontoothmg.ParseFen(fen) + if got := engine.Perft(&b, c.depth); got != c.want { + t.Errorf("perft(kiwipete, %d) = %d, want %d", c.depth, got, c.want) + } + } +} + +func BenchmarkPerftStartpos(b *testing.B) { + for i := 0; i < b.N; i++ { + board := dragontoothmg.ParseFen(dragontoothmg.Startpos) + engine.Perft(&board, 4) + } +} diff --git a/internal/engine/search.go b/internal/engine/search.go new file mode 100644 index 0000000..fa7f7b4 --- /dev/null +++ b/internal/engine/search.go @@ -0,0 +1,235 @@ +package engine + +import ( + "sort" + "time" + + "github.com/dylhunn/dragontoothmg" +) + +const ( + // mateScore is the score for being checkmated at ply 0. Scores within + // maxPly of it encode "mate in N", closer to mateScore meaning sooner. + mateScore = 1_000_000 + drawScore = 0 + infinity = 1 << 30 + maxPly = 64 +) + +// SearchParams controls a single Search call. +type SearchParams struct { + // MaxDepth caps iterative deepening. Zero or negative means "use maxPly". + MaxDepth int + // MoveTime is a hard wall-clock budget. Zero means "no time limit; obey + // MaxDepth only". When set, the last fully completed depth is returned. + MoveTime time.Duration +} + +// SearchResult is the outcome of a Search call. +type SearchResult struct { + BestMove dragontoothmg.Move + Score int + Depth int + Nodes int64 + Elapsed time.Duration +} + +type searcher struct { + nodes int64 + deadline time.Time + stopped bool +} + +func (s *searcher) timeUp() bool { + return !s.deadline.IsZero() && time.Now().After(s.deadline) +} + +// Search runs iterative-deepening alpha-beta and returns the best move it found. +// The board is left unmodified (every applied move is unapplied). +func Search(b *dragontoothmg.Board, p SearchParams) SearchResult { + if p.MaxDepth <= 0 || p.MaxDepth > maxPly { + p.MaxDepth = maxPly + } + s := &searcher{} + if p.MoveTime > 0 { + s.deadline = time.Now().Add(p.MoveTime) + } + start := time.Now() + + var res SearchResult + if b.White.Kings == 0 || b.Black.Kings == 0 { + return res // illegal position, nothing sensible to search + } + root := b.GenerateLegalMoves() + if len(root) == 0 { + return res + } + res.BestMove = root[0] + + for depth := 1; depth <= p.MaxDepth; depth++ { + score, move, ok := s.searchRoot(b, depth) + if !ok { + break // out of time: keep the previous completed depth + } + res.BestMove, res.Score, res.Depth = move, score, depth + if score >= mateScore-maxPly || score <= -mateScore+maxPly { + break // forced mate found; deeper search cannot improve on it + } + if s.timeUp() { + break + } + } + res.Nodes = s.nodes + res.Elapsed = time.Since(start) + return res +} + +func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, best dragontoothmg.Move, ok bool) { + moves := orderMoves(b, b.GenerateLegalMoves()) + alpha, beta := -infinity, infinity + bestScore := -infinity + for _, m := range moves { + unapply := b.Apply(m) + v := -s.negamax(b, depth-1, -beta, -alpha, 1) + unapply() + if s.stopped { + return 0, dragontoothmg.Move(0), false + } + if v > bestScore { + bestScore, best = v, m + } + if v > alpha { + alpha = v + } + } + return bestScore, best, true +} + +func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int) int { + s.nodes++ + if s.nodes&2047 == 0 && s.timeUp() { + s.stopped = true + return 0 + } + if b.White.Kings == 0 || b.Black.Kings == 0 { + return kingCaptureScore(b, ply) + } + if b.Halfmoveclock >= 100 { + return drawScore + } + if depth <= 0 { + return s.quiesce(b, alpha, beta, ply) + } + + moves := b.GenerateLegalMoves() + if len(moves) == 0 { + if b.OurKingInCheck() { + return -mateScore + ply // checkmate; prefer the shortest mate + } + return drawScore // stalemate + } + + best := -infinity + for _, m := range orderMoves(b, moves) { + unapply := b.Apply(m) + v := -s.negamax(b, depth-1, -beta, -alpha, ply+1) + unapply() + if s.stopped { + return 0 + } + if v > best { + best = v + } + if v > alpha { + alpha = v + } + if alpha >= beta { + break // fail-high: opponent won't enter this line + } + } + return best +} + +// quiesce searches only "loud" moves (captures and promotions) past the horizon +// so the static eval is never taken in the middle of a capture sequence. +func (s *searcher) quiesce(b *dragontoothmg.Board, alpha, beta, ply int) int { + s.nodes++ + if b.White.Kings == 0 || b.Black.Kings == 0 { + return kingCaptureScore(b, ply) + } + stand := Evaluate(b) + if stand >= beta { + return beta + } + if stand > alpha { + alpha = stand + } + if ply >= maxPly { + return stand + } + + for _, m := range orderMoves(b, b.GenerateLegalMoves()) { + if !dragontoothmg.IsCapture(m, b) && m.Promote() == dragontoothmg.Nothing { + continue + } + unapply := b.Apply(m) + v := -s.quiesce(b, -beta, -alpha, ply+1) + unapply() + if s.stopped { + return 0 + } + if v >= beta { + return beta + } + if v > alpha { + alpha = v + } + } + return alpha +} + +// kingCaptureScore scores the illegal position left when a previous ply captured +// a king. dragontoothmg can generate such a move when the position it is given +// has the side *not* to move in check; without this guard the next +// GenerateLegalMoves call panics on an empty king bitboard. These positions +// never arise from legal play. +func kingCaptureScore(b *dragontoothmg.Board, ply int) int { + ourKings := b.White.Kings + if !b.Wtomove { + ourKings = b.Black.Kings + } + if ourKings == 0 { + return -mateScore + ply // our king was just captured + } + return mateScore - ply // the opponent's king is gone +} + +// orderMoves sorts moves best-first so alpha-beta prunes as early as possible: +// promotions first, then captures by MVV-LVA (most valuable victim, least +// valuable attacker), then quiet moves. +func orderMoves(b *dragontoothmg.Board, moves []dragontoothmg.Move) []dragontoothmg.Move { + type scored struct { + move dragontoothmg.Move + score int + } + list := make([]scored, len(moves)) + for i := range moves { + m := moves[i] + sc := 0 + if p := m.Promote(); p != dragontoothmg.Nothing { + sc += 90000 + pieceValue[p] + } + if dragontoothmg.IsCapture(m, b) { + victim, _ := dragontoothmg.GetPieceType(m.To(), b) + attacker, _ := dragontoothmg.GetPieceType(m.From(), b) + sc += 10000 + pieceValue[victim]*8 - pieceValue[attacker] + } + list[i] = scored{m, sc} + } + sort.SliceStable(list, func(i, j int) bool { return list[i].score > list[j].score }) + out := make([]dragontoothmg.Move, len(moves)) + for i := range list { + out[i] = list[i].move + } + return out +} diff --git a/internal/engine/search_test.go b/internal/engine/search_test.go new file mode 100644 index 0000000..7068a94 --- /dev/null +++ b/internal/engine/search_test.go @@ -0,0 +1,59 @@ +package engine_test + +import ( + "testing" + "time" + + "github.com/dylhunn/dragontoothmg" + + "github.com/cjunius/goChess/internal/engine" +) + +func TestSearchFindsMateInOne(t *testing.T) { + // Black king boxed on g8 by its own pawns; Ra1-a8 is mate. + b := dragontoothmg.ParseFen("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 3}) + if got := res.BestMove.String(); got != "a1a8" { + t.Fatalf("best move = %s (score %d), want a1a8", got, res.Score) + } + if res.Score < mateThreshold { + t.Errorf("score = %d, want a mate score (>= %d)", res.Score, mateThreshold) + } +} + +func TestSearchWinsFreeMaterial(t *testing.T) { + // White rook on d1 can take the undefended queen on d8. + b := dragontoothmg.ParseFen("3q2k1/8/8/8/8/8/6K1/3R4 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 4}) + if got := res.BestMove.String(); got != "d1d8" { + t.Fatalf("best move = %s, want d1d8", got) + } +} + +func TestSearchDoesNotHangQueen(t *testing.T) { + // White queen on d2 can grab the rook on d8, but Kxd8 then trades the queen + // for a rook and leaves K vs K. The engine must keep the queen instead. + b := dragontoothmg.ParseFen("3rk3/8/8/8/8/8/3Q4/4K3 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 5}) + if got := res.BestMove.String(); got == "d2d8" { + t.Fatalf("engine hung its queen with %s", got) + } +} + +func TestSearchRespectsMoveTime(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + start := time.Now() + res := engine.Search(&b, engine.SearchParams{MaxDepth: maxDepthUnbounded, MoveTime: 200 * time.Millisecond}) + elapsed := time.Since(start) + if elapsed > time.Second { + t.Fatalf("search ran %s, expected it to stop near 200ms", elapsed) + } + if res.BestMove.String() == "0000" { + t.Fatal("search returned no move") + } +} + +const ( + mateThreshold = 1_000_000 - 64 + maxDepthUnbounded = 64 +) diff --git a/internal/uci/uci.go b/internal/uci/uci.go new file mode 100644 index 0000000..769eb23 --- /dev/null +++ b/internal/uci/uci.go @@ -0,0 +1,163 @@ +// Package uci implements the subset of the Universal Chess Interface protocol +// that goChess needs to play in a GUI (Arena, CuteChess, BanksiaGUI) or against +// other engines. The protocol is line-based over stdin/stdout. +package uci + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/dylhunn/dragontoothmg" + + "github.com/cjunius/goChess/internal/engine" +) + +const ( + engineName = "goChess" + engineAuthor = "Christopher Junius" + + // Fraction of the remaining clock to spend on one move when the GUI sends + // wtime/btime rather than an explicit movetime. + clockDivisor = 30 +) + +type session struct { + board dragontoothmg.Board + out io.Writer +} + +// Run reads UCI commands from r and writes responses to w until "quit" or EOF. +// version is reported in the "id name" line. +func Run(r io.Reader, w io.Writer, version string) error { + s := &session{board: dragontoothmg.ParseFen(dragontoothmg.Startpos), out: w} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for sc.Scan() { + fields := strings.Fields(strings.TrimSpace(sc.Text())) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "uci": + fmt.Fprintf(w, "id name %s %s\n", engineName, version) + fmt.Fprintf(w, "id author %s\n", engineAuthor) + fmt.Fprintln(w, "uciok") + case "isready": + fmt.Fprintln(w, "readyok") + case "ucinewgame": + s.board = dragontoothmg.ParseFen(dragontoothmg.Startpos) + case "position": + s.handlePosition(fields[1:]) + case "go": + s.handleGo(fields[1:]) + case "stop": + // Search is synchronous, so there is nothing to interrupt. + case "d": + fmt.Fprintln(w, s.board.ToFen()) + case "quit": + return nil + } + } + return sc.Err() +} + +func (s *session) handlePosition(args []string) { + if len(args) == 0 { + return + } + var rest []string + switch args[0] { + case "startpos": + s.board = dragontoothmg.ParseFen(dragontoothmg.Startpos) + rest = args[1:] + case "fen": + if len(args) < 7 { + return + } + s.board = dragontoothmg.ParseFen(strings.Join(args[1:7], " ")) + rest = args[7:] + default: + return + } + if len(rest) == 0 || rest[0] != "moves" { + return + } + for _, tok := range rest[1:] { + m, err := dragontoothmg.ParseMove(tok) + if err != nil { + return + } + s.board.Apply(m) + } +} + +func (s *session) handleGo(args []string) { + params := engine.SearchParams{} + var wtime, btime, movetime time.Duration + + // Scan for the keywords we support, each followed by an integer argument. + // Unknown keywords (movestogo, winc, ponder, ...) are ignored. + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "depth": + if d, err := strconv.Atoi(args[i+1]); err == nil { + params.MaxDepth = d + } + case "movetime": + movetime = millis(args[i+1]) + case "wtime": + wtime = millis(args[i+1]) + case "btime": + btime = millis(args[i+1]) + } + } + + if movetime == 0 { + remaining := btime + if s.board.Wtomove { + remaining = wtime + } + if remaining > 0 { + movetime = remaining / clockDivisor + } + } + params.MoveTime = movetime + + res := engine.Search(&s.board, params) + if res.Depth > 0 { + fmt.Fprintf(s.out, "info depth %d score %s nodes %d time %d pv %s\n", + res.Depth, scoreString(res.Score), res.Nodes, res.Elapsed.Milliseconds(), res.BestMove.String()) + } + best := res.BestMove.String() + if best == "0000" { + fmt.Fprintln(s.out, "bestmove (none)") + return + } + fmt.Fprintf(s.out, "bestmove %s\n", best) +} + +func millis(s string) time.Duration { + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0 + } + return time.Duration(n) * time.Millisecond +} + +// scoreString renders a centipawn score, or "mate N" when the score encodes a +// forced mate, in the format UCI GUIs expect. +func scoreString(cp int) string { + const mate = 1_000_000 + if cp >= mate-64 { + return fmt.Sprintf("mate %d", (mate-cp+1)/2) + } + if cp <= -mate+64 { + return fmt.Sprintf("mate %d", -(mate+cp+1)/2) + } + return "cp " + strconv.Itoa(cp) +} diff --git a/internal/uci/uci_test.go b/internal/uci/uci_test.go new file mode 100644 index 0000000..b0567cb --- /dev/null +++ b/internal/uci/uci_test.go @@ -0,0 +1,51 @@ +package uci_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/cjunius/goChess/internal/uci" +) + +func run(t *testing.T, input string) string { + t.Helper() + var out bytes.Buffer + if err := uci.Run(strings.NewReader(input), &out, "test"); err != nil { + t.Fatalf("uci.Run: %v", err) + } + return out.String() +} + +func TestHandshake(t *testing.T) { + out := run(t, "uci\nisready\nquit\n") + for _, want := range []string{"id name goChess test", "id author", "uciok", "readyok"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestGoReturnsLegalBestMove(t *testing.T) { + out := run(t, "position startpos\ngo depth 3\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Fatalf("no bestmove line:\n%s", out) + } +} + +func TestPositionWithMovesThenMateSearch(t *testing.T) { + out := run(t, "position fen 6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1\ngo depth 4\nquit\n") + if !strings.Contains(out, "bestmove a1a8") { + t.Errorf("expected bestmove a1a8:\n%s", out) + } + if !strings.Contains(out, "score mate 1") { + t.Errorf("expected 'score mate 1' in info line:\n%s", out) + } +} + +func TestUnknownCommandIsIgnored(t *testing.T) { + out := run(t, "frobnicate\nisready\nquit\n") + if !strings.Contains(out, "readyok") { + t.Errorf("unknown command should be skipped, got:\n%s", out) + } +} diff --git a/main.go b/main.go deleted file mode 100644 index ca00e16..0000000 --- a/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "fmt" - "math/rand" - - "github.com/notnil/chess" -) - -func main() { - game := chess.NewGame() - for game.Outcome() == chess.NoOutcome { - moves := game.ValidMoves() - move := moves[rand.Intn(len(moves))] - game.Move(move) - } - - fmt.Println(game.Position().Board().Draw()) - fmt.Printf("Game completed. %s by %s.\n", game.Outcome(), game.Method()) - fmt.Println(game.String()) -} \ No newline at end of file diff --git a/perft.go b/perft.go deleted file mode 100644 index 4216524..0000000 --- a/perft.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "time" - - "github.com/notnil/chess" -) - -func main() { - game := chess.NewGame() - for i := range 6 { - start := time.Now() - nodes := perft(i, game.Position()) - end := time.Now() - diff := end.Sub(start) - fmt.Println("Depth " + strconv.Itoa(i) + " nodes " + strconv.Itoa(nodes) + " time " + diff.String()) - } -} - -func perft(depth int, position *chess.Position) (int) { - if depth == 1 { - return len(position.ValidMoves()) - } else if depth > 1{ - nodes := 0 - for _, move := range position.ValidMoves() { - newPos := position.Update(move) - nodes += perft(depth - 1, newPos) - } - return nodes - } else { - return 1 - } -} \ No newline at end of file