From 11efb1a42d21982d46d243ffe452aab42755ff2b Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 24 Mar 2026 18:12:53 +0100 Subject: [PATCH] docs: remove outdated JUnitSuiteLike issue from unresolved.md --- docs/adr/ADR-001-Stack.md | 43 - docs/adr/ADR-002-api-shared-models.md | 86 -- .../plans/2026-03-22-scalatest-scoverage.md | 244 ---- ...6-03-23-chess-check-checkmate-stalemate.md | 579 --------- docs/superpowers/plans/2026-03-24-castling.md | 1049 ----------------- .../2026-03-22-scalatest-scoverage-design.md | 85 -- ...-chess-check-checkmate-stalemate-design.md | 169 --- .../specs/2026-03-24-castling-design.md | 255 ---- docs/unresolved.md | 23 - 9 files changed, 2533 deletions(-) delete mode 100644 docs/adr/ADR-001-Stack.md delete mode 100644 docs/adr/ADR-002-api-shared-models.md delete mode 100644 docs/superpowers/plans/2026-03-22-scalatest-scoverage.md delete mode 100644 docs/superpowers/plans/2026-03-23-chess-check-checkmate-stalemate.md delete mode 100644 docs/superpowers/plans/2026-03-24-castling.md delete mode 100644 docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md delete mode 100644 docs/superpowers/specs/2026-03-23-chess-check-checkmate-stalemate-design.md delete mode 100644 docs/superpowers/specs/2026-03-24-castling-design.md diff --git a/docs/adr/ADR-001-Stack.md b/docs/adr/ADR-001-Stack.md deleted file mode 100644 index e6f01ae..0000000 --- a/docs/adr/ADR-001-Stack.md +++ /dev/null @@ -1,43 +0,0 @@ -# ADR-001: Technology Stack Selection - -## Status -Accepted - -## Context -The "NowChessSystems" project requires a modern, scalable, -and maintainable technology stack to support web-based interfaces. -The system is designed as a microservice architecture to allow for independent scaling and development of various components (e.g., engine, matchmaking, user management). - -## Decision -We have decided to use the following technologies for the core system: - -### Backend -- **Language:** [Scala 3](https://scala-lang.org/) for its powerful type system, functional programming capabilities, and seamless JVM integration. -- **Framework:** [Quarkus](https://quarkus.io/) with the `io.quarkiverse.scala:quarkus-scala3` extension to leverage GraalVM native compilation and fast startup times. -- **Persistence:** [Hibernate](https://hibernate.org/) and [Jakarta Persistence](https://jakarta.ee/specifications/persistence/) for standard-based ORM. - -### Frontend -- **Build Tool:** [Vite](https://vitejs.dev/) for a fast development experience. -- **Framework:** TBD (Evaluation between React, Angular, and Vue). -- **Terminal UI:** [Lanterna](https://github.com/mabe02/lanterna) for a text-based user interface (TUI). - -### DevOps & Infrastructure -- **Orchestration:** [Kubernetes](https://kubernetes.io/) for container orchestration. -- **GitOps & Delivery:** [ArgoCD](https://argoproj.github.io/cd/) for continuous delivery and [Kargo](https://kargo.io/) for multi-stage lifecycle management. - -### AI-Assisted Development -- [Claude Code Pro](https://claude.ai/) and [Claude Agent Teams](https://claude.ai/team) for coding and reviews. -- [Google Stitch](https://stitch.google.com/) (Free) for UI design and prototyping. - -## Consequences - -### Positive -- **High Performance:** Quarkus and GraalVM enable low memory footprint and fast startup. -- **Developer Productivity:** Scala 3 and AI tools provide a high-level, expressive environment. -- **Robustness:** Kubernetes and ArgoCD ensure reliable deployment and scaling. -- **Accessibility:** Offering both a TUI and a web interface caters to different user preferences. - -### Negative / Risks -- **Complexity:** Managing a microservices architecture with Kubernetes adds operational overhead. -- **Learning Curve:** Scala 3 and the specific Quarkus-Scala integration may require training for new developers. -- **Consistency:** Maintaining parity between the TUI and Web frontend functionality. \ No newline at end of file diff --git a/docs/adr/ADR-002-api-shared-models.md b/docs/adr/ADR-002-api-shared-models.md deleted file mode 100644 index fa456ed..0000000 --- a/docs/adr/ADR-002-api-shared-models.md +++ /dev/null @@ -1,86 +0,0 @@ -# ADR-002: Shared-Models Library (`modules/api`) - -## Status -Accepted - -## Context - -NowChessSystems is a microservice platform. As soon as two or more services need to -exchange data — whether through REST, messaging, or internal function calls — they must -agree on common data types. Without a shared home for those types, the same case class -(e.g. `Square`, `Move`, `GameState`) is duplicated in every module, diverges over time, -and causes silent serialisation mismatches at runtime. - -The `core` module currently owns the chess engine logic. Future modules (matchmaking, -game history, user management, notation export, etc.) will all need to refer to the -same chess domain vocabulary. A cross-cutting place to hold that vocabulary is therefore -required before any second service is built. - -## Decision - -We introduce `modules/api` as a **shared-models library**: a plain Scala 3 library -(no Quarkus, no Jakarta, no persistence) that contains only: - -- Pure Scala 3 data types: `case class`, `sealed trait`, and `enum` definitions -- Value objects that model the chess domain (pieces, colors, squares, moves, game state) -- Cross-service API envelope types (`ApiResponse[A]`, `ApiError`, `Pagination`) -- Minimal player/user identity stubs (IDs and display names only) - -Every service module that needs these types declares: - -```kotlin -implementation(project(":modules:api")) -``` - -in its own `build.gradle.kts`. The `modules/api` module itself carries no runtime -dependencies beyond the Scala 3 standard library. - -### Package layout - -``` -de.nowchess.api -├── board – Color, PieceType, Piece, File, Rank, Square -├── game – CastlingRights, GameState, GameResult, GameStatus -├── move – MoveType, Move, PromotionPiece -├── player – PlayerId, PlayerInfo -└── response – ApiResponse, ApiError, Pagination -``` - -## What belongs in `modules/api` - -| Belongs | Does NOT belong | -|---|---| -| `case class`, `sealed trait`, `enum` for chess domain | Quarkus `@ApplicationScoped` beans | -| API envelope types (`ApiResponse`, `ApiError`) | Jakarta Persistence entities (`@Entity`) | -| Player identity stubs (ID + display name) | REST resource classes | -| FEN/board-state representation types | Business logic, engine algorithms | -| Pure type aliases and value objects | Database queries or repositories | - -The rule of thumb: if a type carries a framework annotation or requires I/O to produce, -it does not belong in `modules/api`. - -## How other modules depend on it - -1. `modules/api` is a regular Gradle subproject already declared in `settings.gradle.kts`. -2. Consuming modules add `implementation(project(":modules:api"))` — nothing else. -3. Because `modules/api` has no Quarkus BOM, consuming modules must not re-export Quarkus - transitive dependencies through it. -4. If a future module needs JSON serialisation, it adds its own JSON library (e.g. - `circe`, `jsoniter-scala`) as a dependency and derives codecs for the shared types - there — codec derivation stays out of `modules/api`. - -## Consequences - -### Positive -- Single source of truth for all chess domain vocabulary. -- Adding a new microservice requires only one `implementation(project(":modules:api"))` - line — no copy-paste of types. -- The library is fast to compile (no framework processing) and cheap to test in isolation. -- Enforces a strict boundary: if a type needs a framework annotation it is forced into the - correct service module. - -### Negative / Risks -- Any breaking change to a shared type (rename, field removal) is a cross-cutting change - that touches every consuming module simultaneously. -- Developers must resist the temptation to add convenience methods or logic to these - types; discipline is required to keep the library pure. diff --git a/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md b/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md deleted file mode 100644 index d6d98f9..0000000 --- a/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md +++ /dev/null @@ -1,244 +0,0 @@ -# ScalaTest + Scoverage Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace JaCoCo with Scoverage and add ScalaTest (with its JUnit 5 bridge) as the test library across all modules. - -**Architecture:** Three build files are modified — the root for shared dependency versions, and each module for plugins, dependencies, and task wiring. No source files are created. The Scoverage Gradle plugin is applied per-module with its version hardcoded inline (Gradle resolves `plugins {}` before `rootProject.extra` is available). - -**Tech Stack:** Scala 3, Gradle (Kotlin DSL), ScalaTest 3.2.19, scalatestplus-junit-5-11 3.2.19.1, Scoverage Gradle plugin 8.1. - ---- - -## File Map - -| File | Change | -|---|---| -| `build.gradle.kts` (root) | Add `SCALATEST` and `SCALATESTPLUS_JUNIT5` version entries | -| `modules/core/build.gradle.kts` | Replace `jacoco` with `org.scoverage`; swap JUnit deps for ScalaTest; merge two `tasks.test {}` blocks | -| `modules/api/build.gradle.kts` | Same as core; also add missing `useJUnitPlatform()` | - ---- - -### Task 1: Add ScalaTest version entries to root build - -**Files:** -- Modify: `build.gradle.kts` (root) - -- [ ] **Step 1: Add version entries** - -Open `build.gradle.kts` at the root. The `versions` map currently looks like: - -```kotlin -val versions = mapOf( - "QUARKUS_SCALA3" to "1.0.0", - "SCALA3" to "3.5.1", - "SCALA_LIBRARY" to "2.13.18" -) -``` - -Add two entries so it becomes: - -```kotlin -val versions = mapOf( - "QUARKUS_SCALA3" to "1.0.0", - "SCALA3" to "3.5.1", - "SCALA_LIBRARY" to "2.13.18", - "SCALATEST" to "3.2.19", - "SCALATESTPLUS_JUNIT5" to "3.2.19.1" -) -``` - -- [ ] **Step 2: Verify the root build file parses** - -```bash -./gradlew help --quiet -``` - -Expected: exits 0 with no errors. - -- [ ] **Step 3: Commit** - -```bash -git add build.gradle.kts -git commit -m "build: add ScalaTest version entries to root versions map" -``` - ---- - -### Task 2: Migrate `modules/core` to ScalaTest + Scoverage - -**Files:** -- Modify: `modules/core/build.gradle.kts` - -- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`** - -In the `plugins {}` block, replace: -```kotlin -jacoco -``` -with: -```kotlin -id("org.scoverage") version "8.1" -``` - -The full plugins block should be: -```kotlin -plugins { - id("scala") - id("org.scoverage") version "8.1" - application -} -``` - -- [ ] **Step 2: Swap JUnit dependencies for ScalaTest** - -In the `dependencies {}` block, remove: -```kotlin -testImplementation(platform("org.junit:junit-bom:5.10.0")) -testImplementation("org.junit.jupiter:junit-jupiter") -testRuntimeOnly("org.junit.platform:junit-platform-launcher") -``` - -Add in their place: -```kotlin -testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") -testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") -``` - -- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring** - -The file currently has two separate `tasks.test {}` blocks and a `tasks.jacocoTestReport {}` block. Delete all three. Add the following single merged block **after** the `dependencies {}` block: - -```kotlin -tasks.test { - useJUnitPlatform() - finalizedBy(tasks.reportScoverage) -} -tasks.reportScoverage { - dependsOn(tasks.test) -} -``` - -- [ ] **Step 4: Run the tests** - -```bash -./gradlew :modules:core:test -``` - -Expected: BUILD SUCCESSFUL. (Zero tests is fine — there are no test files yet. The build must not fail with dependency resolution or plugin errors.) - -- [ ] **Step 5: Run the coverage report** - -```bash -./gradlew :modules:core:reportScoverage -``` - -Expected: BUILD SUCCESSFUL. A report is generated under `modules/core/build/reports/scoverage/`. - -- [ ] **Step 6: Commit** - -```bash -git add modules/core/build.gradle.kts -git commit -m "build(core): replace JaCoCo with Scoverage, add ScalaTest dependencies" -``` - ---- - -### Task 3: Migrate `modules/api` to ScalaTest + Scoverage - -**Files:** -- Modify: `modules/api/build.gradle.kts` - -- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`** - -In the `plugins {}` block, replace: -```kotlin -jacoco -``` -with: -```kotlin -id("org.scoverage") version "8.1" -``` - -The full plugins block should be: -```kotlin -plugins { - id("scala") - id("org.scoverage") version "8.1" -} -``` - -- [ ] **Step 2: Swap JUnit dependencies for ScalaTest** - -In the `dependencies {}` block, remove: -```kotlin -testImplementation(platform("org.junit:junit-bom:5.10.0")) -testImplementation("org.junit.jupiter:junit-jupiter") -testRuntimeOnly("org.junit.platform:junit-platform-launcher") -``` - -Add in their place: -```kotlin -testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") -testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") -``` - -- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring** - -The `modules/api` file also has two `tasks.test {}` blocks and a `jacocoTestReport` block. Delete all three. Add the following merged block **after** the `dependencies {}` block: - -```kotlin -tasks.test { - useJUnitPlatform() - finalizedBy(tasks.reportScoverage) -} -tasks.reportScoverage { - dependsOn(tasks.test) -} -``` - -> Note: `modules/api` did not previously have `useJUnitPlatform()` — it is being **added** here, not preserved. - -- [ ] **Step 4: Run the tests** - -```bash -./gradlew :modules:api:test -``` - -Expected: BUILD SUCCESSFUL. - -- [ ] **Step 5: Run the coverage report** - -```bash -./gradlew :modules:api:reportScoverage -``` - -Expected: BUILD SUCCESSFUL. A report is generated under `modules/api/build/reports/scoverage/`. - -- [ ] **Step 6: Commit** - -```bash -git add modules/api/build.gradle.kts -git commit -m "build(api): replace JaCoCo with Scoverage, add ScalaTest dependencies" -``` - ---- - -### Task 4: Full build verification - -- [ ] **Step 1: Run the full build** - -```bash -./gradlew build -``` - -Expected: BUILD SUCCESSFUL with no errors across all modules. - -- [ ] **Step 2: Confirm no JaCoCo references remain** - -```bash -grep -r "jacoco\|jacocoTestReport" --include="*.kts" . -``` - -Expected: no output (zero matches). diff --git a/docs/superpowers/plans/2026-03-23-chess-check-checkmate-stalemate.md b/docs/superpowers/plans/2026-03-23-chess-check-checkmate-stalemate.md deleted file mode 100644 index d44f78a..0000000 --- a/docs/superpowers/plans/2026-03-23-chess-check-checkmate-stalemate.md +++ /dev/null @@ -1,579 +0,0 @@ -# Chess Check / Checkmate / Stalemate Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add check detection, checkmate (win by opponent having no legal reply while in check), and stalemate (draw by opponent having no legal reply while not in check) to the chess game loop. - -**Architecture:** A new `GameRules` object owns all check-aware logic; the existing `MoveValidator` keeps its geometric-only contract unchanged. `GameController.processMove` calls `GameRules.gameStatus` after each move and returns new `MoveResult` variants (`MovedInCheck`, `Checkmate`, `Stalemate`). Terminal states reset the board. - -**Tech Stack:** Scala 3.5, ScalaTest (`AnyFunSuite with Matchers`), Gradle (`:modules:core:test`) - ---- - -## File Map - -| File | Action | Responsibility | -|---|---|---| -| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | **Create** | `isInCheck`, `legalMoves`, `gameStatus`, `PositionStatus` enum | -| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | **Create** | Unit tests for all three `GameRules` methods | -| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | **Modify** | Add `MovedInCheck`/`Checkmate`/`Stalemate` to `MoveResult`; wire `processMove` and `gameLoop` | -| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | **Modify** | Add `processMove` and `gameLoop` tests for the three new results | - ---- - -## Task 1: Create `GameRules` stub - -**Files:** -- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` - -- [ ] **Step 1: Create the stub file** - -```scala -package de.nowchess.chess.logic - -import de.nowchess.api.board.* - -enum PositionStatus: - case Normal, InCheck, Mated, Drawn - -object GameRules: - - /** True if `color`'s king is under attack on this board. */ - def isInCheck(board: Board, color: Color): Boolean = false - - /** All (from, to) moves for `color` that do not leave their own king in check. */ - def legalMoves(board: Board, color: Color): Set[(Square, Square)] = Set.empty - - /** Position status for the side whose turn it is (`color`). */ - def gameStatus(board: Board, color: Color): PositionStatus = PositionStatus.Normal -``` - -- [ ] **Step 2: Verify the project compiles** - -```bash -./gradlew :modules:core:compileScala -``` - -Expected: `BUILD SUCCESSFUL` - -- [ ] **Step 3: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala -git commit -m "feat: add GameRules stub with PositionStatus enum" -``` - ---- - -## Task 2: Write `GameRulesTest` (all tests must fail) - -**Files:** -- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` - -- [ ] **Step 1: Create the test file** - -```scala -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class GameRulesTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) - - // ──── isInCheck ────────────────────────────────────────────────────── - - test("isInCheck: king attacked by enemy rook on same rank"): - // White King E1, Black Rook A1 — rook slides along rank 1 to E1 - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.BlackRook - ) - GameRules.isInCheck(b, Color.White) shouldBe true - - test("isInCheck: king not attacked"): - // Black Rook A3 does not cover E1 - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R3) -> Piece.BlackRook - ) - GameRules.isInCheck(b, Color.White) shouldBe false - - test("isInCheck: no king on board returns false"): - val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook) - GameRules.isInCheck(b, Color.White) shouldBe false - - // ──── legalMoves ───────────────────────────────────────────────────── - - test("legalMoves: move that exposes own king to rook is excluded"): - // White King E1, White Rook E4 (pinned on E-file), Black Rook E8 - // Moving the White Rook off the E-file would expose the king - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.E, Rank.R4) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook - ) - val moves = GameRules.legalMoves(b, Color.White) - moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) - - test("legalMoves: move that blocks check is included"): - // White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5 - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R5) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook - ) - val moves = GameRules.legalMoves(b, Color.White) - moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5)) - - // ──── gameStatus ────────────────────────────────────────────────────── - - test("gameStatus: checkmate returns Mated"): - // White Qh8, Ka6; Black Ka8 - // Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position) - val b = board( - sq(File.H, Rank.R8) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Mated - - test("gameStatus: stalemate returns Drawn"): - // White Qb6, Kc6; Black Ka8 - // Black king has no legal moves and is not in check (spec-verified position) - val b = board( - sq(File.B, Rank.R6) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Drawn - - test("gameStatus: king in check with legal escape returns InCheck"): - // White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7 - val b = board( - sq(File.A, Rank.R8) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.InCheck - - test("gameStatus: normal starting position returns Normal"): - GameRules.gameStatus(Board.initial, Color.White) shouldBe PositionStatus.Normal -``` - -- [ ] **Step 2: Run the tests and confirm they all fail** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" -``` - -Expected: all 8 tests FAIL (stubs always return `false` / `Set.empty` / `Normal`) - -- [ ] **Step 3: Commit** - -```bash -git add modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala -git commit -m "test: add failing GameRulesTest for check/checkmate/stalemate" -``` - ---- - -## Task 3: Implement `GameRules` - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` - -- [ ] **Step 1: Replace the stub bodies with real implementations** - -```scala -package de.nowchess.chess.logic - -import de.nowchess.api.board.* - -enum PositionStatus: - case Normal, InCheck, Mated, Drawn - -object GameRules: - - def isInCheck(board: Board, color: Color): Boolean = - board.pieces - .collectFirst { case (sq, Piece(`color`, PieceType.King)) => sq } - .exists { kingSq => - board.pieces.exists { case (sq, piece) => - piece.color != color && - MoveValidator.legalTargets(board, sq).contains(kingSq) - } - } - - def legalMoves(board: Board, color: Color): Set[(Square, Square)] = - board.pieces - .collect { case (from, piece) if piece.color == color => from } - .flatMap { from => - MoveValidator.legalTargets(board, from) - .filter { to => - val (newBoard, _) = board.withMove(from, to) - !isInCheck(newBoard, color) - } - .map(to => from -> to) - } - .toSet - - def gameStatus(board: Board, color: Color): PositionStatus = - val moves = legalMoves(board, color) - val inCheck = isInCheck(board, color) - if moves.isEmpty && inCheck then PositionStatus.Mated - else if moves.isEmpty then PositionStatus.Drawn - else if inCheck then PositionStatus.InCheck - else PositionStatus.Normal -``` - -- [ ] **Step 2: Run the GameRules tests and confirm they all pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" -``` - -Expected: all 8 tests PASS - -- [ ] **Step 3: Run the full test suite to make sure nothing regressed** - -```bash -./gradlew :modules:core:test -``` - -Expected: `BUILD SUCCESSFUL`, all existing tests still pass - -- [ ] **Step 4: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala -git commit -m "feat: implement GameRules with isInCheck, legalMoves, gameStatus" -``` - ---- - -## Task 4: Add new `MoveResult` variants and stub `processMove` - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` - -- [ ] **Step 1: Add three new variants to `MoveResult` and import `GameRules`** - -In `GameController.scala`, update the `MoveResult` object and `processMove`. The new variants go after `Moved`. The import of `GameRules`/`PositionStatus` is added at the top. The stub `processMove` calls `GameRules.gameStatus` but always maps to `Moved` — this makes it compile while the new tests will fail: - -```scala -package de.nowchess.chess.controller - -import scala.io.StdIn -import de.nowchess.api.board.{Board, Color, Piece} -import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus} -import de.nowchess.chess.view.Renderer - -// --------------------------------------------------------------------------- -// Result ADT returned by the pure processMove function -// --------------------------------------------------------------------------- - -sealed trait MoveResult -object MoveResult: - case object Quit extends MoveResult - case class InvalidFormat(raw: String) extends MoveResult - case object NoPiece extends MoveResult - case object WrongColor extends MoveResult - case object IllegalMove extends MoveResult - case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult - case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult - case class Checkmate(winner: Color) extends MoveResult - case object Stalemate extends MoveResult - -// --------------------------------------------------------------------------- -// Controller -// --------------------------------------------------------------------------- - -object GameController: - - def processMove(board: Board, turn: Color, raw: String): MoveResult = - raw.trim match - case "quit" | "q" => - MoveResult.Quit - case trimmed => - Parser.parseMove(trimmed) match - case None => - MoveResult.InvalidFormat(trimmed) - case Some((from, to)) => - board.pieceAt(from) match - case None => - MoveResult.NoPiece - case Some(piece) if piece.color != turn => - MoveResult.WrongColor - case Some(_) => - if !MoveValidator.isLegal(board, from, to) then - MoveResult.IllegalMove - else - val (newBoard, captured) = board.withMove(from, to) - MoveResult.Moved(newBoard, captured, turn.opposite) // stub — Task 6 will fix - - def gameLoop(board: Board, turn: Color): Unit = - println() - print(Renderer.render(board)) - println(s"${turn.label}'s turn. Enter move: ") - val input = Option(StdIn.readLine()).getOrElse("quit").trim - processMove(board, turn, input) match - case MoveResult.Quit => - println("Game over. Goodbye!") - case MoveResult.InvalidFormat(raw) => - println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") - gameLoop(board, turn) - case MoveResult.NoPiece => - println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") - gameLoop(board, turn) - case MoveResult.WrongColor => - println(s"That is not your piece.") - gameLoop(board, turn) - case MoveResult.IllegalMove => - println(s"Illegal move.") - gameLoop(board, turn) - case MoveResult.Moved(newBoard, captured, newTurn) => - val prevTurn = newTurn.opposite - captured.foreach: cap => - val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) - println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") - gameLoop(newBoard, newTurn) - case MoveResult.MovedInCheck(newBoard, captured, newTurn) => // stub — Task 6 - gameLoop(newBoard, newTurn) - case MoveResult.Checkmate(winner) => // stub — Task 6 - gameLoop(Board.initial, Color.White) - case MoveResult.Stalemate => // stub — Task 6 - gameLoop(Board.initial, Color.White) -``` - -- [ ] **Step 2: Confirm everything still compiles and existing tests pass** - -```bash -./gradlew :modules:core:test -``` - -Expected: `BUILD SUCCESSFUL` — existing tests still pass, no compilation errors - -- [ ] **Step 3: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala -git commit -m "feat: add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch)" -``` - ---- - -## Task 5: Write new `GameControllerTest` cases (all must fail) - -**Files:** -- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - -- [ ] **Step 1: Append the following tests to the existing file** - -Add after the last existing test (the `gameLoop: capture` test). Add the `captureOutput` helper alongside `withInput`: - -```scala - // ──── helpers ──────────────────────────────────────────────────────── - - private def captureOutput(block: => Unit): String = - val out = java.io.ByteArrayOutputStream() - scala.Console.withOut(out)(block) - out.toString("UTF-8") - - // ──── processMove: check / checkmate / stalemate ───────────────────── - - test("processMove: legal move that delivers check returns MovedInCheck"): - // White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, putting Kh8 in check - // (Ra8 attacks along rank 8: b8..h8; king escapes to g7/g8/h7 — InCheck, not Mated) - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.A, Rank.R3) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "a1a8") match - case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black - case other => fail(s"Expected MovedInCheck, got $other") - - test("processMove: legal move that results in checkmate returns Checkmate"): - // White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1-h8) - // After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified) - // Note: Qa1 does NOT currently attack Ka8 (path along file A is blocked by Ka6) - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "a1h8") match - case MoveResult.Checkmate(winner) => winner shouldBe Color.White - case other => fail(s"Expected Checkmate(White), got $other") - - test("processMove: legal move that results in stalemate returns Stalemate"): - // White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6 - // After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified) - val b = Board(Map( - sq(File.B, Rank.R1) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "b1b6") match - case MoveResult.Stalemate => succeed - case other => fail(s"Expected Stalemate, got $other") - - // ──── gameLoop: check / checkmate / stalemate ───────────────────────── - - test("gameLoop: checkmate prints winner message and resets to new game"): - // Same position as checkmate processMove test above; after Qa1-Qh8 game resets - // Second move "quit" exits the new game cleanly - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1h8\nquit\n"): - GameController.gameLoop(b, Color.White) - output should include("Checkmate! White wins.") - - test("gameLoop: stalemate prints draw message and resets to new game"): - val b = Board(Map( - sq(File.B, Rank.R1) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("b1b6\nquit\n"): - GameController.gameLoop(b, Color.White) - output should include("Stalemate! The game is a draw.") - - test("gameLoop: MovedInCheck without capture prints check message"): - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.A, Rank.R3) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1a8\nquit\n"): - GameController.gameLoop(b, Color.White) - output should include("Black is in check!") - - test("gameLoop: MovedInCheck with capture prints both capture and check message"): - // White Rook A1 captures Black Pawn on A8, putting Black King (H8) in check - // Ra8 attacks rank 8 → Black Kh8 is in check; king can escape to g7/g8/h7 - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.A, Rank.R3) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackPawn, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1a8\nquit\n"): - GameController.gameLoop(b, Color.White) - output should include("captures") - output should include("Black is in check!") -``` - -- [ ] **Step 2: Run only the new tests and confirm they fail** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -``` - -Expected: the 7 new tests FAIL; the existing 17 tests PASS - -- [ ] **Step 3: Commit** - -```bash -git add modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala -git commit -m "test: add failing GameControllerTest cases for check/checkmate/stalemate" -``` - ---- - -## Task 6: Implement `processMove` dispatch and `gameLoop` branches - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` - -- [ ] **Step 1: Replace the stub `processMove` else-branch and the three stub `gameLoop` cases** - -Replace only the `else` branch inside `processMove` (keep everything else identical): - -```scala - else - val (newBoard, captured) = board.withMove(from, to) - GameRules.gameStatus(newBoard, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite) - case PositionStatus.Mated => MoveResult.Checkmate(turn) - case PositionStatus.Drawn => MoveResult.Stalemate -``` - -Replace the three stub `gameLoop` cases: - -```scala - case MoveResult.MovedInCheck(newBoard, captured, newTurn) => - val prevTurn = newTurn.opposite - captured.foreach: cap => - val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) - println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") - println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newTurn) - case MoveResult.Checkmate(winner) => - println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, Color.White) - case MoveResult.Stalemate => - println("Stalemate! The game is a draw.") - gameLoop(Board.initial, Color.White) -``` - -- [ ] **Step 2: Run all controller tests** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -``` - -Expected: all 24 tests PASS - -- [ ] **Step 3: Run the full test suite** - -```bash -./gradlew :modules:core:test -``` - -Expected: `BUILD SUCCESSFUL`, all tests pass - -- [ ] **Step 4: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala -git commit -m "feat: wire check/checkmate/stalemate into processMove and gameLoop" -``` - ---- - -## Task 7: Coverage check and final verification - -- [ ] **Step 1: Run the full build with coverage** - -```bash -./gradlew :modules:core:test -``` - -Expected: `BUILD SUCCESSFUL` - -- [ ] **Step 2: Check coverage gaps** - -```bash -python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml -``` - -Review output. If any newly added method falls below the thresholds from `CLAUDE.md` (branch ≥ 90%, line ≥ 95%, method ≥ 90%), add targeted tests to close the gaps before considering the task done. - -- [ ] **Step 3: Commit coverage fixes (if any)** - -```bash -git add -p -git commit -m "test: improve coverage for GameRules and GameController" -``` diff --git a/docs/superpowers/plans/2026-03-24-castling.md b/docs/superpowers/plans/2026-03-24-castling.md deleted file mode 100644 index 24991b7..0000000 --- a/docs/superpowers/plans/2026-03-24-castling.md +++ /dev/null @@ -1,1049 +0,0 @@ -# Castling Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement legal castling in the NowChess TUI engine by introducing a `GameContext` wrapper that threads castling-rights state through the engine. - -**Architecture:** A new `GameContext(board, whiteCastling, blackCastling)` in `modules/core` replaces `Board` in all engine signatures. `MoveValidator` gains context-aware overloads that include castling targets. `GameRules` and `GameController` are updated to pass `GameContext` through the whole move-processing pipeline. - -**Tech Stack:** Scala 3.5, ScalaTest (`AnyFunSuite with Matchers`), Gradle (`./gradlew :modules:core:test`) - -**TDD discipline:** Every task follows the same cycle — write one failing test, confirm it fails, write the minimum code to make it pass, confirm it passes, commit. Never write implementation before a failing test. - ---- - -## File Map - -| Action | Path | Responsibility | -|--------|------|----------------| -| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` | `CastleSide` enum, `GameContext` case class, `withCastle` Board extension | -| **Create** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala` | Tests for `GameContext` methods and `withCastle` | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` | Add `castlingTargets`, `isCastle`, `castleSide`, `isAttackedBy`; add context-aware `legalTargets(ctx,from)` and `isLegal(ctx,from,to)` overloads | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` | New castling scenario tests | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | Update `legalMoves` and `gameStatus` to accept `GameContext` | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | Update existing tests; add castling and false-stalemate tests | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Update `MoveResult`, `processMove`, `gameLoop` to use `GameContext`; add castle detection, execution, and rights revocation | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Update existing tests; add castling and rights-revocation tests | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` | Use `GameContext.initial` | - ---- - -## Task 1: Create `GameContext` - -**Files:** -- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` -- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala` - -- [ ] **Step 1.1: Write failing tests** - -Create `GameContextTest.scala`: - -```scala -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import de.nowchess.api.game.CastlingRights -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class GameContextTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) - - test("GameContext.initial has Board.initial and CastlingRights.Both for both sides"): - GameContext.initial.board shouldBe Board.initial - GameContext.initial.whiteCastling shouldBe CastlingRights.Both - GameContext.initial.blackCastling shouldBe CastlingRights.Both - - test("castlingFor returns white rights for Color.White"): - GameContext.initial.castlingFor(Color.White) shouldBe CastlingRights.Both - - test("castlingFor returns black rights for Color.Black"): - GameContext.initial.castlingFor(Color.Black) shouldBe CastlingRights.Both - - test("withUpdatedRights updates white castling without touching black"): - val ctx = GameContext.initial.withUpdatedRights(Color.White, CastlingRights.None) - ctx.whiteCastling shouldBe CastlingRights.None - ctx.blackCastling shouldBe CastlingRights.Both - - test("withUpdatedRights updates black castling without touching white"): - val ctx = GameContext.initial.withUpdatedRights(Color.Black, CastlingRights.None) - ctx.blackCastling shouldBe CastlingRights.None - ctx.whiteCastling shouldBe CastlingRights.Both - - // ── withCastle ─────────────────────────────────────────────────────────────── - - test("withCastle: white kingside — king e1→g1, rook h1→f1"): - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook - ) - val after = b.withCastle(Color.White, CastleSide.Kingside) - after.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) - after.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) - after.pieceAt(sq(File.E, Rank.R1)) shouldBe None - after.pieceAt(sq(File.H, Rank.R1)) shouldBe None - - test("withCastle: white queenside — king e1→c1, rook a1→d1"): - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook - ) - val after = b.withCastle(Color.White, CastleSide.Queenside) - after.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) - after.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) - after.pieceAt(sq(File.E, Rank.R1)) shouldBe None - after.pieceAt(sq(File.A, Rank.R1)) shouldBe None - - test("withCastle: black kingside — king e8→g8, rook h8→f8"): - val b = board( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.BlackRook - ) - val after = b.withCastle(Color.Black, CastleSide.Kingside) - after.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing) - after.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook) - after.pieceAt(sq(File.E, Rank.R8)) shouldBe None - after.pieceAt(sq(File.H, Rank.R8)) shouldBe None - - test("withCastle: black queenside — king e8→c8, rook a8→d8"): - val b = board( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.A, Rank.R8) -> Piece.BlackRook - ) - val after = b.withCastle(Color.Black, CastleSide.Queenside) - after.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing) - after.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook) - after.pieceAt(sq(File.E, Rank.R8)) shouldBe None - after.pieceAt(sq(File.A, Rank.R8)) shouldBe None -``` - -- [ ] **Step 1.2: Run — expect compilation failure** - -```bash -./gradlew :modules:core:test 2>&1 | tail -20 -``` -Expected: compilation error — `GameContext` / `CastleSide` not found. - -- [ ] **Step 1.3: Implement `GameContext.scala`** - -Create `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala`: - -```scala -package de.nowchess.chess.logic - -import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} -import de.nowchess.api.game.CastlingRights - -enum CastleSide: - case Kingside, Queenside - -case class GameContext( - board: Board, - whiteCastling: CastlingRights, - blackCastling: CastlingRights -): - def castlingFor(color: Color): CastlingRights = - if color == Color.White then whiteCastling else blackCastling - - def withUpdatedRights(color: Color, rights: CastlingRights): GameContext = - if color == Color.White then copy(whiteCastling = rights) - else copy(blackCastling = rights) - -object GameContext: - /** Convenience constructor for test boards: no castling rights on either side. */ - def apply(board: Board): GameContext = - GameContext(board, CastlingRights.None, CastlingRights.None) - - val initial: GameContext = - GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both) - -extension (b: Board) - def withCastle(color: Color, side: CastleSide): Board = - val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match - case (Color.White, CastleSide.Kingside) => - (Square(File.E, Rank.R1), Square(File.G, Rank.R1), - Square(File.H, Rank.R1), Square(File.F, Rank.R1)) - case (Color.White, CastleSide.Queenside) => - (Square(File.E, Rank.R1), Square(File.C, Rank.R1), - Square(File.A, Rank.R1), Square(File.D, Rank.R1)) - case (Color.Black, CastleSide.Kingside) => - (Square(File.E, Rank.R8), Square(File.G, Rank.R8), - Square(File.H, Rank.R8), Square(File.F, Rank.R8)) - case (Color.Black, CastleSide.Queenside) => - (Square(File.E, Rank.R8), Square(File.C, Rank.R8), - Square(File.A, Rank.R8), Square(File.D, Rank.R8)) - val king = Piece(color, PieceType.King) - val rook = Piece(color, PieceType.Rook) - Board(b.pieces.removed(kingFrom).removed(rookFrom) - .updated(kingTo, king).updated(rookTo, rook)) -``` - -- [ ] **Step 1.4: Run — expect all GameContext tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameContextTest" 2>&1 | tail -20 -``` -Expected: 9 tests, 9 passed. - -- [ ] **Step 1.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala -git commit -m "feat: add GameContext, CastleSide, and Board.withCastle" -``` - ---- - -## Task 2: Extend `MoveValidator` with castling logic - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` - -- [ ] **Step 2.1: Write failing castling tests** - -Add the following to the bottom of `MoveValidatorTest.scala`. Also add these imports at the top of the file: -```scala -import de.nowchess.api.game.CastlingRights -import de.nowchess.chess.logic.{GameContext, CastleSide} -``` - -```scala - // ──── castlingTargets ──────────────────────────────────────────────── - - private def ctxWithRights( - entries: (Square, Piece)* - )(white: CastlingRights = CastlingRights.Both, - black: CastlingRights = CastlingRights.Both - ): GameContext = - GameContext(Board(entries.toMap), white, black) - - test("castlingTargets: white kingside available when all conditions met"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.G, Rank.R1)) - - test("castlingTargets: white queenside available when all conditions met"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.C, Rank.R1)) - - test("castlingTargets: black kingside available when all conditions met"): - val ctx = ctxWithRights( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.BlackRook, - sq(File.H, Rank.R1) -> Piece.WhiteKing - )() - MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.G, Rank.R8)) - - test("castlingTargets: black queenside available when all conditions met"): - val ctx = ctxWithRights( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.A, Rank.R8) -> Piece.BlackRook, - sq(File.H, Rank.R1) -> Piece.WhiteKing - )() - MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.C, Rank.R8)) - - test("castlingTargets: blocked when transit square is occupied"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.F, Rank.R1) -> Piece.WhiteBishop, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) - - test("castlingTargets: blocked when king is in check"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty - - test("castlingTargets: blocked when transit square f1 is attacked"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.F, Rank.R8) -> Piece.BlackRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) - - test("castlingTargets: blocked when landing square g1 is attacked"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.G, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) - - test("castlingTargets: blocked when kingSide right is false"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )(white = CastlingRights(kingSide = false, queenSide = true)) - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) - - test("castlingTargets: blocked when queenSide right is false"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )(white = CastlingRights(kingSide = true, queenSide = false)) - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.C, Rank.R1) - - test("castlingTargets: blocked when relevant rook is not on home square"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.G, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) - - // ──── context-aware legalTargets includes castling ──────────────────── - - test("legalTargets(ctx, from): king on e1 includes g1 when castling available"): - val ctx = ctxWithRights( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )() - MoveValidator.legalTargets(ctx, sq(File.E, Rank.R1)) should contain(sq(File.G, Rank.R1)) - - test("legalTargets(ctx, from): non-king pieces unchanged by context"): - val ctx = ctxWithRights( - sq(File.D, Rank.R4) -> Piece.WhiteBishop, - sq(File.H, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R1) -> Piece.WhiteKing - )() - MoveValidator.legalTargets(ctx, sq(File.D, Rank.R4)) shouldBe - MoveValidator.legalTargets(ctx.board, sq(File.D, Rank.R4)) -``` - -- [ ] **Step 2.2: Run — expect compilation failure** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 -``` -Expected: compilation error — `castlingTargets` / `legalTargets(ctx, …)` not found. - -- [ ] **Step 2.3: Implement castling logic in `MoveValidator.scala`** - -Append the following methods inside `object MoveValidator`, after the existing `kingTargets` method: - -```scala - // ── Castling helpers ──────────────────────────────────────────────────────── - - private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean = - board.pieces.exists { case (from, piece) => - piece.color == attackerColor && legalTargets(board, from).contains(sq) - } - - def isCastle(board: Board, from: Square, to: Square): Boolean = - board.pieceAt(from).exists(_.pieceType == PieceType.King) && - math.abs(to.file.ordinal - from.file.ordinal) == 2 - - def castleSide(from: Square, to: Square): CastleSide = - if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside - - def castlingTargets(ctx: GameContext, color: Color): Set[Square] = - val rights = ctx.castlingFor(color) - val rank = if color == Color.White then Rank.R1 else Rank.R8 - val kingSq = Square(File.E, rank) - val enemy = color.opposite - - if ctx.board.pieceAt(kingSq) != Some(Piece(color, PieceType.King)) then return Set.empty - if GameRules.isInCheck(ctx.board, color) then return Set.empty - - var result = Set.empty[Square] - - if rights.kingSide then - val rookSq = Square(File.H, rank) - val transit = List(Square(File.F, rank), Square(File.G, rank)) - if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) && - transit.forall(s => ctx.board.pieceAt(s).isEmpty) && - !transit.exists(s => isAttackedBy(ctx.board, s, enemy)) then - result += Square(File.G, rank) - - if rights.queenSide then - val rookSq = Square(File.A, rank) - val emptySquares = List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)) - val transitSqs = List(Square(File.D, rank), Square(File.C, rank)) - if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) && - emptySquares.forall(s => ctx.board.pieceAt(s).isEmpty) && - !transitSqs.exists(s => isAttackedBy(ctx.board, s, enemy)) then - result += Square(File.C, rank) - - result - - def legalTargets(ctx: GameContext, from: Square): Set[Square] = - ctx.board.pieceAt(from) match - case Some(piece) if piece.pieceType == PieceType.King => - legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color) - case _ => - legalTargets(ctx.board, from) - - def isLegal(ctx: GameContext, from: Square, to: Square): Boolean = - legalTargets(ctx, from).contains(to) -``` - -- [ ] **Step 2.4: Run — expect all MoveValidator tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 -``` -Expected: all tests pass (existing + 13 new). - -- [ ] **Step 2.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala -git commit -m "feat: add castling logic to MoveValidator (castlingTargets + context-aware overloads)" -``` - ---- - -## Task 3: Migrate `GameRules.legalMoves` to `GameContext` - -Only the signature and internal call changes here. Castling inclusion comes in Task 4. - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` - -- [ ] **Step 3.1: Update the two existing `legalMoves` call sites in `GameRulesTest.scala` to use `GameContext`** - -Add import at the top: -```scala -import de.nowchess.chess.logic.GameContext -``` - -Add a private helper and update the two legalMoves tests: -```scala - /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ - private def ctx(entries: (Square, Piece)*): GameContext = GameContext(Board(entries.toMap)) -``` - -Change: -```scala - // legalMoves test 1 - val moves = GameRules.legalMoves(b, Color.White) // old - // → replace `b` with the ctx helper: - val moves = GameRules.legalMoves(ctx( // new - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.E, Rank.R4) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook - ), Color.White) -``` - -Similarly update the second legalMoves test. The `board(...)` helper is still used for `isInCheck` tests (they keep `Board`). Do not touch `gameStatus` tests yet. - -- [ ] **Step 3.2: Run — expect compilation failure** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: compilation error — `legalMoves` does not accept `GameContext`. - -- [ ] **Step 3.3: Update `legalMoves` signature in `GameRules.scala`** - -Change the signature and internal call (no castling logic yet — use the board-only `legalTargets`): - -```scala - def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] = - ctx.board.pieces - .collect { case (from, piece) if piece.color == color => from } - .flatMap { from => - MoveValidator.legalTargets(ctx.board, from) // board-only for now - .filter { to => - val (newBoard, _) = ctx.board.withMove(from, to) - !isInCheck(newBoard, color) - } - .map(to => from -> to) - } - .toSet -``` - -- [ ] **Step 3.4: Run — expect all existing GameRules tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: all existing tests pass. - -- [ ] **Step 3.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala -git commit -m "refactor: migrate GameRules.legalMoves signature to GameContext" -``` - ---- - -## Task 4: Include castling in `GameRules.legalMoves` - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` - -- [ ] **Step 4.1: Write two failing castling tests in `GameRulesTest.scala`** - -Add import at the top: -```scala -import de.nowchess.api.game.CastlingRights -``` - -Append to the file: -```scala - test("legalMoves: includes castling destination when available"): - val c = GameContext( - board = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - ), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameRules.legalMoves(c, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) - - test("legalMoves: excludes castling when king is in check"): - val c = GameContext( - board = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - ), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameRules.legalMoves(c, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) -``` - -- [ ] **Step 4.2: Run — expect the two new tests to fail** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: 2 failures — castling destination not included in `legalMoves`. - -- [ ] **Step 4.3: Update `legalMoves` to use context-aware `legalTargets` and handle castle board simulation** - -In `GameRules.scala`, replace the `MoveValidator.legalTargets(ctx.board, from)` call with the context-aware overload, and use `withCastle` when simulating castle moves: - -```scala - def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] = - ctx.board.pieces - .collect { case (from, piece) if piece.color == color => from } - .flatMap { from => - MoveValidator.legalTargets(ctx, from) // context-aware: includes castling - .filter { to => - val newBoard = - if MoveValidator.isCastle(ctx.board, from, to) then - ctx.board.withCastle(color, MoveValidator.castleSide(from, to)) - else - ctx.board.withMove(from, to)._1 - !isInCheck(newBoard, color) - } - .map(to => from -> to) - } - .toSet -``` - -- [ ] **Step 4.4: Run — expect all GameRules tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: all tests pass (existing + 2 new). - -- [ ] **Step 4.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala -git commit -m "feat: include castling moves in GameRules.legalMoves" -``` - ---- - -## Task 5: Migrate `GameRules.gameStatus` to `GameContext` and add false-stalemate test - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` - -- [ ] **Step 5.1: Update existing `gameStatus` call sites and add false-stalemate test in `GameRulesTest.scala`** - -Change all four existing `GameRules.gameStatus(b, ...)` calls to `GameRules.gameStatus(ctx(...), ...)` using the `ctx` helper (which wraps with no castling rights — appropriate for these non-castling positions). - -Then append the new false-stalemate test: - -```scala - test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"): - // White King e1, Rook h1 (kingside castling available). - // Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both, - // f1 attacked by f2. King cannot move to any adjacent square without entering - // an attacked square or an enemy piece. Only legal move: castle to g1. - val c = GameContext( - board = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.D, Rank.R2) -> Piece.BlackRook, - sq(File.F, Rank.R2) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - ), - whiteCastling = CastlingRights(kingSide = true, queenSide = false), - blackCastling = CastlingRights.None - ) - GameRules.gameStatus(c, Color.White) shouldBe PositionStatus.Normal -``` - -- [ ] **Step 5.2: Run — expect compilation failure on `gameStatus(b, ...)` + the new test failing** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: compilation errors and/or test failures. - -- [ ] **Step 5.3: Update `gameStatus` in `GameRules.scala`** - -```scala - def gameStatus(ctx: GameContext, color: Color): PositionStatus = - val moves = legalMoves(ctx, color) - val inCheck = isInCheck(ctx.board, color) - if moves.isEmpty && inCheck then PositionStatus.Mated - else if moves.isEmpty then PositionStatus.Drawn - else if inCheck then PositionStatus.InCheck - else PositionStatus.Normal -``` - -- [ ] **Step 5.4: Run — expect all GameRules tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 -``` -Expected: all tests pass (existing + 3 new). - -- [ ] **Step 5.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala -git commit -m "feat: migrate GameRules.gameStatus to GameContext; add false-stalemate test" -``` - ---- - -## Task 6: Migrate `GameController` signatures (no castling logic yet) - -Thread `GameContext` through the signatures. No castle detection or rights revocation — just the type migration. All existing tests must stay green. - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - -- [ ] **Step 6.1: Update `GameControllerTest.scala` to use `GameContext`** - -Add imports: -```scala -import de.nowchess.api.game.CastlingRights -import de.nowchess.chess.logic.{GameContext, CastleSide} -``` - -Make these changes throughout the test file — **do not add any new tests yet**: - -1. `val initial = Board.initial` → `val initial = GameContext.initial` -2. Every `Board(Map(...))` test board → `GameContext(Board(Map(...)))` (no-rights convenience constructor) -3. `GameController.processMove(board, ...)` → `GameController.processMove(ctx, ...)` -4. `GameController.gameLoop(Board.initial, ...)` → `GameController.gameLoop(GameContext.initial, ...)` -5. `MoveResult.Moved(newBoard, ...)` → `MoveResult.Moved(newCtx, ...)`; then access board as `newCtx.board` -6. `MoveResult.MovedInCheck(newBoard, ...)` → `MoveResult.MovedInCheck(newCtx, ...)` - -- [ ] **Step 6.2: Run — expect compilation failures** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 -``` -Expected: compilation errors — `processMove` / `gameLoop` still take `Board`. - -- [ ] **Step 6.3: Migrate `GameController.scala` signatures (no castling logic)** - -Update imports, `MoveResult` variants, `processMove`, and `gameLoop`: - -**`MoveResult` changes** — rename `newBoard` → `newCtx`: -```scala - case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult - case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult -``` - -**`processMove`** — replace `(board: Board, turn: Color, raw: String)` with `(ctx: GameContext, turn: Color, raw: String)`. The internal logic stays the same but uses `ctx.board` and returns `ctx.copy(board = newBoard)`: - -```scala - def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult = - raw.trim match - case "quit" | "q" => MoveResult.Quit - case trimmed => - Parser.parseMove(trimmed) match - case None => MoveResult.InvalidFormat(trimmed) - case Some((from, to)) => - ctx.board.pieceAt(from) match - case None => MoveResult.NoPiece - case Some(piece) if piece.color != turn => MoveResult.WrongColor - case Some(_) => - if !MoveValidator.isLegal(ctx, from, to) then - MoveResult.IllegalMove - else - val (newBoard, captured) = ctx.board.withMove(from, to) - val newCtx = ctx.copy(board = newBoard) - GameRules.gameStatus(newCtx, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite) - case PositionStatus.Mated => MoveResult.Checkmate(turn) - case PositionStatus.Drawn => MoveResult.Stalemate -``` - -**`gameLoop`** — replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`: -- `Renderer.render(board)` → `Renderer.render(ctx.board)` -- recursive calls use `newCtx` -- reset on game-over uses `GameContext.initial` - -- [ ] **Step 6.4: Run — expect all existing controller tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.*" 2>&1 | tail -20 -``` -Expected: all previously passing tests still pass. Note: castling inputs like `e1g1` still return `IllegalMove` at this point — that is correct and expected (castle logic is added in Task 7). - -- [ ] **Step 6.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ - modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala -git commit -m "refactor: migrate GameController to GameContext (signatures only)" -``` - ---- - -## Task 7: Add castling execution to `processMove` - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - -- [ ] **Step 7.1: Write two failing castling tests in `GameControllerTest.scala`** - -Append: -```scala - // ──── castling execution ───────────────────────────────────────────── - - test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1g1") match - case MoveResult.Moved(newCtx, captured, newTurn) => - newCtx.board.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) - newCtx.board.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) - newCtx.board.pieceAt(sq(File.E, Rank.R1)) shouldBe None - newCtx.board.pieceAt(sq(File.H, Rank.R1)) shouldBe None - captured shouldBe None - newTurn shouldBe Color.Black - case other => fail(s"Expected Moved, got $other") - - test("processMove: e1c1 returns Moved with king on c1 and rook on d1"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1c1") match - case MoveResult.Moved(newCtx, _, _) => - newCtx.board.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) - newCtx.board.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) - case other => fail(s"Expected Moved, got $other") -``` - -- [ ] **Step 7.2: Run — expect the two new tests to fail** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 -``` -Expected: 2 failures — `e1g1` and `e1c1` return `IllegalMove` (castle not yet executed). - -- [ ] **Step 7.3: Add castle detection and execution to `processMove` in `GameController.scala`** - -In the `Some(_) =>` branch of `processMove`, replace `ctx.board.withMove(from, to)` with castle-aware logic: - -```scala - case Some(_) => - if !MoveValidator.isLegal(ctx, from, to) then - MoveResult.IllegalMove - else - val castleOpt = if MoveValidator.isCastle(ctx.board, from, to) - then Some(MoveValidator.castleSide(from, to)) - else None - val (newBoard, captured) = castleOpt match - case Some(side) => (ctx.board.withCastle(turn, side), None) - case None => ctx.board.withMove(from, to) - val newCtx = ctx.copy(board = newBoard) - GameRules.gameStatus(newCtx, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite) - case PositionStatus.Mated => MoveResult.Checkmate(turn) - case PositionStatus.Drawn => MoveResult.Stalemate -``` - -- [ ] **Step 7.4: Run — expect all tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 -``` -Expected: all tests pass. - -- [ ] **Step 7.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ - modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala -git commit -m "feat: add castling execution to processMove" -``` - ---- - -## Task 8: Add rights revocation to `processMove` - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - -- [ ] **Step 8.1: Write failing rights-revocation tests** - -Append to `GameControllerTest.scala`: - -```scala - // ──── rights revocation ────────────────────────────────────────────── - - test("processMove: e1g1 revokes both white castling rights"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1g1") match - case MoveResult.Moved(newCtx, _, _) => - newCtx.whiteCastling shouldBe CastlingRights.None - case other => fail(s"Expected Moved, got $other") - - test("processMove: moving rook from h1 revokes white kingside right"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "h1h4") match - case MoveResult.Moved(newCtx, _, _) => - newCtx.whiteCastling.kingSide shouldBe false - newCtx.whiteCastling.queenSide shouldBe true - case other => fail(s"Expected Moved, got $other") - - test("processMove: moving king from e1 revokes both white rights"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1e2") match - case MoveResult.Moved(newCtx, _, _) => - newCtx.whiteCastling shouldBe CastlingRights.None - case other => fail(s"Expected Moved, got $other") - - test("processMove: enemy capture on h1 revokes white kingside right"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R2) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.Black, "h2h1") match - case MoveResult.Moved(newCtx, _, _) => - newCtx.whiteCastling.kingSide shouldBe false - case other => fail(s"Expected Moved, got $other") - - test("processMove: castle attempt when rights revoked returns IllegalMove"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.None, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove - - test("processMove: castle attempt when rook not on home square returns IllegalMove"): - val ctx = GameContext( - board = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.G, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )), - whiteCastling = CastlingRights.Both, - blackCastling = CastlingRights.None - ) - GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove -``` - -- [ ] **Step 8.2: Run — expect the revocation tests to fail** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 -``` -Expected: revocation tests fail (rights unchanged in `newCtx`). Castle-attempt tests may already pass. - -- [ ] **Step 8.3: Add `applyRightsRevocation` to `GameController.scala`** - -Add a private helper and call it from `processMove`: - -```scala - private def applyRightsRevocation( - ctx: GameContext, - turn: Color, - from: Square, - to: Square, - castle: Option[CastleSide] - ): GameContext = - // Step 1: Revoke all rights for a castling move (idempotent with step 2) - val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None)) - - // Step 2: Source-square revocation - val ctx1 = from match - case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None) - case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None) - case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false)) - case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false)) - case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false)) - case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false)) - case _ => ctx0 - - // Step 3: Destination-square revocation (enemy captures a rook on its home square) - to match - case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false)) - case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false)) - case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false)) - case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false)) - case _ => ctx1 -``` - -In `processMove`, replace `val newCtx = ctx.copy(board = newBoard)` with: - -```scala - val newCtx = applyRightsRevocation( - ctx.copy(board = newBoard), turn, from, to, castleOpt - ) -``` - -- [ ] **Step 8.4: Run — expect all controller tests to pass** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 -``` -Expected: all tests pass. - -- [ ] **Step 8.5: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ - modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala -git commit -m "feat: add castling rights revocation to processMove" -``` - ---- - -## Task 9: Update `Main` and verify full build - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/Main.scala` - -- [ ] **Step 9.1: Update `Main.scala`** - -```scala -package de.nowchess.chess - -import de.nowchess.api.board.Color -import de.nowchess.chess.controller.GameController -import de.nowchess.chess.logic.GameContext - -object Main { - def main(args: Array[String]): Unit = - println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") - GameController.gameLoop(GameContext.initial, Color.White) -} -``` - -- [ ] **Step 9.2: Run the full build** - -```bash -./gradlew :modules:core:build 2>&1 | tail -20 -``` -Expected: `BUILD SUCCESSFUL` - -- [ ] **Step 9.3: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/Main.scala -git commit -m "feat: update Main to use GameContext.initial" -``` - ---- - -## Done - -All nine tasks complete. The engine now supports legal castling: - -- White/Black kingside (`e1g1` / `e8g8`) and queenside (`e1c1` / `e8c8`) -- All six legality conditions enforced (rights flags, home squares, empty transit, king not in check, transit squares not attacked) -- Rights revoked on king moves, rook moves, castle moves, and enemy rook captures -- Stalemate/checkmate detection correctly includes castling as a legal move diff --git a/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md b/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md deleted file mode 100644 index 53db28e..0000000 --- a/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md +++ /dev/null @@ -1,85 +0,0 @@ -# Design: Add ScalaTest + Replace JaCoCo with Scoverage - -**Date:** 2026-03-22 -**Status:** Approved - -## Summary - -Replace the current JUnit-only test setup and JaCoCo coverage with ScalaTest (via its JUnit 5 bridge) and Scoverage across both `modules/core` and `modules/api`. - -## Motivation - -- The CLAUDE.md working agreement prescribes `AnyFunSuite with Matchers with JUnitSuiteLike` as the unit test style, which requires ScalaTest. -- Scoverage is the standard Scala code coverage tool and understands Scala semantics; JaCoCo's JVM bytecode instrumentation is less accurate for Scala code. - -## Scope - -Two modules are affected: `modules/core` and `modules/api`. The root `build.gradle.kts` is updated for shared dependency versions only. - -## Changes - -### Root `build.gradle.kts` - -Add to the `versions` map (dependency versions only — plugin version is hardcoded per module, see note below): -- `SCALATEST` → `3.2.19` -- `SCALATESTPLUS_JUNIT5` → `3.2.19.1` - -> **Note on plugin versioning:** Gradle resolves the `plugins {}` block before `rootProject.extra` is available, so the Scoverage plugin version (`8.1`) must be declared inline in each module's `plugins {}` block. It cannot be read from the root versions map. - -### `modules/core/build.gradle.kts` and `modules/api/build.gradle.kts` - -Both modules require the same set of changes. Both currently have **two separate `tasks.test {}` blocks** that must be merged into one. - -**Plugins block:** -- Remove `jacoco` -- Add `id("org.scoverage") version "8.1"` - -**Dependencies block:** -- Remove `testImplementation(platform("org.junit:junit-bom:5.10.0"))` -- Remove `testImplementation("org.junit.jupiter:junit-jupiter")` -- Remove `testRuntimeOnly("org.junit.platform:junit-platform-launcher")` -- Add `testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")` -- Add `testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")` - -**Task wiring — merge both `tasks.test {}` blocks into one and replace jacoco wiring:** - -Both `modules/core` and `modules/api` currently have two `tasks.test {}` blocks. Delete both and replace with the following single merged block placed **after** the `dependencies {}` block (conventional position): - -```kotlin -tasks.test { - useJUnitPlatform() // required — scalatestplus JUnit 5 bridge relies on this - finalizedBy(tasks.reportScoverage) -} -tasks.reportScoverage { - dependsOn(tasks.test) -} -``` - -> Note: `modules/api` does not currently have `useJUnitPlatform()` — it must be **added** (not just kept) in the merged block. - -Remove the `jacocoTestReport` task block entirely from both modules. - -**Task name confirmation:** The Scoverage Gradle plugin 8.1 registers `reportScoverage` as the HTML report task. - -## Versions - -| Artifact | Version | Notes | -|---|---|---| -| `org.scalatest:scalatest_3` | 3.2.19 | Core ScalaTest for Scala 3 | -| `org.scalatestplus:junit-5-11_3` | 3.2.19.1 | JUnit 5.11 runner bridge; `.1` = build 1 | -| Scoverage Gradle plugin | 8.1 | Hardcoded inline in `plugins {}` block | - -## Testing the Change - -After applying: -1. `./gradlew :modules:core:test` and `./gradlew :modules:api:test` must pass (green, even with zero test files). -2. `./gradlew :modules:core:reportScoverage` must produce a coverage report. -3. `./gradlew build` must be fully green. - -## Files Modified - -- `build.gradle.kts` (root) — add two version entries -- `modules/core/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, replace jacoco wiring -- `modules/api/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, add `useJUnitPlatform()`, replace jacoco wiring - -No new source files are created. diff --git a/docs/superpowers/specs/2026-03-23-chess-check-checkmate-stalemate-design.md b/docs/superpowers/specs/2026-03-23-chess-check-checkmate-stalemate-design.md deleted file mode 100644 index 690fe2a..0000000 --- a/docs/superpowers/specs/2026-03-23-chess-check-checkmate-stalemate-design.md +++ /dev/null @@ -1,169 +0,0 @@ -# Chess Check / Checkmate / Stalemate — Design Spec - -**Date:** 2026-03-23 -**Status:** Approved - ---- - -## Scope - -Implement check detection, checkmate (win condition), and stalemate (draw) on top of the existing normal-move rules. En passant, castling, and pawn promotion are **out of scope** for this iteration. - ---- - -## Architecture - -### New: `GameRules` object - -**File:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` - -Owns all check-aware game logic. `MoveValidator` retains its documented geometric-only contract ("ignoring check/pin"). - -``` -GameRules - isInCheck(board, color): Boolean - legalMoves(board, color): Set[(Square, Square)] - gameStatus(board, color): PositionStatus -``` - -#### `isInCheck(board, color)` - -Finds the king square for `color` by scanning `board.pieces` for a `Piece(color, PieceType.King)`. If no king is found (constructed/test boards), returns `false`. - -Then checks whether any enemy piece's `MoveValidator.legalTargets` contains that square. This works correctly for all piece types, including the king: `kingTargets` returns the squares the king can move to, which are identical to the squares the king attacks, so using `legalTargets` for attack detection is correct by design. - -Returns `true` if the king square is covered by at least one enemy piece. - - -#### `legalMoves(board, color)` - -1. Filter `board.pieces` to entries where `piece.color == color`. -2. For each such `(from, piece)`, call `MoveValidator.legalTargets(board, from)` to get geometric candidates. -3. For each candidate `to`, apply `board.withMove(from, to)` to get `newBoard`. -4. Keep only moves where `isInCheck(newBoard, color)` is `false` (i.e., the move does not leave own king in check). -5. Return the full set of `(from, to)` pairs that survive this filter. - -#### `gameStatus(board, color)` - -Returns a `PositionStatus` enum value based on `legalMoves(board, color)` and `isInCheck(board, color)`: - -- `Mated` — `legalMoves` is empty **and** king is in check → the side to move has been checkmated -- `Drawn` — `legalMoves` is empty **and** king is **not** in check → stalemate (draw) -- `InCheck` — `legalMoves` is non-empty **and** king is in check → game continues under check -- `Normal` — otherwise - -#### Local `PositionStatus` enum - -Defined in `GameRules.scala`. Names are intentionally distinct from `MoveResult` variants to avoid unqualified-name collisions in `GameController.scala`: - -```scala -enum PositionStatus: - case Normal, InCheck, Mated, Drawn -``` - ---- - -### Modified: `MoveResult` (in `GameController.scala`) - -Three new variants; existing variants are unchanged: - -| Variant | When used | -|---|---| -| `MovedInCheck(newBoard, captured, newTurn)` | Move was legal; opponent is now in check but has legal replies | -| `Checkmate(winner: Color)` | Move was legal; opponent is `Mated` → `winner` is the side that just moved | -| `Stalemate` | Move was legal; opponent is `Drawn` (no legal reply, not in check) | - -`Moved` continues to be used when `gameStatus` returns `Normal`. - ---- - -### Modified: `GameController.processMove` - -After computing `(newBoard, captured)` from `board.withMove`: - -1. Call `GameRules.gameStatus(newBoard, newTurn)`. -2. Map to the appropriate `MoveResult`: - -``` -PositionStatus.Normal → Moved(newBoard, captured, newTurn) -PositionStatus.InCheck → MovedInCheck(newBoard, captured, newTurn) -PositionStatus.Mated → Checkmate(turn) // turn = the side that just moved -PositionStatus.Drawn → Stalemate -``` - ---- - -### Modified: `GameController.gameLoop` - -**New terminal branches** (both print a message then restart): - -- `Checkmate(winner)` → print `"Checkmate! {winner.label} wins."`, then recurse with `(Board.initial, Color.White)` -- `Stalemate` → print `"Stalemate! The game is a draw."`, then recurse with `(Board.initial, Color.White)` - -**New non-terminal branch:** - -- `MovedInCheck(newBoard, captured, newTurn)` → print the same optional capture message as `Moved` (when `captured.isDefined`), then print `"{newTurn.label} is in check!"`, then recurse with `(newBoard, newTurn)` - -**Restart vs. exit:** Checkmate and stalemate restart the game automatically (no prompt). This is intentionally asymmetric with `Quit`, which exits. `Quit` is an explicit user request to stop; Checkmate/Stalemate are natural game endings that should roll into a new game. - ---- - -## Test Strategy - -All tests are unit tests extending `AnyFunSuite with Matchers with JUnitSuiteLike`. - -### `GameRulesTest` — new file - -| Scenario | Method | Expected | -|---|---|---| -| King attacked by enemy rook on same rank | `isInCheck` | `true` | -| King not attacked (only own pieces nearby) | `isInCheck` | `false` | -| No king on board (constructed board) | `isInCheck` | `false` | -| Move that exposes own king to rook is excluded | `legalMoves` | does not contain that move | -| Move that blocks check is included | `legalMoves` | contains the blocking move | -| Checkmate: White Qh8, Ka6; Black Ka8 — Black king is in check (Qh8 along rank 8), cannot escape to a7 (Ka6), b7 (Ka6), or b8 (Qh8) | `gameStatus` | `Mated` | -| Stalemate: White Qb6, Kc6; Black Ka8 — Black king has no legal moves (a7/b7/b8 all controlled by Qb6), not in check | `gameStatus` | `Drawn` | -| King in check with at least one escape square | `gameStatus` | `InCheck` | -| Normal midgame position, not in check, has moves | `gameStatus` | `Normal` | - -### `GameControllerTest` additions — new `processMove` cases - -| Scenario | Expected `MoveResult` | -|---|---| -| Move leaves opponent in check (has escape) | `MovedInCheck` | -| Move results in checkmate | `Checkmate(winner)` where winner is the side that moved | -| Move results in stalemate | `Stalemate` | - -### `GameControllerTest` additions — new `gameLoop` cases - -| Scenario | Expected output / behavior | -|---|---| -| `gameLoop` receives `Checkmate(White)` | Prints "Checkmate! White wins." and continues (new game) | -| `gameLoop` receives `Stalemate` | Prints "Stalemate! The game is a draw." and continues (new game) | -| `gameLoop` receives `MovedInCheck` with a capture | Prints capture message AND check message | -| `gameLoop` receives `MovedInCheck` without a capture | Prints check message only | - ---- - -## Development Workflow (TDD) - -1. Create `GameRules.scala` with empty/stub method bodies that compile but return placeholder values (`false`, `Set.empty`, `PositionStatus.Normal`). -2. Write all `GameRulesTest` tests — they should **fail**. -3. Implement `GameRules` logic until `GameRulesTest` is green. -4. Add new `MoveResult` variants to `GameController.scala`; update `processMove` to call `GameRules.gameStatus` (stub the match arms initially). -5. Write new `GameControllerTest` cases — they should **fail**. -6. Implement `processMove` match arms and `gameLoop` new branches until all tests pass. -7. Run `./gradlew :modules:core:test` — full green build required. - ---- - -## Files Changed - -| File | Change | -|---|---| -| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | New | -| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | New | -| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `MoveResult` variants; update `processMove` and `gameLoop` | -| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add new test cases | - -No changes to `modules/api` or `MoveValidator`. diff --git a/docs/superpowers/specs/2026-03-24-castling-design.md b/docs/superpowers/specs/2026-03-24-castling-design.md deleted file mode 100644 index 7825cff..0000000 --- a/docs/superpowers/specs/2026-03-24-castling-design.md +++ /dev/null @@ -1,255 +0,0 @@ -# Castling Implementation Design - -**Date:** 2026-03-24 -**Status:** Approved (rev 2) -**Branch:** castling - ---- - -## Context - -The NowChessSystems chess engine currently operates on a raw `Board` (opaque `Map[Square, Piece]`) paired with a `Color` for turn tracking. Castling requires tracking whether the king and rooks have previously moved — state that does not exist in the current engine layer. The `CastlingRights` and `MoveType.Castle*` types are already defined in the `api` module but are not wired into the engine. - ---- - -## Approach: `GameContext` Wrapper (Option B) - -Introduce a thin `GameContext` wrapper in `modules/core` that bundles `Board` with castling rights for both sides. This is the single seam through which the engine learns about castling availability without pulling in the full FEN-structured `GameState` type. - ---- - -## Section 1 — `GameContext` Type - -**Location:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` - -```scala -case class GameContext( - board: Board, - whiteCastling: CastlingRights, - blackCastling: CastlingRights -): - def castlingFor(color: Color): CastlingRights = - if color == Color.White then whiteCastling else blackCastling - - def withUpdatedRights(color: Color, rights: CastlingRights): GameContext = - if color == Color.White then copy(whiteCastling = rights) - else copy(blackCastling = rights) -``` - -`GameContext.initial` wraps `Board.initial` with `CastlingRights.Both` for both sides. - -`gameLoop` and `processMove` replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`. All `MoveResult` variants that previously carried `newBoard: Board` now carry `newCtx: GameContext`. The `gameLoop` render call becomes `Renderer.render(ctx.board)`, and all `gameLoop` pattern match arms that destructure `MoveResult.Moved(newBoard, ...)` or `MoveResult.MovedInCheck(newBoard, ...)` must be updated to destructure `newCtx` and pass it to the recursive `gameLoop` call. - ---- - -## Section 2 — `CastleSide` and Board Extension for Castle Moves - -### `CastleSide` enum - -`CastleSide` is a two-value engine-internal enum defined in `core` (not in `api`). It is co-located in `GameContext.scala` — there is no separate `CastleSide.scala` file. - -```scala -enum CastleSide: - case Kingside, Queenside -``` - -### `withCastle` extension - -`Board.withMove(from, to)` moves a single piece. Castling moves two pieces atomically. To avoid a circular dependency (`api` must not import from `core`), `withCastle` is **not** added to `Board` in the `api` module. Instead it is defined as an extension method in `core`, co-located with `GameContext`: - -```scala -// inside GameContext.scala or a BoardCastleOps.scala in core -extension (b: Board) - def withCastle(color: Color, side: CastleSide): Board = ... -``` - -Post-castle square assignments: -- **Kingside White:** King e1→g1, Rook h1→f1 -- **Queenside White:** King e1→c1, Rook a1→d1 -- **Kingside Black:** King e8→g8, Rook h8→f8 -- **Queenside Black:** King e8→c8, Rook a8→d8 - ---- - -## Section 3 — `MoveValidator` Castling Logic - -### Signature change - -`legalTargets` and `isLegal` are extended to accept `GameContext` when the caller has full game context. To avoid breaking `GameRules.isInCheck` (which uses `legalTargets` with only a `Board` for attacked-square detection), the implementation retains a **board-only private helper** for sliding/jump/normal king targets, and a **public overload** that additionally unions castling targets when a `GameContext` is provided: - -```scala -// board-only (used internally by isInCheck) -def legalTargets(board: Board, from: Square): Set[Square] - -// context-aware (used by legalMoves and processMove) -def legalTargets(ctx: GameContext, from: Square): Set[Square] -``` - -The `GameContext` overload delegates to the `Board` overload for all piece types except King, where it additionally unions `castlingTargets(ctx, color)`. - -`isLegal` is likewise overloaded: - -```scala -// board-only (retained for callers that have no castling context) -def isLegal(board: Board, from: Square, to: Square): Boolean - -// context-aware (used by processMove) -def isLegal(ctx: GameContext, from: Square, to: Square): Boolean -``` - -The context-aware `isLegal(ctx, from, to)` calls `legalTargets(ctx, from).contains(to)` — using the context-aware overload — so castling targets are included in the legality check. - -### `castlingTargets` method - -```scala -def castlingTargets(ctx: GameContext, color: Color): Set[Square] -``` - -For each side (kingside, queenside), checks all six conditions in order (failing fast): - -1. `CastlingRights` flag is `true` for that side (`ctx.castlingFor(color)`) -2. King is on its home square (e1 for White, e8 for Black) -3. Relevant rook is on its home square (h-file for kingside, a-file for queenside) -4. All squares between king and rook are empty -5. King is **not currently in check** — calls `GameRules.isInCheck(ctx.board, color)` using the board-only path (no castling recursion) -6. Each square the king **passes through and lands on** is not attacked — checks that no enemy `legalTargets(board, enemySq)` (board-only) covers those squares - -Transit and landing squares: -- **Kingside:** f-file, g-file (White: f1, g1; Black: f8, g8) -- **Queenside:** d-file, c-file (White: d1, c1; Black: d8, c8). Note: b1/b8 must be empty (condition 4) but the king does not pass through them, so they are not checked for attacks. - ---- - -## Section 4 — `GameRules` Changes - -`GameRules.legalMoves` must accept `GameContext` (not just `Board`) so it can enumerate castling moves as part of the legal move set. This is required for correct stalemate and checkmate detection — a position where the only legal move is to castle must not be evaluated as stalemate. - -```scala -def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] -``` - -Internally it calls `MoveValidator.legalTargets(ctx, from)` (the context-aware overload) for all pieces of `color`, then filters to moves that do not leave the king in check. - -`isInCheck` retains its `(board: Board, color: Color)` signature — it does not need castling context. - -`gameStatus` is updated to accept `GameContext`: - -```scala -def gameStatus(ctx: GameContext, color: Color): PositionStatus -``` - ---- - -## Section 5 — `GameController` Changes - -### Move detection and execution - -`processMove` identifies a castling move by the king occupying its home square and moving exactly two files laterally: -- White: e1→g1 (kingside) or e1→c1 (queenside) -- Black: e8→g8 (kingside) or e8→c8 (queenside) - -Legality is confirmed via `MoveValidator.isLegal(ctx, from, to)` (the context-aware overload, which includes castling targets). When a castling move is legal and executed: -1. Call `ctx.board.withCastle(color, side)` to move both pieces atomically. -2. Revoke **both** castling rights for the moving color in the new `GameContext`. - -### Rights revocation rules (applied on every move) - -After every move `(from, to)` is applied, revoke rights based on both the **source square** and the **destination square**. Both tables are checked independently and all triggered revocations are applied. - -**Source square → revocation** (piece leaves its home square): - -| Source square | Rights revoked | -|---------------|---------------| -| `e1` | Both White castling rights | -| `e8` | Both Black castling rights | -| `a1` | White queenside | -| `h1` | White kingside | -| `a8` | Black queenside | -| `h8` | Black kingside | - -**Destination square → revocation** (a piece — including an enemy piece — arrives on a rook home square, meaning a capture removed the rook): - -| Destination square | Rights revoked | -|--------------------|---------------| -| `a1` | White queenside | -| `h1` | White kingside | -| `a8` | Black queenside | -| `h8` | Black kingside | - -This covers the following cases: -- **King normal move** — source square e1/e8 fires; both rights revoked. -- **King castle move** — the castle-specific step 2 revokes both rights for the moving color. Additionally, the source-square table fires (king departs e1/e8), revoking the same rights a second time. This double-revocation is idempotent and harmless. The king's destination (g1/c1/g8/c8) does not appear in the destination table, so no extra revocation fires there. -- **Own rook move** — source square a1/h1/a8/h8 fires. -- **Enemy capture on a rook home square** — destination square a1/h1/a8/h8 fires, revoking the side that lost the rook. - -`processMove` also calls `GameRules.gameStatus(newCtx, turn.opposite)` — note this call passes the full `GameContext`, not just a `Board`, because `gameStatus` now accepts `GameContext`. - -The revocation is applied to the `GameContext` that results from the move, before it is returned in `MoveResult`. - -### Signatures - -```scala -def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult -def gameLoop(ctx: GameContext, turn: Color): Unit -``` - -`MoveResult.Moved` and `MoveResult.MovedInCheck` carry `newCtx: GameContext` instead of `newBoard: Board`. All `gameLoop` pattern match arms are updated to use `newCtx`. The render call uses `newCtx.board`. - -On checkmate/stalemate reset, `GameContext.initial` is used. - ---- - -## Section 6 — Move Notation - -The player types standard coordinate notation: -- `e1g1` → White kingside castle -- `e1c1` → White queenside castle -- `e8g8` → Black kingside castle -- `e8c8` → Black queenside castle - -No parser changes required. The controller identifies castling by the king moving 2 files from the home square. - ---- - -## Section 7 — Testing - -### `MoveValidatorTest` -- Castling target (g1) is returned when all kingside conditions are met (White) -- Castling target (c1) is returned when all queenside conditions are met (White) -- Castling targets returned for Black kingside (g8) and queenside (c8) -- Castling blocked when transit square is occupied (piece between king and rook) -- Castling blocked when king is in check (condition 5) -- Castling blocked when **transit square** is attacked (e.g., f1 attacked for White kingside) -- Castling blocked when **landing square** is attacked (e.g., g1 attacked for White kingside) -- Castling blocked when `kingSide = false` in `CastlingRights` -- Castling blocked when `queenSide = false` in `CastlingRights` -- Castling blocked when relevant rook is not on its home square - -### `GameControllerTest` -- `processMove` with `e1g1` returns `Moved` with king on g1, rook on f1, and both White castling rights revoked in `newCtx` -- `processMove` with `e1c1` returns `Moved` with king on c1, rook on d1, and both White castling rights revoked in `newCtx` -- `processMove` castle attempt after king has moved returns `IllegalMove` -- `processMove` castle attempt after rook has moved returns `IllegalMove` -- Normal rook move from h1 revokes White kingside right in the returned `newCtx` -- Normal king move from e1 revokes both White rights in the returned `newCtx` -- Enemy capture on h1 (e.g., Black rook captures White rook on h1) revokes White kingside right in the returned `newCtx` - -### `GameRulesTest` -- `legalMoves` includes castling destinations when available -- `legalMoves` excludes castling when king is in check -- `gameStatus` returns `Normal` (not `Drawn`) when the only legal move available is to castle — verifying that the `GameContext` signature change correctly prevents a false stalemate - ---- - -## Files to Create / Modify - -| Action | File | -|--------|------| -| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` — includes `CastleSide` enum and `withCastle` Board extension | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` — add `castlingTargets`, board-only + context-aware `legalTargets`/`isLegal` overloads | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` — update `legalMoves` and `gameStatus` to accept `GameContext` | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` — use `GameContext`; castling detection, execution, rights revocation | -| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` — use `GameContext.initial` | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` — new castling tests | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` — update signatures + new castling tests | -| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` — update signatures + new castling tests | diff --git a/docs/unresolved.md b/docs/unresolved.md index c8f6738..e69de29 100644 --- a/docs/unresolved.md +++ b/docs/unresolved.md @@ -1,23 +0,0 @@ -# Unresolved Issues - -## [2026-03-24] JUnitSuiteLike mixin not available for ScalaTest 3.2.19 with Scala 3 - -**Requirement / Bug:** -CLAUDE.md prescribes that all unit tests should extend `AnyFunSuite with Matchers with JUnitSuiteLike`. However, the `JUnitSuiteLike` trait cannot be resolved in the current build configuration. - -**Root Cause (if known):** -- ScalaTest 3.2.19 for Scala 3 does not provide `JUnitSuiteLike` in any public package. -- The `co.helmethair:scalatest-junit-runner:0.1.11` dependency does not expose this trait. -- There is no `org.scalatest:scalatest-junit_3` artifact available for version 3.2.19. -- The trait may have been removed or changed in the ScalaTest 3.x → Scala 3 migration. - -**Attempted Fixes:** -1. Tried importing from `org.scalatest.junit.JUnitSuiteLike` — not found -2. Tried importing from `org.scalatestplus.junit.JUnitSuiteLike` — not found -3. Tried importing from `co.helmethair.scalatest.junit.JUnitSuiteLike` — not found -4. Attempted to add `org.scalatest:scalatest-junit_3:3.2.19` dependency — artifact does not exist in Maven Central - -**Suggested Next Step:** -1. Either find the correct ScalaTest artifact/import for Scala 3 JUnit integration, or -2. Update CLAUDE.md to reflect the actual constraint that unit tests should extend `AnyFunSuite with Matchers` (without `JUnitSuiteLike`), or -3. Investigate whether a different test runner or configuration is needed to achieve JUnit integration with ScalaTest 3 in Scala 3