Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8959c3a849 | |||
| 47032378e2 | |||
| 217f14f899 | |||
| 638139602c | |||
| 8f56a82104 | |||
| 51ffd7aac9 | |||
| 1b9eb471de | |||
| 45013c87a9 | |||
| 80518719d5 | |||
| 2d6ead7e47 | |||
| 3ff80318b4 | |||
| 9fb743d135 |
@@ -1,9 +0,0 @@
|
|||||||
# Memory Index
|
|
||||||
|
|
||||||
## Feedback
|
|
||||||
- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md) — Update structure memory files whenever source files are added, removed, or changed
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
- [project_structure_root.md](project_structure_root.md) — Top-level layout, modules list, VERSIONS map, navigation rules (skip `build/`, `.gradle/`, `.idea/`)
|
|
||||||
- [project_structure_api.md](project_structure_api.md) — `modules/api`: all files and types (Board, Piece, Square, GameState, Move, ApiResponse, PlayerInfo)
|
|
||||||
- [project_structure_core.md](project_structure_core.md) — `modules/core`: all files and types (GameContext, GameRules, MoveValidator, GameController, Parser, Renderer)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
name: keep-structure-memory-updated
|
|
||||||
description: Always update the project structure memory files when adding, removing, or changing source files
|
|
||||||
type: feedback
|
|
||||||
---
|
|
||||||
|
|
||||||
After any change that adds, removes, renames, or significantly alters a source file, update the relevant structure memory file:
|
|
||||||
|
|
||||||
- New/renamed/deleted file in `modules/api` → update `project_structure_api.md`
|
|
||||||
- New/renamed/deleted file in `modules/core` → update `project_structure_core.md`
|
|
||||||
- New module, dependency version change, or new top-level directory → update `project_structure_root.md`
|
|
||||||
- New module added → create a new `project_structure_<module>.md` and add it to `MEMORY.md`
|
|
||||||
|
|
||||||
**Why:** Structure memories are the primary navigation aid. Stale entries cause wasted exploration.
|
|
||||||
|
|
||||||
**How to apply:** Treat the structure memory update as part of completing any implementation task — do it in the same session, not as a follow-up.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
name: module-api-structure
|
|
||||||
description: File and type overview for the modules/api module (shared domain types)
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module: `modules/api`
|
|
||||||
|
|
||||||
**Purpose:** Shared domain model — pure data types with no game logic. Depended on by `modules/core`.
|
|
||||||
|
|
||||||
**Gradle:** `id("scala")`, no `application` plugin. No Quarkus. Uses scoverage plugin.
|
|
||||||
|
|
||||||
**Package root:** `de.nowchess.api`
|
|
||||||
|
|
||||||
## Source files (`src/main/scala/de/nowchess/api/`)
|
|
||||||
|
|
||||||
### `board/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Board.scala` | `opaque type Board = Map[Square, Piece]` — extensions: `pieceAt`, `withMove`, `pieces`; `Board.initial` sets up start position |
|
|
||||||
| `Color.scala` | `enum Color { White, Black }` — `.opposite`, `.label` |
|
|
||||||
| `Piece.scala` | `case class Piece(color, pieceType)` — convenience vals `WhitePawn`…`BlackKing` |
|
|
||||||
| `PieceType.scala` | `enum PieceType { Pawn, Knight, Bishop, Rook, Queen, King }` — `.label` |
|
|
||||||
| `Square.scala` | `enum File { A–H }`, `enum Rank { R1–R8 }`, `case class Square(file, rank)` — `.toString` algebraic, `Square.fromAlgebraic(s)` |
|
|
||||||
|
|
||||||
### `game/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameState.scala` | `case class CastlingRights(kingSide, queenSide)` + `.None`/`.Both`; `enum GameResult { WhiteWins, BlackWins, Draw }`; `enum GameStatus { NotStarted, InProgress, Finished(result) }`; `case class GameState(piecePlacement, activeColor, castlingWhite, castlingBlack, enPassantTarget, halfMoveClock, fullMoveNumber, status)` — FEN-compatible snapshot |
|
|
||||||
|
|
||||||
### `move/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Move.scala` | `enum PromotionPiece { Knight, Bishop, Rook, Queen }`; `enum MoveType { Normal, CastleKingside, CastleQueenside, EnPassant, Promotion(piece) }`; `case class Move(from, to, moveType = Normal)` |
|
|
||||||
|
|
||||||
### `player/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `PlayerInfo.scala` | `opaque type PlayerId = String`; `case class PlayerInfo(id: PlayerId, displayName: String)` |
|
|
||||||
|
|
||||||
### `response/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `ApiResponse.scala` | `sealed trait ApiResponse[+A]` → `Success[A](data)` / `Failure(errors)`; `case class ApiError(code, message, field?)`; `case class Pagination(page, pageSize, totalItems)` + `.totalPages`; `case class PagedResponse[A](items, pagination)` |
|
|
||||||
|
|
||||||
## Test files (`src/test/scala/de/nowchess/api/`)
|
|
||||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- `GameState` is FEN-style but `Board` (in `core`) is a `Map[Square,Piece]` — the two are separate representations
|
|
||||||
- `CastlingRights` is defined here in `api`; the castling logic lives in `core`
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
name: module-core-structure
|
|
||||||
description: File and type overview for the modules/core module (TUI chess engine)
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module: `modules/core`
|
|
||||||
|
|
||||||
**Purpose:** Standalone TUI chess application. All game logic, move validation, rendering. Depends on `modules/api`.
|
|
||||||
|
|
||||||
**Gradle:** `id("scala")` + `application` plugin. Main class: `de.nowchess.chess.Main`. Uses scoverage plugin.
|
|
||||||
|
|
||||||
**Package root:** `de.nowchess.chess`
|
|
||||||
|
|
||||||
## Source files (`src/main/scala/de/nowchess/chess/`)
|
|
||||||
|
|
||||||
### Root
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Main.scala` | Entry point — prints welcome, starts `GameController.gameLoop(GameContext.initial, Color.White)` |
|
|
||||||
|
|
||||||
### `controller/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameController.scala` | `sealed trait MoveResult` ADT: `Quit`, `InvalidFormat`, `NoPiece`, `WrongColor`, `IllegalMove`, `Moved`, `MovedInCheck`, `Checkmate`, `Stalemate`; `object GameController` — `processMove(ctx, turn, raw): MoveResult` (pure), `gameLoop(ctx, turn)` (I/O loop), `applyRightsRevocation(...)` (castling rights bookkeeping) |
|
|
||||||
| `Parser.scala` | `object Parser` — `parseMove(input): Option[(Square, Square)]` parses coordinate notation e.g. `"e2e4"` |
|
|
||||||
|
|
||||||
### `logic/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameContext.scala` | `enum CastleSide { Kingside, Queenside }`; `case class GameContext(board, whiteCastling, blackCastling)` — `.castlingFor(color)`, `.withUpdatedRights(color, rights)`; `GameContext.initial`; `extension (Board).withCastle(color, side)` moves king+rook atomically |
|
|
||||||
| `GameRules.scala` | `enum PositionStatus { Normal, InCheck, Mated, Drawn }`; `object GameRules` — `isInCheck(board, color)`, `legalMoves(ctx, color): Set[(Square,Square)]`, `gameStatus(ctx, color): PositionStatus` |
|
|
||||||
| `MoveValidator.scala` | `object MoveValidator` — `isLegal(board, from, to)`, `legalTargets(board, from): Set[Square]` (board-only, no castling), `legalTargets(ctx, from)` (context-aware, includes castling), `isCastle`, `castleSide`, `castlingTargets(ctx, color)` — full castling legality (empty squares, no check through transit) |
|
|
||||||
|
|
||||||
### `view/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Renderer.scala` | `object Renderer` — `render(board): String` outputs ANSI-colored board with file/rank labels |
|
|
||||||
| `PieceUnicode.scala` | `extension (Piece).unicode: String` maps each piece to its Unicode chess symbol |
|
|
||||||
|
|
||||||
## Test files (`src/test/scala/de/nowchess/chess/`)
|
|
||||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
|
||||||
|
|
||||||
## Key design notes
|
|
||||||
- `MoveValidator` has two overloaded `legalTargets`: one takes `Board` (geometry only), one takes `GameContext` (adds castling)
|
|
||||||
- `GameRules.legalMoves` filters by check — it calls `MoveValidator.legalTargets(ctx, from)` then simulates each move
|
|
||||||
- Castling rights revocation is in `GameController.applyRightsRevocation`, triggered after every move
|
|
||||||
- No `@QuarkusTest` — this module is a plain Scala application, not a Quarkus service
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: project-root-structure
|
|
||||||
description: Top-level project structure, modules list, and navigation notes for NowChessSystems
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# NowChessSystems — Root Structure
|
|
||||||
|
|
||||||
## Directory layout (skip `build/`, `.gradle/`, `.idea/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
NowChessSystems/
|
|
||||||
├── build.gradle.kts # Root: sonarqube plugin, VERSIONS map
|
|
||||||
├── settings.gradle.kts # include(":modules:core", ":modules:api")
|
|
||||||
├── gradlew / gradlew.bat
|
|
||||||
├── CLAUDE.md # Project instructions for Claude Code
|
|
||||||
├── .claude/
|
|
||||||
│ ├── CLAUDE.MD # Working agreement (plan/verify/unresolved)
|
|
||||||
│ ├── settings.json
|
|
||||||
│ └── agents/ # architect, code-reviewer, gradle-builder, scala-implementer, test-writer
|
|
||||||
├── docs/
|
|
||||||
│ ├── Claude-Skills.md
|
|
||||||
│ ├── Security.md
|
|
||||||
│ └── unresolved.md
|
|
||||||
├── jacoco-reporter/ # Python scripts for coverage gap reporting
|
|
||||||
└── modules/
|
|
||||||
├── api/ # Shared domain types (no logic)
|
|
||||||
└── core/ # TUI chess engine + game logic
|
|
||||||
```
|
|
||||||
|
|
||||||
## Modules
|
|
||||||
|
|
||||||
| Module | Gradle path | Purpose |
|
|
||||||
|--------|-------------|---------|
|
|
||||||
| `api` | `:modules:api` | Shared domain model: Board, Piece, Move, GameState, ApiResponse |
|
|
||||||
| `core` | `:modules:core` | TUI chess app: game logic, move validation, rendering |
|
|
||||||
|
|
||||||
`core` depends on `api` via `implementation(project(":modules:api"))`.
|
|
||||||
|
|
||||||
## VERSIONS (root `build.gradle.kts`)
|
|
||||||
|
|
||||||
| Key | Value |
|
|
||||||
|-----|-------|
|
|
||||||
| `QUARKUS_SCALA3` | 1.0.0 |
|
|
||||||
| `SCALA3` | 3.5.1 |
|
|
||||||
| `SCALA_LIBRARY` | 2.13.18 |
|
|
||||||
| `SCALATEST` | 3.2.19 |
|
|
||||||
| `SCALATEST_JUNIT` | 0.1.11 |
|
|
||||||
| `SCOVERAGE` | 2.1.1 |
|
|
||||||
|
|
||||||
## Navigation rules
|
|
||||||
- **Always skip** `build/`, `.gradle/`, `.idea/` when exploring — they are generated artifacts
|
|
||||||
- Tests use `AnyFunSuite with Matchers` (ScalaTest), not JUnit `@Test`
|
|
||||||
- No Quarkus in current modules — Quarkus is planned for future services
|
|
||||||
- Agent workflow: architect → scala-implementer → test-writer → gradle-builder → code-reviewer
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"superpowers@claude-plugins-official": true,
|
"superpowers@claude-plugins-official": false,
|
||||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
"ui-ux-pro-max@ui-ux-pro-max-skill": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
<option value="$PROJECT_DIR$/modules/api" />
|
<option value="$PROJECT_DIR$/modules/api" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/io" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
<option value="$PROJECT_DIR$/modules/ui" />
|
<option value="$PROJECT_DIR$/modules/ui" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</profile>
|
||||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<parameters>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
YOU CAN:
|
||||||
|
- Edit and use the asset in any commercial or non commercial project
|
||||||
|
- Use the asset in any commercial or non commercial project
|
||||||
|
|
||||||
|
YOU CAN'T:
|
||||||
|
- Resell or distribute the asset to others
|
||||||
|
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
|
||||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 907 B |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 818 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 264 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 251 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 280 B |
@@ -1,58 +1,50 @@
|
|||||||
# CLAUDE.md — NowChessSystems
|
# Now-Chess
|
||||||
|
|
||||||
## Stack
|
Scala 3.5.1 · Gradle 9
|
||||||
Scala 3.5.x · Quarkus + quarkus-scala3 · Hibernate/Jakarta · Lanterna TUI · K8s + ArgoCD + Kargo · Frontend TBD (Vite/React/Angular/Vue)
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
|
|
||||||
Your memory is saved under .claude/memory/MEMORY.md.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
```
|
|
||||||
build.gradle.kts / settings.gradle.kts # root; include(":modules:<svc>") per service
|
|
||||||
modules/<svc>/build.gradle.kts + src/
|
|
||||||
docs/adr/ docs/api/ docs/unresolved.md
|
|
||||||
```
|
|
||||||
Versions in root `extra["VERSIONS"]`; modules read via `rootProject.extra["VERSIONS"] as Map<String,String>`.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
```bash
|
|
||||||
./gradlew build
|
|
||||||
./gradlew :modules:<svc>:build|test
|
|
||||||
./gradlew :modules:<svc>:test --tests "de.nowchess.<svc>.<Class>"
|
|
||||||
```
|
```
|
||||||
|
./clean # Clear build dirs — only when necessary
|
||||||
## Workflow
|
./compile # Compile all modules — always run
|
||||||
1. **Plan** — restate requirement, list files, flag risks. Proceed unless genuine ambiguity.
|
./test # Run all tests
|
||||||
2. **Tests first** — cover only new behaviour.
|
./coverage # Check coverage
|
||||||
3. **Implement** — no scope creep.
|
|
||||||
4. **Verify** — check each requirement; confirm green build.
|
|
||||||
|
|
||||||
## Scala/Quarkus Rules
|
|
||||||
- `given`/`using` only (no `implicit`); `Option`/`Either`/`Try` (no `null`/`.get`)
|
|
||||||
- `jakarta.*` only; reactive I/O (`Uni`/`Multi`), no blocking on event loop
|
|
||||||
- Always exclude `org.scala-lang:scala-library` from Quarkus BOM
|
|
||||||
- Unit tests: `extends AnyFunSuite with Matchers` — no `@Test`, no `: Unit`
|
|
||||||
- Integration tests: `@QuarkusTest` + JUnit 5 — `@Test` methods need explicit `: Unit`
|
|
||||||
|
|
||||||
## Coverage
|
|
||||||
Line = 100% · Branch = 100% · Method = 100% · Regression tests · document exceptions
|
|
||||||
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
|
|
||||||
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
|
|
||||||
|
|
||||||
## Bug Fixing
|
|
||||||
Fix failures immediately without asking. After 3 failed attempts → log in `docs/unresolved.md` + surface summary.
|
|
||||||
|
|
||||||
## Agents (new service)
|
|
||||||
Sequential: architect → scala-implementer → test-writer → gradle-builder → code-reviewer (review only, no self-fix)
|
|
||||||
Parallel: only when services are fully independent (no shared contracts/state).
|
|
||||||
|
|
||||||
## Unresolved (`docs/unresolved.md`)
|
|
||||||
Append only, never delete:
|
|
||||||
```
|
|
||||||
## [YYYY-MM-DD] <title>
|
|
||||||
**Requirement/Bug:** **Root Cause:** **Attempted Fixes:** **Next Step:**
|
|
||||||
```
|
```
|
||||||
|
Try to stick to these commands for consistency.
|
||||||
|
|
||||||
## Done Checklist
|
## Modules
|
||||||
- [ ] Plan written · files created/modified · tests green · requirements verified · unresolved logged
|
|
||||||
|
| Module | Role | Depends on |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| `api` | Model / shared types | (none) |
|
||||||
|
| `core` | Primary business logic | api, rule |
|
||||||
|
| `rule` | Game rules | api |
|
||||||
|
| `io` | Export formats | api, core |
|
||||||
|
| `ui` | Entrypoint & UI | core, io |
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Use immutable data and pure functions.
|
||||||
|
- Keep functions under 30 lines. If you need "and" to describe it, split it.
|
||||||
|
- Keep cyclomatic complexity under 15.
|
||||||
|
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
|
||||||
|
- Scan for duplicated logic before finishing. Extract it.
|
||||||
|
- Follow default Sonar style for Scala.
|
||||||
|
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
|
||||||
|
- Naming: types are PascalCase, functions/values are camelCase.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||||
|
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
|
||||||
|
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
|
||||||
|
- Never read build folders. Ask permission if needed.
|
||||||
|
- Keep this file up to date with any important decisions or conventions.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.sonarqube") version "7.2.3.7755"
|
id("org.sonarqube") version "7.2.3.7755"
|
||||||
|
id("org.scoverage") version "8.1" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -28,7 +29,10 @@ val versions = mapOf(
|
|||||||
"SCALA_LIBRARY" to "2.13.18",
|
"SCALA_LIBRARY" to "2.13.18",
|
||||||
"SCALATEST" to "3.2.19",
|
"SCALATEST" to "3.2.19",
|
||||||
"SCALATEST_JUNIT" to "0.1.11",
|
"SCALATEST_JUNIT" to "0.1.11",
|
||||||
"SCOVERAGE" to "2.1.1"
|
"SCOVERAGE" to "2.1.1",
|
||||||
|
"SCALAFX" to "21.0.0-R32",
|
||||||
|
"JAVAFX" to "21.0.1",
|
||||||
|
"JUNIT_BOM" to "5.13.4"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#! /usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
./gradlew classes
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#! /usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||||
|
else
|
||||||
|
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||||
|
fi
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
## [2026-03-31] Unreachable code blocking 100% statement coverage
|
|
||||||
|
|
||||||
**Requirement/Bug:** Reach 100% statement coverage in core module.
|
|
||||||
|
|
||||||
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
|
|
||||||
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
|
|
||||||
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
|
|
||||||
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
|
|
||||||
|
|
||||||
**Attempted Fixes:**
|
|
||||||
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
|
|
||||||
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
|
|
||||||
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
|
|
||||||
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
|
|
||||||
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
|
|
||||||
|
|
||||||
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
|
|
||||||
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
|
|
||||||
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
|
|
||||||
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ Usage:
|
|||||||
python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default)
|
python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default)
|
||||||
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller
|
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller
|
||||||
python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80
|
python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80
|
||||||
|
python scoverage_coverage_gaps.py (default: scans ./modules)
|
||||||
|
python scoverage_coverage_gaps.py --modules-dir ./services
|
||||||
|
python scoverage_coverage_gaps.py <scoverage.xml>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
@@ -26,7 +29,8 @@ import sys
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path, PureWindowsPath
|
import glob
|
||||||
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -112,7 +116,6 @@ class ClassGap:
|
|||||||
@property
|
@property
|
||||||
def uncovered_branch_lines(self) -> list[int]:
|
def uncovered_branch_lines(self) -> list[int]:
|
||||||
"""Lines that are branch points and have at least one uncovered branch statement."""
|
"""Lines that are branch points and have at least one uncovered branch statement."""
|
||||||
# Group branch statements by line; a line is "partial" if some covered, some not
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
by_line: dict[int, list[Statement]] = defaultdict(list)
|
by_line: dict[int, list[Statement]] = defaultdict(list)
|
||||||
for s in self.statements:
|
for s in self.statements:
|
||||||
@@ -120,10 +123,7 @@ class ClassGap:
|
|||||||
by_line[s.line].append(s)
|
by_line[s.line].append(s)
|
||||||
partial = []
|
partial = []
|
||||||
for line, stmts in by_line.items():
|
for line, stmts in by_line.items():
|
||||||
has_covered = any(s.is_covered for s in stmts)
|
if any(s.is_uncovered for s in stmts):
|
||||||
has_uncovered = any(s.is_uncovered for s in stmts)
|
|
||||||
# Report line if any branch arm is uncovered
|
|
||||||
if has_uncovered:
|
|
||||||
partial.append(line)
|
partial.append(line)
|
||||||
return sorted(partial)
|
return sorted(partial)
|
||||||
|
|
||||||
@@ -169,20 +169,10 @@ class ClassGap:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _normalise_source(raw: str) -> str:
|
def _normalise_source(raw: str) -> str:
|
||||||
"""
|
|
||||||
Convert an absolute Windows or Unix source path from the XML into a
|
|
||||||
relative src/main/scala/… path for agent consumption.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Replace Windows backslashes.
|
|
||||||
2. Find the 'src/' anchor and take everything from there.
|
|
||||||
3. Fall back to the package-derived path if no anchor found.
|
|
||||||
"""
|
|
||||||
normalised = raw.replace("\\", "/")
|
normalised = raw.replace("\\", "/")
|
||||||
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
|
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
# Fallback: just the filename portion
|
|
||||||
return normalised.split("/")[-1]
|
return normalised.split("/")[-1]
|
||||||
|
|
||||||
|
|
||||||
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
|
|||||||
# Parser
|
# Parser
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
def parse_scoverage_xml(xml_path: str) -> tuple[dict, list[ClassGap]]:
|
||||||
tree = ET.parse(xml_path)
|
tree = ET.parse(xml_path)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
|
|
||||||
# ── Authoritative project-level totals from <scoverage> root element ──────
|
|
||||||
project_stats = {
|
project_stats = {
|
||||||
"total_statements": int(root.get("statement-count", 0)),
|
"total_statements": int(root.get("statement-count", 0)),
|
||||||
"covered_statements": int(root.get("statements-invoked", 0)),
|
"covered_statements": int(root.get("statements-invoked", 0)),
|
||||||
@@ -202,17 +191,16 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
"branch_coverage_pct": float(root.get("branch-rate", 0.0)),
|
"branch_coverage_pct": float(root.get("branch-rate", 0.0)),
|
||||||
}
|
}
|
||||||
project_stats["missed_statements"] = (
|
project_stats["missed_statements"] = (
|
||||||
project_stats["total_statements"] - project_stats["covered_statements"]
|
project_stats["total_statements"] - project_stats["covered_statements"]
|
||||||
)
|
)
|
||||||
|
|
||||||
class_map: dict[str, ClassGap] = {} # full-class-name → ClassGap
|
class_map: dict[str, ClassGap] = {}
|
||||||
|
|
||||||
for package in root.findall("packages/package"):
|
for package in root.findall("packages/package"):
|
||||||
for cls_elem in package.findall("classes/class"):
|
for cls_elem in package.findall("classes/class"):
|
||||||
class_name = cls_elem.get("name", "")
|
class_name = cls_elem.get("name", "")
|
||||||
filename = cls_elem.get("filename", "")
|
filename = cls_elem.get("filename", "")
|
||||||
|
|
||||||
# Authoritative per-class totals from <class> attributes
|
|
||||||
cls_total = int(cls_elem.get("statement-count", 0))
|
cls_total = int(cls_elem.get("statement-count", 0))
|
||||||
cls_invoked = int(cls_elem.get("statements-invoked", 0))
|
cls_invoked = int(cls_elem.get("statements-invoked", 0))
|
||||||
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0))
|
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0))
|
||||||
@@ -221,11 +209,8 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
for method_elem in cls_elem.findall("methods/method"):
|
for method_elem in cls_elem.findall("methods/method"):
|
||||||
method_name = method_elem.get("name", "")
|
method_name = method_elem.get("name", "")
|
||||||
|
|
||||||
# Authoritative per-method totals from <method> attributes
|
m_total = int(method_elem.get("statement-count", 0))
|
||||||
m_total = int(method_elem.get("statement-count", 0))
|
m_invoked = int(method_elem.get("statements-invoked", 0))
|
||||||
m_invoked = int(method_elem.get("statements-invoked", 0))
|
|
||||||
m_stmt_rate = float(method_elem.get("statement-rate", 0.0))
|
|
||||||
m_br_rate = float(method_elem.get("branch-rate", 0.0))
|
|
||||||
|
|
||||||
for stmt_elem in method_elem.findall("statements/statement"):
|
for stmt_elem in method_elem.findall("statements/statement"):
|
||||||
raw_source = stmt_elem.get("source", filename)
|
raw_source = stmt_elem.get("source", filename)
|
||||||
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
method=method_name,
|
method=method_name,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Register method-level gap using authoritative XML stats
|
|
||||||
cg = next(
|
cg = next(
|
||||||
(v for v in class_map.values() if v.class_name == class_name),
|
(v for v in class_map.values() if v.class_name == class_name),
|
||||||
None,
|
None,
|
||||||
@@ -268,7 +252,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
uncov_lines = sorted({s.line for s in active if s.is_uncovered})
|
uncov_lines = sorted({s.line for s in active if s.is_uncovered})
|
||||||
uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
|
uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
|
||||||
if uncov_lines or uncov_branch_lines:
|
if uncov_lines or uncov_branch_lines:
|
||||||
# Count branches from statement-level data (not in method XML attrs)
|
|
||||||
total_b = sum(1 for s in active if s.is_branch)
|
total_b = sum(1 for s in active if s.is_branch)
|
||||||
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
|
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
|
||||||
mg = MethodGap(
|
mg = MethodGap(
|
||||||
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
)
|
)
|
||||||
cg.method_gaps.append(mg)
|
cg.method_gaps.append(mg)
|
||||||
|
|
||||||
# ── Project stats injected so formatters never recount from statements ────
|
|
||||||
return project_stats, [cg for cg in class_map.values() if cg.has_gaps]
|
return project_stats, [cg for cg in class_map.values() if cg.has_gaps]
|
||||||
|
|
||||||
|
|
||||||
@@ -310,103 +292,60 @@ def _compact_ranges(numbers: list[int]) -> str:
|
|||||||
# Formatters
|
# Formatters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _pct_bar(pct: float, width: int = 20) -> str:
|
|
||||||
"""Render a compact ASCII progress bar, e.g. [████░░░░░░░░░░░░░░░░] 23.5%"""
|
|
||||||
filled = round(pct / 100 * width)
|
|
||||||
bar = "█" * filled + "░" * (width - filled)
|
|
||||||
return f"[{bar}] {pct:.1f}%"
|
|
||||||
|
|
||||||
|
|
||||||
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
||||||
|
"""
|
||||||
|
Compact agent format — optimised for low token count.
|
||||||
|
Emits only actionable gaps: file path, uncovered lines, branch-gap lines,
|
||||||
|
and a per-method breakdown. No ASCII bars, no redundant tables.
|
||||||
|
"""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
lines.append("# scoverage Coverage Gaps — Agent Action Report")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# ---- Project-level totals (authoritative from <scoverage> root element) ----
|
total_stmts = project_stats["total_statements"]
|
||||||
total_stmts = project_stats["total_statements"]
|
covered_stmts = project_stats["covered_statements"]
|
||||||
covered_stmts = project_stats["covered_statements"]
|
missed_stmts = project_stats["missed_statements"]
|
||||||
missed_stmts = project_stats["missed_statements"]
|
|
||||||
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
||||||
overall_branch_pct = project_stats["branch_coverage_pct"]
|
overall_branch_pct = project_stats["branch_coverage_pct"]
|
||||||
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes)
|
total_branches = sum(c.total_branches for c in classes)
|
||||||
# Branch totals: count from statement data (scoverage root has no branch count attr)
|
covered_branches = sum(c.covered_branches for c in classes)
|
||||||
total_branches = sum(c.total_branches for c in classes)
|
missed_branches = total_branches - covered_branches
|
||||||
covered_branches = sum(c.covered_branches for c in classes)
|
|
||||||
missed_branches = sum(c.missed_branches for c in classes)
|
|
||||||
|
|
||||||
lines.append("## Project Coverage Summary")
|
lines.append("# scoverage Coverage Gaps")
|
||||||
lines.append("")
|
lines.append(
|
||||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
|
||||||
lines.append(f"|-------------------|---------|-------|--------|----------|")
|
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
|
||||||
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |")
|
f"files with gaps: {len(classes)}"
|
||||||
lines.append(f"| Branch paths | {covered_branches:>7} | {total_branches:>5} | {missed_branches:>6} | {_pct_bar(overall_branch_pct)} |")
|
)
|
||||||
lines.append(f"| Files with gaps | {'—':>7} | {len(classes):>5} | {'—':>6} | {'—'} |")
|
|
||||||
lines.append(f"| Lines w/ br. gaps | {'—':>7} | {total_branch_lines:>5} | {'—':>6} | {'—'} |")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Files Requiring Tests")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("> Each entry lists the SOURCE FILE PATH, uncovered LINE NUMBERS,")
|
|
||||||
lines.append("> and the METHODS that contain those gaps.")
|
|
||||||
lines.append("> Write or extend unit/integration tests to exercise these paths.")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
|
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
|
||||||
|
|
||||||
for cls in sorted_classes:
|
for cls in sorted_classes:
|
||||||
lines.append(f"### `{cls.source_path}`")
|
uncov = cls.all_uncovered_lines
|
||||||
lines.append(f"**Class**: `{cls.class_name}`")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
|
||||||
lines.append(f"|--------------|---------|-------|--------|----------|")
|
|
||||||
lines.append(f"| Statements | {cls.covered_statements:>7} | {cls.total_statements:>5} | {cls.missed_statements:>6} | {_pct_bar(cls.stmt_coverage_pct)} |")
|
|
||||||
if cls.total_branches:
|
|
||||||
lines.append(f"| Branch paths | {cls.covered_branches:>7} | {cls.total_branches:>5} | {cls.missed_branches:>6} | {_pct_bar(cls.branch_coverage_pct)} |")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
uncov = cls.all_uncovered_lines
|
|
||||||
if uncov:
|
|
||||||
lines.append("#### ❌ Uncovered Statements")
|
|
||||||
lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
branch_lines = cls.uncovered_branch_lines
|
branch_lines = cls.uncovered_branch_lines
|
||||||
if branch_lines:
|
|
||||||
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)")
|
lines.append(f"## {cls.source_path}")
|
||||||
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`")
|
lines.append(
|
||||||
lines.append("")
|
f"stmt: {cls.stmt_coverage_pct:.1f}% ({cls.missed_statements} missed)"
|
||||||
|
+ (f" | branches: {cls.branch_coverage_pct:.1f}% ({cls.missed_branches} missed)"
|
||||||
|
if cls.total_branches else "")
|
||||||
|
)
|
||||||
|
if uncov:
|
||||||
|
lines.append(f"uncovered lines: {_compact_ranges(uncov)}")
|
||||||
|
only_branch = [l for l in branch_lines if l not in cls.all_uncovered_lines]
|
||||||
|
if only_branch:
|
||||||
|
lines.append(f"partial branches: {_compact_ranges(only_branch)}")
|
||||||
|
|
||||||
if cls.method_gaps:
|
if cls.method_gaps:
|
||||||
lines.append("#### Methods with Gaps")
|
lines.append("methods:")
|
||||||
lines.append("")
|
|
||||||
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
|
|
||||||
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
|
|
||||||
for mg in cls.method_gaps:
|
for mg in cls.method_gaps:
|
||||||
stmt_cell = f"{_pct_bar(mg.stmt_coverage_pct, 10)} ({mg.total_statements - mg.covered_statements}/{mg.total_statements} missed)"
|
parts = [f" {mg.short_name}"]
|
||||||
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a"
|
if mg.uncovered_lines:
|
||||||
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "—"
|
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
|
||||||
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "—"
|
if mg.uncovered_branch_lines:
|
||||||
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |")
|
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
|
||||||
lines.append("")
|
lines.append(" ".join(parts))
|
||||||
|
|
||||||
lines.append("**Action**: Add tests that exercise the lines/branches listed above.")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("## Quick Reference: All Uncovered Locations")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Copy-paste friendly list for IDE navigation or grep:")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("```")
|
|
||||||
for cls in sorted_classes:
|
|
||||||
for ln in cls.all_uncovered_lines:
|
|
||||||
lines.append(f"{cls.source_path}:{ln} # uncovered statement")
|
|
||||||
for ln in cls.uncovered_branch_lines:
|
|
||||||
if ln not in cls.all_uncovered_lines:
|
|
||||||
lines.append(f"{cls.source_path}:{ln} # partial branch")
|
|
||||||
lines.append("```")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scan-modules mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Candidate sub-paths within a module directory where scoverage.xml may live.
|
||||||
|
_SCOVERAGE_SUBPATHS = [
|
||||||
|
# Gradle / default layout
|
||||||
|
"build/reports/scoverageTest/scoverage.xml",
|
||||||
|
# sbt default (scala version wildcard resolved via glob)
|
||||||
|
"target/scala-*/scoverage-report/scoverage.xml",
|
||||||
|
# Maven / flat layout
|
||||||
|
"target/scoverage-report/scoverage.xml",
|
||||||
|
# Already at root of module
|
||||||
|
"scoverage.xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_scoverage_xml(module_dir: Path) -> Optional[Path]:
|
||||||
|
"""Return the first scoverage.xml found inside *module_dir*, or None."""
|
||||||
|
for pattern in _SCOVERAGE_SUBPATHS:
|
||||||
|
hits = sorted(module_dir.glob(pattern))
|
||||||
|
if hits:
|
||||||
|
return hits[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_module_gaps(module_name: str, classes: list[ClassGap], stmt_pct: float) -> str:
|
||||||
|
"""
|
||||||
|
One summary line per module. If coverage is not 100%, append an agent hint.
|
||||||
|
"""
|
||||||
|
if not classes:
|
||||||
|
return f"[{module_name}] stmt: {stmt_pct:.1f}% ✅"
|
||||||
|
|
||||||
|
line = f"[{module_name}] stmt: {stmt_pct:.1f}% files_with_gaps: {len(classes)}"
|
||||||
|
if stmt_pct < 100.0:
|
||||||
|
line += f" # hint: run ./coverage {module_name} for details"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan_modules(modules_dir: str, package_filter: Optional[str], min_coverage: float) -> None:
|
||||||
|
base = Path(modules_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
module_dirs = sorted(p for p in base.iterdir() if p.is_dir())
|
||||||
|
if not module_dirs:
|
||||||
|
print(f"No sub-directories found in {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for mod_dir in module_dirs:
|
||||||
|
if mod_dir.name.startswith("build"):
|
||||||
|
continue
|
||||||
|
xml_path = _find_scoverage_xml(mod_dir)
|
||||||
|
if xml_path is None:
|
||||||
|
missing.append(mod_dir.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
project_stats, classes = parse_scoverage_xml(str(xml_path))
|
||||||
|
|
||||||
|
if package_filter:
|
||||||
|
classes = [c for c in classes if c.class_name.startswith(package_filter)]
|
||||||
|
if min_coverage > 0:
|
||||||
|
classes = [c for c in classes if c.stmt_coverage_pct < min_coverage]
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
format_module_gaps(mod_dir.name, classes, project_stats["stmt_coverage_pct"])
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n".join(results))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"\n# Modules without scoverage.xml: {', '.join(missing)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Entry point
|
# Entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -519,7 +539,13 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Report missing statement & branch coverage from a scoverage XML report."
|
description="Report missing statement & branch coverage from a scoverage XML report."
|
||||||
)
|
)
|
||||||
parser.add_argument("xml_file", help="Path to scoverage.xml report file")
|
|
||||||
|
# Positional xml_file is optional when --scan-modules is used
|
||||||
|
parser.add_argument(
|
||||||
|
"xml_file",
|
||||||
|
nargs="?",
|
||||||
|
help="Path to scoverage.xml report file (not required with --scan-modules)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", "-o",
|
"--output", "-o",
|
||||||
choices=["agent", "json", "markdown"],
|
choices=["agent", "json", "markdown"],
|
||||||
@@ -537,8 +563,30 @@ def main() -> None:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
|
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
|
||||||
)
|
)
|
||||||
|
# ── Scan-modules mode ──────────────────────────────────────────────────
|
||||||
|
parser.add_argument(
|
||||||
|
"--scan-modules",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Scan every sub-directory of --modules-dir for a scoverage.xml "
|
||||||
|
"and print a compact coverage-gaps summary per module."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modules-dir",
|
||||||
|
default="./modules",
|
||||||
|
help="Root directory that contains one sub-directory per module (default: ./modules)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# ── Scan-modules path (explicit flag, or default when no xml_file given) ──
|
||||||
|
if args.scan_modules or not args.xml_file:
|
||||||
|
run_scan_modules(args.modules_dir, args.package_filter, args.min_coverage)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Single-file path ──────────────────────────────────────────────────
|
||||||
|
|
||||||
xml_path = Path(args.xml_file)
|
xml_path = Path(args.xml_file)
|
||||||
if not xml_path.exists():
|
if not xml_path.exists():
|
||||||
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
||||||
@@ -565,4 +613,4 @@ def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import glob,re
|
||||||
|
mods=['api','core','io','rule','ui']
|
||||||
|
tot=0
|
||||||
|
for m in mods:
|
||||||
|
s=0
|
||||||
|
for f in glob.glob(f'modules/{m}/build/test-results/test/TEST-*.xml'):
|
||||||
|
txt=open(f,encoding='utf-8').read(300)
|
||||||
|
m2=re.search(r'tests="(\d+)"',txt)
|
||||||
|
if m2:s+=int(m2.group(1))
|
||||||
|
print(f'{m}: {s}')
|
||||||
|
tot+=s
|
||||||
|
print('overall:',tot)
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Gap Reporter
|
||||||
|
Scans JUnit XML test results under modules/*/build/test-results/*.xml and
|
||||||
|
outputs a minimal summary optimised for agent consumption.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python test_gaps.py # scan all modules (default)
|
||||||
|
python test_gaps.py --module chess # single module
|
||||||
|
python test_gaps.py --module all # explicit all
|
||||||
|
python test_gaps.py --modules-dir ./modules
|
||||||
|
python test_gaps.py --results-subdir build/test-results
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestCase:
|
||||||
|
classname: str
|
||||||
|
name: str
|
||||||
|
time: float
|
||||||
|
failure: Optional[str] = None # message if failed
|
||||||
|
error: Optional[str] = None # message if errored
|
||||||
|
skipped: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_class(self) -> str:
|
||||||
|
return self.classname.split(".")[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.failure is not None:
|
||||||
|
return "FAIL"
|
||||||
|
if self.error is not None:
|
||||||
|
return "ERROR"
|
||||||
|
if self.skipped:
|
||||||
|
return "SKIP"
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SuiteResult:
|
||||||
|
name: str
|
||||||
|
total: int
|
||||||
|
failures: int
|
||||||
|
errors: int
|
||||||
|
skipped: int
|
||||||
|
time: float
|
||||||
|
cases: list[TestCase] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> int:
|
||||||
|
return self.total - self.failures - self.errors - self.skipped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_clean(self) -> bool:
|
||||||
|
return self.failures == 0 and self.errors == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_cases(self) -> list[TestCase]:
|
||||||
|
return [c for c in self.cases if c.status in ("FAIL", "ERROR")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped_cases(self) -> list[TestCase]:
|
||||||
|
return [c for c in self.cases if c.skipped]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleResult:
|
||||||
|
name: str
|
||||||
|
suites: list[SuiteResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int: return sum(s.total for s in self.suites)
|
||||||
|
@property
|
||||||
|
def failures(self) -> int: return sum(s.failures for s in self.suites)
|
||||||
|
@property
|
||||||
|
def errors(self) -> int: return sum(s.errors for s in self.suites)
|
||||||
|
@property
|
||||||
|
def skipped(self) -> int: return sum(s.skipped for s in self.suites)
|
||||||
|
@property
|
||||||
|
def passed(self) -> int: return sum(s.passed for s in self.suites)
|
||||||
|
@property
|
||||||
|
def is_clean(self) -> bool: return self.failures == 0 and self.errors == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_cases(self) -> list[TestCase]:
|
||||||
|
return [c for s in self.suites for c in s.bad_cases]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped_cases(self) -> list[TestCase]:
|
||||||
|
return [c for s in self.suites for c in s.skipped_cases]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_suite_xml(xml_path: Path) -> SuiteResult:
|
||||||
|
tree = ET.parse(xml_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Handle both <testsuite> root and <testsuites> wrapper
|
||||||
|
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||||
|
|
||||||
|
# Merge multiple suites from one file into a single SuiteResult
|
||||||
|
total = failures = errors = skipped = 0
|
||||||
|
elapsed = 0.0
|
||||||
|
name = xml_path.stem
|
||||||
|
cases: list[TestCase] = []
|
||||||
|
|
||||||
|
for suite in suites:
|
||||||
|
total += int(suite.get("tests", 0))
|
||||||
|
failures += int(suite.get("failures", 0))
|
||||||
|
errors += int(suite.get("errors", 0))
|
||||||
|
skipped += int(suite.get("skipped", 0))
|
||||||
|
elapsed += float(suite.get("time", 0.0))
|
||||||
|
if suite.get("name"):
|
||||||
|
name = suite.get("name")
|
||||||
|
|
||||||
|
for tc in suite.findall("testcase"):
|
||||||
|
fail_el = tc.find("failure")
|
||||||
|
err_el = tc.find("error")
|
||||||
|
skip_el = tc.find("skipped")
|
||||||
|
cases.append(TestCase(
|
||||||
|
classname=tc.get("classname", ""),
|
||||||
|
name=tc.get("name", ""),
|
||||||
|
time=float(tc.get("time", 0.0)),
|
||||||
|
failure=fail_el.get("message", fail_el.text or "") if fail_el is not None else None,
|
||||||
|
error=err_el.get("message", err_el.text or "") if err_el is not None else None,
|
||||||
|
skipped=skip_el is not None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return SuiteResult(
|
||||||
|
name=name, total=total, failures=failures,
|
||||||
|
errors=errors, skipped=skipped, time=elapsed, cases=cases,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(module_dir: Path, results_subdir: str) -> Optional[ModuleResult]:
|
||||||
|
results_dir = module_dir / results_subdir
|
||||||
|
if not results_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
xml_files = sorted(results_dir.glob("*.xml"))
|
||||||
|
if not xml_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mod = ModuleResult(name=module_dir.name)
|
||||||
|
for xml_path in xml_files:
|
||||||
|
try:
|
||||||
|
mod.suites.append(parse_suite_xml(xml_path))
|
||||||
|
except ET.ParseError:
|
||||||
|
pass # skip malformed files silently
|
||||||
|
return mod if mod.suites else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Formatter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _truncate(text: str, max_len: int = 120) -> str:
|
||||||
|
text = " ".join(text.split()) # collapse whitespace
|
||||||
|
return text[:max_len] + "…" if len(text) > max_len else text
|
||||||
|
|
||||||
|
|
||||||
|
def format_module(mod: ModuleResult) -> str:
|
||||||
|
parts = [f"[{mod.name}]"]
|
||||||
|
|
||||||
|
if mod.is_clean and mod.skipped == 0:
|
||||||
|
parts.append(f"tests: {mod.total} ✅")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
parts.append(f"tests: {mod.total}")
|
||||||
|
if mod.failures: parts.append(f"failed: {mod.failures}")
|
||||||
|
if mod.errors: parts.append(f"errors: {mod.errors}")
|
||||||
|
if mod.skipped: parts.append(f"skipped: {mod.skipped}")
|
||||||
|
|
||||||
|
# Agent hint only when there are actual failures/errors
|
||||||
|
if not mod.is_clean:
|
||||||
|
parts.append(f" # hint: run ./test {mod.name} for details")
|
||||||
|
|
||||||
|
lines = [" ".join(parts)]
|
||||||
|
|
||||||
|
# List each failed/errored test — this IS the actionable info
|
||||||
|
for tc in mod.bad_cases:
|
||||||
|
msg = tc.failure if tc.failure is not None else tc.error
|
||||||
|
label = f" {tc.status}: {tc.short_class} > {tc.name}"
|
||||||
|
if msg:
|
||||||
|
label += f" [{_truncate(msg, 80)}]"
|
||||||
|
lines.append(label)
|
||||||
|
|
||||||
|
# Skipped: compact, one line total
|
||||||
|
if mod.skipped_cases:
|
||||||
|
skipped_names = ", ".join(
|
||||||
|
f"{c.short_class}.{c.name}" for c in mod.skipped_cases[:5]
|
||||||
|
)
|
||||||
|
if len(mod.skipped_cases) > 5:
|
||||||
|
skipped_names += f" (+{len(mod.skipped_cases) - 5} more)"
|
||||||
|
lines.append(f" SKIP: {skipped_names}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(modules_dir: str, results_subdir: str, module_filter: Optional[str]) -> None:
|
||||||
|
base = Path(modules_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve which module dirs to scan
|
||||||
|
if module_filter and module_filter != "all":
|
||||||
|
mod_dir = base / module_filter
|
||||||
|
if not mod_dir.is_dir():
|
||||||
|
print(f"ERROR: module not found: {mod_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
candidates = [mod_dir]
|
||||||
|
else:
|
||||||
|
candidates = sorted(p for p in base.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for mod_dir in candidates:
|
||||||
|
if mod_dir.name.startswith("build"):
|
||||||
|
continue
|
||||||
|
mod = load_module(mod_dir, results_subdir)
|
||||||
|
if mod is None:
|
||||||
|
missing.append(mod_dir.name)
|
||||||
|
continue
|
||||||
|
results.append(format_module(mod))
|
||||||
|
|
||||||
|
print("\n".join(results))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"\n# Modules without test results: {', '.join(missing)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Minimal test-gap reporter for JUnit XML results across modules."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--module", "-m",
|
||||||
|
nargs="?",
|
||||||
|
const="all",
|
||||||
|
default="all",
|
||||||
|
help="Module name to scan, or 'all' (default: all)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modules-dir",
|
||||||
|
default="./modules",
|
||||||
|
help="Root directory containing one sub-directory per module (default: ./modules)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--results-subdir",
|
||||||
|
default="build/test-results/test",
|
||||||
|
help="Sub-path inside each module dir where *.xml files live (default: build/test-results/test)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
filter_ = None if args.module == "all" else args.module
|
||||||
|
run(args.modules_dir, args.results_subdir, filter_)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,3 +4,20 @@
|
|||||||
## (2026-03-29)
|
## (2026-03-29)
|
||||||
## (2026-03-31)
|
## (2026-03-31)
|
||||||
## (2026-04-01)
|
## (2026-04-01)
|
||||||
|
## (2026-04-01)
|
||||||
|
## (2026-04-01)
|
||||||
|
## (2026-04-02)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
## (2026-04-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
## (2026-04-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("passed", "skipped", "failed")
|
events("skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ object Board:
|
|||||||
val captured = b.get(to)
|
val captured = b.get(to)
|
||||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||||
(updatedBoard, captured)
|
(updatedBoard, captured)
|
||||||
|
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||||
|
val (updatedBoard, _) = b.withMove(move.from, move.to)
|
||||||
|
updatedBoard
|
||||||
def pieces: Map[Square, Piece] = b
|
def pieces: Map[Square, Piece] = b
|
||||||
|
|
||||||
val initial: Board =
|
val initial: Board =
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified castling rights tracker for all four sides.
|
||||||
|
* Tracks whether castling is still available for each side and direction.
|
||||||
|
*
|
||||||
|
* @param whiteKingSide White's king-side castling (0-0) still legally available
|
||||||
|
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
|
||||||
|
* @param blackKingSide Black's king-side castling (0-0) still legally available
|
||||||
|
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
|
||||||
|
*/
|
||||||
|
final case class CastlingRights(
|
||||||
|
whiteKingSide: Boolean,
|
||||||
|
whiteQueenSide: Boolean,
|
||||||
|
blackKingSide: Boolean,
|
||||||
|
blackQueenSide: Boolean
|
||||||
|
):
|
||||||
|
/**
|
||||||
|
* Check if either side has any castling rights remaining.
|
||||||
|
*/
|
||||||
|
def hasAnyRights: Boolean =
|
||||||
|
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific color has any castling rights remaining.
|
||||||
|
*/
|
||||||
|
def hasRights(color: Color): Boolean = color match
|
||||||
|
case Color.White => whiteKingSide || whiteQueenSide
|
||||||
|
case Color.Black => blackKingSide || blackQueenSide
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all castling rights for a specific color.
|
||||||
|
*/
|
||||||
|
def revokeColor(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||||
|
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific castling right.
|
||||||
|
*/
|
||||||
|
def revokeKingSide(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteKingSide = false)
|
||||||
|
case Color.Black => copy(blackKingSide = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific castling right.
|
||||||
|
*/
|
||||||
|
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteQueenSide = false)
|
||||||
|
case Color.Black => copy(blackQueenSide = false)
|
||||||
|
|
||||||
|
object CastlingRights:
|
||||||
|
/** No castling rights for any side. */
|
||||||
|
val None: CastlingRights = CastlingRights(
|
||||||
|
whiteKingSide = false,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All castling rights available. */
|
||||||
|
val All: CastlingRights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = true,
|
||||||
|
blackKingSide = true,
|
||||||
|
blackQueenSide = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||||
|
val Initial: CastlingRights = All
|
||||||
@@ -39,3 +39,19 @@ object Square:
|
|||||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
||||||
)
|
)
|
||||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||||
|
|
||||||
|
val all: IndexedSeq[Square] =
|
||||||
|
for
|
||||||
|
r <- Rank.values.toIndexedSeq
|
||||||
|
f <- File.values.toIndexedSeq
|
||||||
|
yield Square(f, r)
|
||||||
|
|
||||||
|
/** Compute a target square by offsetting file and rank.
|
||||||
|
* Returns None if the resulting square is outside the board (0-7 range). */
|
||||||
|
extension (sq: Square)
|
||||||
|
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||||
|
val newFileOrd = sq.file.ordinal + fileDelta
|
||||||
|
val newRankOrd = sq.rank.ordinal + rankDelta
|
||||||
|
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
|
||||||
|
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
|
||||||
|
else None
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
|
/** Immutable bundle of complete game state.
|
||||||
|
* All state changes produce new GameContext instances.
|
||||||
|
*/
|
||||||
|
case class GameContext(
|
||||||
|
board: Board,
|
||||||
|
turn: Color,
|
||||||
|
castlingRights: CastlingRights,
|
||||||
|
enPassantSquare: Option[Square],
|
||||||
|
halfMoveClock: Int,
|
||||||
|
moves: List[Move]
|
||||||
|
):
|
||||||
|
/** Create new context with updated board. */
|
||||||
|
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||||
|
|
||||||
|
/** Create new context with updated turn. */
|
||||||
|
def withTurn(newTurn: Color): GameContext = copy(turn = newTurn)
|
||||||
|
|
||||||
|
/** Create new context with updated castling rights. */
|
||||||
|
def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights)
|
||||||
|
|
||||||
|
/** Create new context with updated en passant square. */
|
||||||
|
def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq)
|
||||||
|
|
||||||
|
/** Create new context with updated half-move clock. */
|
||||||
|
def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock)
|
||||||
|
|
||||||
|
/** Create new context with move appended to history. */
|
||||||
|
def withMove(move: Move): GameContext = copy(moves = moves :+ move)
|
||||||
|
|
||||||
|
object GameContext:
|
||||||
|
/** Initial position: white to move, all castling rights, no en passant. */
|
||||||
|
def initial: GameContext = GameContext(
|
||||||
|
board = Board.initial,
|
||||||
|
turn = Color.White,
|
||||||
|
castlingRights = CastlingRights.Initial,
|
||||||
|
enPassantSquare = None,
|
||||||
|
halfMoveClock = 0,
|
||||||
|
moves = List.empty
|
||||||
|
)
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package de.nowchess.api.game
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, Square}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Castling availability flags for one side.
|
|
||||||
*
|
|
||||||
* @param kingSide king-side castling still legally available
|
|
||||||
* @param queenSide queen-side castling still legally available
|
|
||||||
*/
|
|
||||||
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
|
|
||||||
|
|
||||||
object CastlingRights:
|
|
||||||
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
|
|
||||||
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
|
|
||||||
|
|
||||||
/** Outcome of a finished game. */
|
|
||||||
enum GameResult:
|
|
||||||
case WhiteWins
|
|
||||||
case BlackWins
|
|
||||||
case Draw
|
|
||||||
|
|
||||||
/** Lifecycle state of a game. */
|
|
||||||
enum GameStatus:
|
|
||||||
case NotStarted
|
|
||||||
case InProgress
|
|
||||||
case Finished(result: GameResult)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A FEN-compatible snapshot of board and game state.
|
|
||||||
*
|
|
||||||
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
|
|
||||||
* separated by '/'). All other fields mirror standard FEN fields.
|
|
||||||
*
|
|
||||||
* @param piecePlacement FEN piece-placement field, e.g.
|
|
||||||
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
||||||
* @param activeColor side to move
|
|
||||||
* @param castlingWhite castling rights for White
|
|
||||||
* @param castlingBlack castling rights for Black
|
|
||||||
* @param enPassantTarget square behind the double-pushed pawn, if any
|
|
||||||
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
|
|
||||||
* @param fullMoveNumber increments after Black's move, starts at 1
|
|
||||||
* @param status current lifecycle status of the game
|
|
||||||
*/
|
|
||||||
final case class GameState(
|
|
||||||
piecePlacement: String,
|
|
||||||
activeColor: Color,
|
|
||||||
castlingWhite: CastlingRights,
|
|
||||||
castlingBlack: CastlingRights,
|
|
||||||
enPassantTarget: Option[Square],
|
|
||||||
halfMoveClock: Int,
|
|
||||||
fullMoveNumber: Int,
|
|
||||||
status: GameStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
object GameState:
|
|
||||||
/** Standard starting position. */
|
|
||||||
val initial: GameState = GameState(
|
|
||||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
|
||||||
activeColor = Color.White,
|
|
||||||
castlingWhite = CastlingRights.Both,
|
|
||||||
castlingBlack = CastlingRights.Both,
|
|
||||||
enPassantTarget = None,
|
|
||||||
halfMoveClock = 0,
|
|
||||||
fullMoveNumber = 1,
|
|
||||||
status = GameStatus.InProgress
|
|
||||||
)
|
|
||||||
@@ -9,7 +9,7 @@ enum PromotionPiece:
|
|||||||
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
||||||
enum MoveType:
|
enum MoveType:
|
||||||
/** A normal move or capture with no special rule. */
|
/** A normal move or capture with no special rule. */
|
||||||
case Normal
|
case Normal(isCapture: Boolean = false)
|
||||||
/** Kingside castling (O-O). */
|
/** Kingside castling (O-O). */
|
||||||
case CastleKingside
|
case CastleKingside
|
||||||
/** Queenside castling (O-O-O). */
|
/** Queenside castling (O-O-O). */
|
||||||
@@ -29,5 +29,5 @@ enum MoveType:
|
|||||||
final case class Move(
|
final case class Move(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveType: MoveType = MoveType.Normal
|
moveType: MoveType = MoveType.Normal()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -7,13 +8,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
private val e2 = Square(File.E, Rank.R2)
|
private val e2 = Square(File.E, Rank.R2)
|
||||||
private val e4 = Square(File.E, Rank.R4)
|
private val e4 = Square(File.E, Rank.R4)
|
||||||
private val d7 = Square(File.D, Rank.R7)
|
|
||||||
|
|
||||||
test("pieceAt returns Some for occupied square") {
|
test("pieceAt resolves occupied and empty squares") {
|
||||||
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
}
|
|
||||||
|
|
||||||
test("pieceAt returns None for empty square") {
|
|
||||||
Board.initial.pieceAt(e4) shouldBe None
|
Board.initial.pieceAt(e4) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,38 +31,20 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
board.pieceAt(from) shouldBe None
|
board.pieceAt(from) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
test("pieces returns the underlying map") {
|
test("Board.apply and pieces expose the wrapped map") {
|
||||||
val map = Map(e2 -> Piece.WhitePawn)
|
|
||||||
val b = Board(map)
|
|
||||||
b.pieces shouldBe map
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Board.apply constructs board from map") {
|
|
||||||
val map = Map(e2 -> Piece.WhitePawn)
|
val map = Map(e2 -> Piece.WhitePawn)
|
||||||
val b = Board(map)
|
val b = Board(map)
|
||||||
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
|
b.pieces shouldBe map
|
||||||
}
|
}
|
||||||
|
|
||||||
test("initial board has 32 pieces") {
|
test("initial board has expected material and pawn placement") {
|
||||||
Board.initial.pieces should have size 32
|
Board.initial.pieces should have size 32
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board has 16 white pieces") {
|
|
||||||
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board has 16 black pieces") {
|
|
||||||
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board white pawns on rank 2") {
|
|
||||||
File.values.foreach { file =>
|
File.values.foreach { file =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board black pawns on rank 7") {
|
|
||||||
File.values.foreach { file =>
|
|
||||||
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,17 +80,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
test("updated adds or replaces piece at square") {
|
test("updated adds and replaces piece at squares") {
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
val updated = b.updated(e4, Piece.WhiteKnight)
|
val added = b.updated(e4, Piece.WhiteKnight)
|
||||||
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
}
|
|
||||||
|
|
||||||
test("updated replaces existing piece") {
|
val replaced = b.updated(e2, Piece.WhiteKnight)
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
||||||
val updated = b.updated(e2, Piece.WhiteKnight)
|
|
||||||
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("removed deletes piece from board") {
|
test("removed deletes piece from board") {
|
||||||
@@ -120,3 +96,13 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
removed.pieceAt(e2) shouldBe None
|
removed.pieceAt(e2) shouldBe None
|
||||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("applyMove uses move.from and move.to to relocate a piece") {
|
||||||
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
|
|
||||||
|
val moved = b.applyMove(Move(e2, e4))
|
||||||
|
|
||||||
|
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||||
|
moved.pieceAt(e2) shouldBe None
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("hasAnyRights and hasRights reflect current flags"):
|
||||||
|
val rights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = true
|
||||||
|
)
|
||||||
|
|
||||||
|
rights.hasAnyRights shouldBe true
|
||||||
|
rights.hasRights(Color.White) shouldBe true
|
||||||
|
rights.hasRights(Color.Black) shouldBe true
|
||||||
|
|
||||||
|
CastlingRights.None.hasAnyRights shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.White) shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.Black) shouldBe false
|
||||||
|
|
||||||
|
test("revokeColor clears both castling sides for selected color"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteRevoked = all.revokeColor(Color.White)
|
||||||
|
whiteRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteRevoked.whiteQueenSide shouldBe false
|
||||||
|
whiteRevoked.blackKingSide shouldBe true
|
||||||
|
whiteRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackRevoked = all.revokeColor(Color.Black)
|
||||||
|
blackRevoked.whiteKingSide shouldBe true
|
||||||
|
blackRevoked.whiteQueenSide shouldBe true
|
||||||
|
blackRevoked.blackKingSide shouldBe false
|
||||||
|
blackRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("revokeKingSide and revokeQueenSide disable only requested side"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
|
||||||
|
whiteKingSideRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteKingSideRevoked.whiteQueenSide shouldBe true
|
||||||
|
|
||||||
|
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
|
||||||
|
whiteQueenSideRevoked.whiteKingSide shouldBe true
|
||||||
|
whiteQueenSideRevoked.whiteQueenSide shouldBe false
|
||||||
|
|
||||||
|
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
|
||||||
|
blackKingSideRevoked.blackKingSide shouldBe false
|
||||||
|
blackKingSideRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||||
|
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||||
|
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class ColorTest extends AnyFunSuite with Matchers:
|
class ColorTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("White.opposite returns Black") {
|
test("Color values expose opposite and label consistently"):
|
||||||
Color.White.opposite shouldBe Color.Black
|
val cases = List(
|
||||||
}
|
(Color.White, Color.Black, "White"),
|
||||||
|
(Color.Black, Color.White, "Black")
|
||||||
|
)
|
||||||
|
|
||||||
test("Black.opposite returns White") {
|
cases.foreach { (color, opposite, label) =>
|
||||||
Color.Black.opposite shouldBe Color.White
|
color.opposite shouldBe opposite
|
||||||
}
|
color.label shouldBe label
|
||||||
|
}
|
||||||
test("White.label returns 'White'") {
|
|
||||||
Color.White.label shouldBe "White"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Black.label returns 'Black'") {
|
|
||||||
Color.Black.label shouldBe "Black"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
|
|||||||
p.pieceType shouldBe PieceType.Queen
|
p.pieceType shouldBe PieceType.Queen
|
||||||
}
|
}
|
||||||
|
|
||||||
test("WhitePawn convenience constant") {
|
test("all convenience constants map to expected color and piece type") {
|
||||||
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
|
val expected = List(
|
||||||
}
|
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
||||||
|
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
|
||||||
|
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
||||||
|
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||||
|
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||||
|
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||||
|
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||||
|
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
||||||
|
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||||
|
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||||
|
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||||
|
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
|
||||||
|
)
|
||||||
|
|
||||||
test("WhiteKnight convenience constant") {
|
expected.foreach { case (actual, wanted) =>
|
||||||
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight)
|
actual shouldBe wanted
|
||||||
}
|
}
|
||||||
|
|
||||||
test("WhiteBishop convenience constant") {
|
|
||||||
Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteRook convenience constant") {
|
|
||||||
Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteQueen convenience constant") {
|
|
||||||
Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteKing convenience constant") {
|
|
||||||
Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackPawn convenience constant") {
|
|
||||||
Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackKnight convenience constant") {
|
|
||||||
Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackBishop convenience constant") {
|
|
||||||
Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackRook convenience constant") {
|
|
||||||
Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackQueen convenience constant") {
|
|
||||||
Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackKing convenience constant") {
|
|
||||||
Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PieceTypeTest extends AnyFunSuite with Matchers:
|
class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("Pawn.label returns 'Pawn'") {
|
test("PieceType values expose the expected labels"):
|
||||||
PieceType.Pawn.label shouldBe "Pawn"
|
val expectedLabels = List(
|
||||||
}
|
PieceType.Pawn -> "Pawn",
|
||||||
|
PieceType.Knight -> "Knight",
|
||||||
|
PieceType.Bishop -> "Bishop",
|
||||||
|
PieceType.Rook -> "Rook",
|
||||||
|
PieceType.Queen -> "Queen",
|
||||||
|
PieceType.King -> "King"
|
||||||
|
)
|
||||||
|
|
||||||
test("Knight.label returns 'Knight'") {
|
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||||
PieceType.Knight.label shouldBe "Knight"
|
pieceType.label shouldBe expectedLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Bishop.label returns 'Bishop'") {
|
|
||||||
PieceType.Bishop.label shouldBe "Bishop"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Rook.label returns 'Rook'") {
|
|
||||||
PieceType.Rook.label shouldBe "Rook"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Queen.label returns 'Queen'") {
|
|
||||||
PieceType.Queen.label shouldBe "Queen"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("King.label returns 'King'") {
|
|
||||||
PieceType.King.label shouldBe "King"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,58 +5,33 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class SquareTest extends AnyFunSuite with Matchers:
|
class SquareTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("Square.toString produces lowercase file and rank number") {
|
test("toString renders algebraic notation for edge and middle squares") {
|
||||||
Square(File.E, Rank.R4).toString shouldBe "e4"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Square.toString for a1") {
|
|
||||||
Square(File.A, Rank.R1).toString shouldBe "a1"
|
Square(File.A, Rank.R1).toString shouldBe "a1"
|
||||||
}
|
Square(File.E, Rank.R4).toString shouldBe "e4"
|
||||||
|
|
||||||
test("Square.toString for h8") {
|
|
||||||
Square(File.H, Rank.R8).toString shouldBe "h8"
|
Square(File.H, Rank.R8).toString shouldBe "h8"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square e4") {
|
test("fromAlgebraic parses valid coordinates including case-insensitive files") {
|
||||||
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4))
|
val expected = List(
|
||||||
|
"a1" -> Square(File.A, Rank.R1),
|
||||||
|
"e4" -> Square(File.E, Rank.R4),
|
||||||
|
"h8" -> Square(File.H, Rank.R8),
|
||||||
|
"E4" -> Square(File.E, Rank.R4)
|
||||||
|
)
|
||||||
|
expected.foreach { case (raw, sq) =>
|
||||||
|
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square a1") {
|
test("fromAlgebraic rejects malformed coordinates") {
|
||||||
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
|
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
|
||||||
|
Square.fromAlgebraic(raw) shouldBe None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square h8") {
|
test("offset returns Some in-bounds and None out-of-bounds") {
|
||||||
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
|
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
|
||||||
|
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||||
|
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic is case-insensitive for file") {
|
|
||||||
Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for empty string") {
|
|
||||||
Square.fromAlgebraic("") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for string too short") {
|
|
||||||
Square.fromAlgebraic("e") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for string too long") {
|
|
||||||
Square.fromAlgebraic("e42") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for invalid file character") {
|
|
||||||
Square.fromAlgebraic("z4") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for non-digit rank") {
|
|
||||||
Square.fromAlgebraic("ex") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for rank 0") {
|
|
||||||
Square.fromAlgebraic("e0") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for rank 9") {
|
|
||||||
Square.fromAlgebraic("e9") shouldBe None
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class GameContextTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("GameContext.initial exposes expected default state"):
|
||||||
|
val initial = GameContext.initial
|
||||||
|
|
||||||
|
initial.board shouldBe Board.initial
|
||||||
|
initial.turn shouldBe Color.White
|
||||||
|
initial.castlingRights shouldBe CastlingRights.Initial
|
||||||
|
initial.enPassantSquare shouldBe None
|
||||||
|
initial.halfMoveClock shouldBe 0
|
||||||
|
initial.moves shouldBe List.empty
|
||||||
|
|
||||||
|
test("withBoard updates only board"):
|
||||||
|
val square = Square(File.E, Rank.R4)
|
||||||
|
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
||||||
|
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||||
|
updated.board shouldBe updatedBoard
|
||||||
|
updated.turn shouldBe GameContext.initial.turn
|
||||||
|
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||||
|
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
|
||||||
|
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
|
||||||
|
updated.moves shouldBe GameContext.initial.moves
|
||||||
|
|
||||||
|
test("withers update only targeted fields"):
|
||||||
|
val initial = GameContext.initial
|
||||||
|
val rights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = true
|
||||||
|
)
|
||||||
|
val square = Some(Square(File.E, Rank.R3))
|
||||||
|
val updatedTurn = initial.withTurn(Color.Black)
|
||||||
|
val updatedRights = initial.withCastlingRights(rights)
|
||||||
|
val updatedEp = initial.withEnPassantSquare(square)
|
||||||
|
val updatedClock = initial.withHalfMoveClock(17)
|
||||||
|
|
||||||
|
updatedTurn.turn shouldBe Color.Black
|
||||||
|
updatedTurn.board shouldBe initial.board
|
||||||
|
|
||||||
|
updatedRights.castlingRights shouldBe rights
|
||||||
|
updatedRights.turn shouldBe initial.turn
|
||||||
|
|
||||||
|
updatedEp.enPassantSquare shouldBe square
|
||||||
|
updatedEp.castlingRights shouldBe initial.castlingRights
|
||||||
|
|
||||||
|
updatedClock.halfMoveClock shouldBe 17
|
||||||
|
updatedClock.moves shouldBe initial.moves
|
||||||
|
|
||||||
|
test("withMove appends move to history"):
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||||
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package de.nowchess.api.game
|
|
||||||
|
|
||||||
import de.nowchess.api.board.Color
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameStateTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("CastlingRights.None has both flags false") {
|
|
||||||
CastlingRights.None.kingSide shouldBe false
|
|
||||||
CastlingRights.None.queenSide shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
test("CastlingRights.Both has both flags true") {
|
|
||||||
CastlingRights.Both.kingSide shouldBe true
|
|
||||||
CastlingRights.Both.queenSide shouldBe true
|
|
||||||
}
|
|
||||||
|
|
||||||
test("CastlingRights constructor sets fields") {
|
|
||||||
val cr = CastlingRights(kingSide = true, queenSide = false)
|
|
||||||
cr.kingSide shouldBe true
|
|
||||||
cr.queenSide shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameResult cases exist") {
|
|
||||||
GameResult.WhiteWins shouldBe GameResult.WhiteWins
|
|
||||||
GameResult.BlackWins shouldBe GameResult.BlackWins
|
|
||||||
GameResult.Draw shouldBe GameResult.Draw
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.NotStarted") {
|
|
||||||
GameStatus.NotStarted shouldBe GameStatus.NotStarted
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.InProgress") {
|
|
||||||
GameStatus.InProgress shouldBe GameStatus.InProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.Finished carries result") {
|
|
||||||
val status = GameStatus.Finished(GameResult.Draw)
|
|
||||||
status shouldBe GameStatus.Finished(GameResult.Draw)
|
|
||||||
status match
|
|
||||||
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
|
|
||||||
case _ => fail("expected Finished")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial has standard FEN piece placement") {
|
|
||||||
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial active color is White") {
|
|
||||||
GameState.initial.activeColor shouldBe Color.White
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial white has full castling rights") {
|
|
||||||
GameState.initial.castlingWhite shouldBe CastlingRights.Both
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial black has full castling rights") {
|
|
||||||
GameState.initial.castlingBlack shouldBe CastlingRights.Both
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial en-passant target is None") {
|
|
||||||
GameState.initial.enPassantTarget shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial half-move clock is 0") {
|
|
||||||
GameState.initial.halfMoveClock shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial full-move number is 1") {
|
|
||||||
GameState.initial.fullMoveNumber shouldBe 1
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial status is InProgress") {
|
|
||||||
GameState.initial.status shouldBe GameStatus.InProgress
|
|
||||||
}
|
|
||||||
@@ -9,48 +9,26 @@ class MoveTest extends AnyFunSuite with Matchers:
|
|||||||
private val e2 = Square(File.E, Rank.R2)
|
private val e2 = Square(File.E, Rank.R2)
|
||||||
private val e4 = Square(File.E, Rank.R4)
|
private val e4 = Square(File.E, Rank.R4)
|
||||||
|
|
||||||
test("Move defaults moveType to Normal") {
|
test("Move defaults to Normal and keeps from/to squares") {
|
||||||
val m = Move(e2, e4)
|
|
||||||
m.moveType shouldBe MoveType.Normal
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move stores from and to squares") {
|
|
||||||
val m = Move(e2, e4)
|
val m = Move(e2, e4)
|
||||||
m.from shouldBe e2
|
m.from shouldBe e2
|
||||||
m.to shouldBe e4
|
m.to shouldBe e4
|
||||||
|
m.moveType shouldBe MoveType.Normal()
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Move with CastleKingside moveType") {
|
test("Move accepts all supported move types") {
|
||||||
val m = Move(e2, e4, MoveType.CastleKingside)
|
val moveTypes = List(
|
||||||
m.moveType shouldBe MoveType.CastleKingside
|
MoveType.Normal(isCapture = true),
|
||||||
}
|
MoveType.CastleKingside,
|
||||||
|
MoveType.CastleQueenside,
|
||||||
|
MoveType.EnPassant,
|
||||||
|
MoveType.Promotion(PromotionPiece.Queen),
|
||||||
|
MoveType.Promotion(PromotionPiece.Rook),
|
||||||
|
MoveType.Promotion(PromotionPiece.Bishop),
|
||||||
|
MoveType.Promotion(PromotionPiece.Knight)
|
||||||
|
)
|
||||||
|
|
||||||
test("Move with CastleQueenside moveType") {
|
moveTypes.foreach { moveType =>
|
||||||
val m = Move(e2, e4, MoveType.CastleQueenside)
|
Move(e2, e4, moveType).moveType shouldBe moveType
|
||||||
m.moveType shouldBe MoveType.CastleQueenside
|
}
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with EnPassant moveType") {
|
|
||||||
val m = Move(e2, e4, MoveType.EnPassant)
|
|
||||||
m.moveType shouldBe MoveType.EnPassant
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Queen") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Knight") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Bishop") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Rook") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("PlayerId.apply wraps a string") {
|
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||||
val id = PlayerId("player-123")
|
val raw = "player-123"
|
||||||
id.value shouldBe "player-123"
|
val id = PlayerId(raw)
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerId.value unwraps to original string") {
|
id.value shouldBe raw
|
||||||
val raw = "abc-456"
|
|
||||||
PlayerId(raw).value shouldBe raw
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerInfo holds id and displayName") {
|
val playerId = PlayerId("p1")
|
||||||
val id = PlayerId("p1")
|
val info = PlayerInfo(playerId, "Magnus")
|
||||||
val info = PlayerInfo(id, "Magnus")
|
|
||||||
info.id.value shouldBe "p1"
|
info.id.value shouldBe "p1"
|
||||||
info.displayName shouldBe "Magnus"
|
info.displayName shouldBe "Magnus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class ApiResponseTest extends AnyFunSuite with Matchers:
|
class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("ApiResponse.Success carries data") {
|
test("ApiResponse factories and payload wrappers keep values") {
|
||||||
val r = ApiResponse.Success(42)
|
val r = ApiResponse.Success(42)
|
||||||
r.data shouldBe 42
|
r.data shouldBe 42
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiResponse.Failure carries error list") {
|
|
||||||
val err = ApiError("CODE", "msg")
|
val err = ApiError("CODE", "msg")
|
||||||
val r = ApiResponse.Failure(List(err))
|
ApiResponse.Failure(List(err)).errors shouldBe List(err)
|
||||||
r.errors shouldBe List(err)
|
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiResponse.error creates single-error Failure") {
|
|
||||||
val err = ApiError("NOT_FOUND", "not found")
|
|
||||||
val f = ApiResponse.error(err)
|
|
||||||
f shouldBe ApiResponse.Failure(List(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiError holds code and message") {
|
|
||||||
val e = ApiError("CODE", "message")
|
val e = ApiError("CODE", "message")
|
||||||
e.code shouldBe "CODE"
|
e.code shouldBe "CODE"
|
||||||
e.message shouldBe "message"
|
e.message shouldBe "message"
|
||||||
e.field shouldBe None
|
e.field shouldBe None
|
||||||
|
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("ApiError holds optional field") {
|
test("Pagination.totalPages handles normal and guarded inputs") {
|
||||||
val e = ApiError("INVALID", "bad value", Some("email"))
|
|
||||||
e.field shouldBe Some("email")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages with exact division") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages rounds up") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when totalItems is 0") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when pageSize is 0") {
|
|
||||||
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when pageSize is negative") {
|
|
||||||
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=0
|
MINOR=3
|
||||||
PATCH=6
|
PATCH=0
|
||||||
|
|||||||
@@ -99,3 +99,115 @@
|
|||||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-02)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("scala")
|
id("scala")
|
||||||
id("org.scoverage") version "8.1"
|
id("org.scoverage") version "8.1"
|
||||||
application
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -22,19 +21,10 @@ scoverage {
|
|||||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
|
||||||
mainClass.set("de.nowchess.chess.Main")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<ScalaCompile> {
|
tasks.withType<ScalaCompile> {
|
||||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named<JavaExec>("run") {
|
|
||||||
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
|
||||||
standardInput = System.`in`
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation("org.scala-lang:scala3-compiler_3") {
|
implementation("org.scala-lang:scala3-compiler_3") {
|
||||||
@@ -49,6 +39,8 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
|
implementation(project(":modules:io"))
|
||||||
|
implementation(project(":modules:rule"))
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
@@ -62,7 +54,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("passed", "skipped", "failed")
|
events("skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
import de.nowchess.api.board.{Square, Piece}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Marker trait for all commands that can be executed and undone.
|
/** Marker trait for all commands that can be executed and undone.
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* Commands encapsulate user actions and game state transitions.
|
||||||
@@ -23,23 +23,22 @@ case class MoveCommand(
|
|||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
moveResult: Option[MoveResult] = None,
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
notation: String = ""
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
moveResult.isDefined
|
moveResult.isDefined
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = s"Move from $from to $to"
|
override def description: String = s"Move from $from to $to"
|
||||||
|
|
||||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||||
sealed trait MoveResult
|
sealed trait MoveResult
|
||||||
object MoveResult:
|
object MoveResult:
|
||||||
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
|
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||||
case object InvalidFormat extends MoveResult
|
case object InvalidFormat extends MoveResult
|
||||||
case object InvalidMove extends MoveResult
|
case object InvalidMove extends MoveResult
|
||||||
|
|
||||||
@@ -51,14 +50,12 @@ case class QuitCommand() extends Command:
|
|||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None
|
||||||
previousHistory: Option[GameHistory] = None,
|
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = "Reset board"
|
override def description: String = "Reset board"
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
package de.nowchess.chess.controller
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.*
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 PromotionRequired(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
boardBefore: Board,
|
|
||||||
historyBefore: GameHistory,
|
|
||||||
captured: Option[Piece],
|
|
||||||
turn: Color
|
|
||||||
) extends MoveResult
|
|
||||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
||||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
||||||
case class Checkmate(winner: Color) extends MoveResult
|
|
||||||
case object Stalemate extends MoveResult
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Controller
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
object GameController:
|
|
||||||
|
|
||||||
/** Pure function: interprets one raw input line against the current game context.
|
|
||||||
* Has no I/O side effects — all output must be handled by the caller.
|
|
||||||
*/
|
|
||||||
def processMove(board: Board, history: GameHistory, 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)) => validateAndApply(board, history, turn, from, to)
|
|
||||||
|
|
||||||
/** Apply a previously detected promotion move with the chosen piece.
|
|
||||||
* Called after processMove returned PromotionRequired.
|
|
||||||
*/
|
|
||||||
def completePromotion(
|
|
||||||
board: Board,
|
|
||||||
history: GameHistory,
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
piece: PromotionPiece,
|
|
||||||
turn: Color
|
|
||||||
): MoveResult =
|
|
||||||
val (boardAfterMove, captured) = board.withMove(from, to)
|
|
||||||
val promotedPieceType = piece match
|
|
||||||
case PromotionPiece.Queen => PieceType.Queen
|
|
||||||
case PromotionPiece.Rook => PieceType.Rook
|
|
||||||
case PromotionPiece.Bishop => PieceType.Bishop
|
|
||||||
case PromotionPiece.Knight => PieceType.Knight
|
|
||||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
|
||||||
// Promotion is always a pawn move → clock resets
|
|
||||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
|
||||||
toMoveResult(newBoard, newHistory, captured, turn)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Private helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case None => MoveResult.NoPiece
|
|
||||||
case Some(piece) if piece.color != turn => MoveResult.WrongColor
|
|
||||||
case Some(_) =>
|
|
||||||
if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
|
|
||||||
else if MoveValidator.isPromotionMove(board, from, to) then
|
|
||||||
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
|
|
||||||
else applyNormalMove(board, history, turn, from, to)
|
|
||||||
|
|
||||||
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
|
||||||
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
|
|
||||||
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
|
||||||
val (newBoard, captured) = castleOpt match
|
|
||||||
case Some(side) => (board.withCastle(turn, side), None)
|
|
||||||
case None =>
|
|
||||||
val (b, cap) = board.withMove(from, to)
|
|
||||||
if isEP then
|
|
||||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
|
||||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
|
||||||
else (b, cap)
|
|
||||||
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
|
|
||||||
val wasCapture = captured.isDefined
|
|
||||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
|
|
||||||
toMoveResult(newBoard, newHistory, captured, turn)
|
|
||||||
|
|
||||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
|
||||||
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
|
||||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
|
||||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
|
||||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
|
||||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
|
||||||
@@ -1,46 +1,37 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
|
import de.nowchess.io.{GameContextImport, GameContextExport}
|
||||||
|
import de.nowchess.rules.RuleSet
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
/** Pure game engine that manages game state and notifies observers of state changes.
|
||||||
* This class is the single source of truth for the game state.
|
* All rule queries delegate to the injected RuleSet.
|
||||||
* All user interactions must go through this engine via Commands, and all state changes
|
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||||
* are communicated to observers via GameEvent notifications.
|
|
||||||
*/
|
*/
|
||||||
class GameEngine(
|
class GameEngine(
|
||||||
initialBoard: Board = Board.initial,
|
val initialContext: GameContext = GameContext.initial,
|
||||||
initialHistory: GameHistory = GameHistory.empty,
|
val ruleSet: RuleSet = DefaultRules
|
||||||
initialTurn: Color = Color.White,
|
|
||||||
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
|
||||||
GameController.completePromotion
|
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
private var currentBoard: Board = initialBoard
|
private var currentContext: GameContext = initialContext
|
||||||
private var currentHistory: GameHistory = initialHistory
|
|
||||||
private var currentTurn: Color = initialTurn
|
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Inner class for tracking pending promotion state */
|
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||||
private case class PendingPromotion(
|
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||||
from: Square, to: Square,
|
|
||||||
boardBefore: Board, historyBefore: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Current pending promotion, if any */
|
|
||||||
private var pendingPromotion: Option[PendingPromotion] = None
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentContext.board }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def turn: Color = synchronized { currentContext.turn }
|
||||||
def turn: Color = synchronized { currentTurn }
|
def context: GameContext = synchronized { currentContext }
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||||
@@ -58,7 +49,6 @@ class GameEngine(
|
|||||||
val trimmed = rawInput.trim.toLowerCase
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
trimmed match
|
trimmed match
|
||||||
case "quit" | "q" =>
|
case "quit" | "q" =>
|
||||||
// Client should handle quit logic; we just return
|
|
||||||
()
|
()
|
||||||
|
|
||||||
case "undo" =>
|
case "undo" =>
|
||||||
@@ -68,96 +58,55 @@ class GameEngine(
|
|||||||
performRedo()
|
performRedo()
|
||||||
|
|
||||||
case "draw" =>
|
case "draw" =>
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
if currentContext.halfMoveClock >= 100 then
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(DrawClaimedEvent(currentContext))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(
|
||||||
currentBoard, currentHistory, currentTurn,
|
currentContext,
|
||||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||||
))
|
))
|
||||||
|
|
||||||
case "" =>
|
case "" =>
|
||||||
val event = InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"Please enter a valid move or command."
|
|
||||||
)
|
|
||||||
notifyObservers(event)
|
|
||||||
|
|
||||||
case moveInput =>
|
case moveInput =>
|
||||||
Parser.parseMove(moveInput) match
|
Parser.parseMove(moveInput) match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(
|
||||||
currentBoard, currentHistory, currentTurn,
|
currentContext,
|
||||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||||
))
|
))
|
||||||
case Some((from, to)) =>
|
case Some((from, to)) =>
|
||||||
handleParsedMove(from, to, moveInput)
|
handleParsedMove(from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
private def handleParsedMove(from: Square, to: Square): Unit =
|
||||||
val cmd = MoveCommand(
|
currentContext.board.pieceAt(from) match
|
||||||
from = from,
|
case None =>
|
||||||
to = to,
|
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
|
||||||
previousBoard = Some(currentBoard),
|
case Some(piece) if piece.color != currentContext.turn =>
|
||||||
previousHistory = Some(currentHistory),
|
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
|
||||||
previousTurn = Some(currentTurn)
|
case Some(piece) =>
|
||||||
)
|
val legal = ruleSet.legalMoves(currentContext)(from)
|
||||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
// Find all legal moves going to `to`
|
||||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
val candidates = legal.filter(_.to == to)
|
||||||
handleFailedMove(moveInput)
|
candidates match
|
||||||
|
case Nil =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
|
||||||
|
case moves if isPromotionMove(piece, to) =>
|
||||||
|
// Multiple moves (one per promotion piece) — ask user to choose
|
||||||
|
val contextBefore = currentContext
|
||||||
|
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
|
||||||
|
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
|
||||||
|
case move :: _ =>
|
||||||
|
executeMove(move)
|
||||||
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
private def isPromotionMove(piece: Piece, to: Square): Boolean =
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
piece.pieceType == PieceType.Pawn && {
|
||||||
invoker.execute(updatedCmd)
|
val promoRank = if piece.color == Color.White then 7 else 0
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
to.rank.ordinal == promoRank
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
}
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
|
||||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
|
||||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
|
||||||
|
|
||||||
/** Undo the last move. */
|
|
||||||
def undo(): Unit = synchronized {
|
|
||||||
performUndo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redo the last undone move. */
|
|
||||||
def redo(): Unit = synchronized {
|
|
||||||
performRedo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a player's promotion piece choice.
|
/** Apply a player's promotion piece choice.
|
||||||
* Must only be called when isPendingPromotion is true.
|
* Must only be called when isPendingPromotion is true.
|
||||||
@@ -165,130 +114,205 @@ class GameEngine(
|
|||||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||||
pendingPromotion match
|
pendingPromotion match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
|
||||||
case Some(pending) =>
|
case Some(pending) =>
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
val cmd = MoveCommand(
|
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||||
from = pending.from,
|
// Verify it's actually legal
|
||||||
to = pending.to,
|
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||||
previousBoard = Some(pending.boardBefore),
|
if legal.contains(move) then
|
||||||
previousHistory = Some(pending.historyBefore),
|
executeMove(move)
|
||||||
previousTurn = Some(pending.turn)
|
else
|
||||||
)
|
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||||
completePromotionFn(
|
}
|
||||||
pending.boardBefore, pending.historyBefore,
|
|
||||||
pending.from, pending.to, piece, pending.turn
|
|
||||||
) match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
/** Undo the last move. */
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
def undo(): Unit = synchronized { performUndo() }
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
/** Redo the last undone move. */
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
def redo(): Unit = synchronized { performRedo() }
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
/** Load a game using the provided importer.
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
* If the imported context has moves, they are replayed through the command system.
|
||||||
invoker.execute(updatedCmd)
|
* Otherwise, the position is set directly.
|
||||||
currentBoard = Board.initial
|
* Notifies observers with PgnLoadedEvent on success.
|
||||||
currentHistory = GameHistory.empty
|
*/
|
||||||
currentTurn = Color.White
|
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
importer.importGameContext(input) match
|
||||||
|
case Left(err) => Left(err)
|
||||||
|
case Right(ctx) =>
|
||||||
|
replayGame(ctx).map { _ =>
|
||||||
|
notifyObservers(PgnLoadedEvent(currentContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case _ =>
|
private def replayGame(ctx: GameContext): Either[String, Unit] =
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
val savedContext = currentContext
|
||||||
|
currentContext = GameContext.initial
|
||||||
|
pendingPromotion = None
|
||||||
|
invoker.clear()
|
||||||
|
|
||||||
|
if ctx.moves.isEmpty then
|
||||||
|
currentContext = ctx
|
||||||
|
Right(())
|
||||||
|
else
|
||||||
|
replayMoves(ctx.moves, savedContext)
|
||||||
|
|
||||||
|
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||||
|
var error: Option[String] = None
|
||||||
|
moves.foreach: move =>
|
||||||
|
if error.isEmpty then
|
||||||
|
handleParsedMove(move.from, move.to)
|
||||||
|
|
||||||
|
move.moveType match {
|
||||||
|
case MoveType.Promotion(pp) =>
|
||||||
|
if pendingPromotion.isDefined then
|
||||||
|
completePromotion(pp)
|
||||||
|
else
|
||||||
|
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
|
error match
|
||||||
|
case Some(err) =>
|
||||||
|
currentContext = savedContext
|
||||||
|
Left(err)
|
||||||
|
case None =>
|
||||||
|
Right(())
|
||||||
|
|
||||||
|
/** Export the current game context using the provided exporter. */
|
||||||
|
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||||
|
exporter.exportGameContext(currentContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||||
|
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||||
|
currentContext = newContext
|
||||||
|
pendingPromotion = None
|
||||||
|
invoker.clear()
|
||||||
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the board to initial position. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentContext = GameContext.initial
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──── Private Helpers ────
|
// ──── Private helpers ────
|
||||||
|
|
||||||
|
private def executeMove(move: Move): Unit =
|
||||||
|
val contextBefore = currentContext
|
||||||
|
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||||
|
val captured = computeCaptured(currentContext, move)
|
||||||
|
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = move.from,
|
||||||
|
to = move.to,
|
||||||
|
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||||
|
previousContext = Some(contextBefore),
|
||||||
|
notation = translateMoveToNotation(move, contextBefore.board)
|
||||||
|
)
|
||||||
|
invoker.execute(cmd)
|
||||||
|
currentContext = nextContext
|
||||||
|
|
||||||
|
notifyObservers(MoveExecutedEvent(
|
||||||
|
currentContext,
|
||||||
|
move.from.toString,
|
||||||
|
move.to.toString,
|
||||||
|
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
|
))
|
||||||
|
|
||||||
|
if ruleSet.isCheckmate(currentContext) then
|
||||||
|
val winner = currentContext.turn.opposite
|
||||||
|
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||||
|
invoker.clear()
|
||||||
|
currentContext = GameContext.initial
|
||||||
|
else if ruleSet.isStalemate(currentContext) then
|
||||||
|
notifyObservers(StalemateEvent(currentContext))
|
||||||
|
invoker.clear()
|
||||||
|
currentContext = GameContext.initial
|
||||||
|
else if ruleSet.isCheck(currentContext) then
|
||||||
|
notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
|
|
||||||
|
if currentContext.halfMoveClock >= 100 then
|
||||||
|
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
|
|
||||||
|
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.CastleKingside => "O-O"
|
||||||
|
case MoveType.CastleQueenside => "O-O-O"
|
||||||
|
case MoveType.EnPassant => enPassantNotation(move)
|
||||||
|
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||||
|
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
||||||
|
|
||||||
|
private def enPassantNotation(move: Move): String =
|
||||||
|
s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
|
||||||
|
private def promotionNotation(move: Move, piece: PromotionPiece): String =
|
||||||
|
val ppChar = piece match
|
||||||
|
case PromotionPiece.Queen => "Q"
|
||||||
|
case PromotionPiece.Rook => "R"
|
||||||
|
case PromotionPiece.Bishop => "B"
|
||||||
|
case PromotionPiece.Knight => "N"
|
||||||
|
s"${move.to}=$ppChar"
|
||||||
|
|
||||||
|
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
|
||||||
|
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||||
|
case Some(PieceType.Pawn) =>
|
||||||
|
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
else move.to.toString
|
||||||
|
case Some(pt) =>
|
||||||
|
val letter = pieceNotation(pt)
|
||||||
|
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
|
||||||
|
case None => move.to.toString
|
||||||
|
|
||||||
|
private[engine] def pieceNotation(pieceType: PieceType): String =
|
||||||
|
pieceType match
|
||||||
|
case PieceType.Knight => "N"
|
||||||
|
case PieceType.Bishop => "B"
|
||||||
|
case PieceType.Rook => "R"
|
||||||
|
case PieceType.Queen => "Q"
|
||||||
|
case PieceType.King => "K"
|
||||||
|
case _ => ""
|
||||||
|
|
||||||
|
private def computeCaptured(context: GameContext, move: Move): Option[Piece] =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.EnPassant =>
|
||||||
|
// Captured pawn is on the same rank as the moving pawn, same file as destination
|
||||||
|
val capturedSquare = Square(move.to.file, move.from.rank)
|
||||||
|
context.board.pieceAt(capturedSquare)
|
||||||
|
case MoveType.CastleKingside | MoveType.CastleQueenside =>
|
||||||
|
None
|
||||||
|
case _ =>
|
||||||
|
context.board.pieceAt(move.to)
|
||||||
|
|
||||||
private def performUndo(): Unit =
|
private def performUndo(): Unit =
|
||||||
if invoker.canUndo then
|
if invoker.canUndo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
moveCmd.previousContext.foreach(currentContext = _)
|
||||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
|
||||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
|
||||||
updateGameState(nb, nh, nt)
|
currentContext = nextCtx
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt)
|
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
|
notifyObservers(MoveRedoneEvent(
|
||||||
|
currentContext,
|
||||||
|
moveCmd.notation,
|
||||||
|
moveCmd.from.toString,
|
||||||
|
moveCmd.to.toString,
|
||||||
|
capturedDesc
|
||||||
|
))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||||
|
|
||||||
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
|
|
||||||
currentBoard = newBoard
|
|
||||||
currentHistory = newHistory
|
|
||||||
currentTurn = newTurn
|
|
||||||
|
|
||||||
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
|
|
||||||
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
|
||||||
notifyObservers(MoveExecutedEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
newTurn,
|
|
||||||
fromSq,
|
|
||||||
toSq,
|
|
||||||
capturedDesc
|
|
||||||
))
|
|
||||||
|
|
||||||
private def handleFailedMove(moveInput: String): Unit =
|
|
||||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
|
||||||
case MoveResult.NoPiece =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"No piece on that square."
|
|
||||||
))
|
|
||||||
case MoveResult.WrongColor =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"That is not your piece."
|
|
||||||
))
|
|
||||||
case MoveResult.IllegalMove =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"Illegal move."
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
|
|
||||||
enum CastleSide:
|
|
||||||
case Kingside, Queenside
|
|
||||||
|
|
||||||
extension (b: Board)
|
|
||||||
def withCastle(color: Color, side: CastleSide): Board =
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
val kingFrom = Square(File.E, rank)
|
|
||||||
val (kingTo, rookFrom, rookTo) = side match
|
|
||||||
case CastleSide.Kingside =>
|
|
||||||
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
|
||||||
case CastleSide.Queenside =>
|
|
||||||
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
|
||||||
|
|
||||||
val king = b.pieceAt(kingFrom).get
|
|
||||||
val rook = b.pieceAt(rookFrom).get
|
|
||||||
|
|
||||||
b.removed(kingFrom).removed(rookFrom)
|
|
||||||
.updated(kingTo, king)
|
|
||||||
.updated(rookTo, rook)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square}
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
|
|
||||||
/** Derives castling rights from move history. */
|
|
||||||
object CastlingRightsCalculator:
|
|
||||||
|
|
||||||
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
|
|
||||||
val (kingRow, kingsideRookFile, queensideRookFile) = color match
|
|
||||||
case Color.White => (Rank.R1, File.H, File.A)
|
|
||||||
case Color.Black => (Rank.R8, File.H, File.A)
|
|
||||||
|
|
||||||
// Check if king has moved
|
|
||||||
val kingHasMoved = history.moves.exists: move =>
|
|
||||||
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
|
|
||||||
|
|
||||||
if kingHasMoved then
|
|
||||||
CastlingRights.None
|
|
||||||
else
|
|
||||||
// Check if kingside rook has moved or was captured
|
|
||||||
val kingsideLost = history.moves.exists: move =>
|
|
||||||
move.from == Square(kingsideRookFile, kingRow) ||
|
|
||||||
move.to == Square(kingsideRookFile, kingRow)
|
|
||||||
|
|
||||||
// Check if queenside rook has moved or was captured
|
|
||||||
val queensideLost = history.moves.exists: move =>
|
|
||||||
move.from == Square(queensideRookFile, kingRow) ||
|
|
||||||
move.to == Square(queensideRookFile, kingRow)
|
|
||||||
|
|
||||||
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
|
|
||||||
object EnPassantCalculator:
|
|
||||||
|
|
||||||
/** Returns the en passant target square if the last move was a double pawn push.
|
|
||||||
* The target is the square the pawn passed through (e.g. e2→e4 yields e3).
|
|
||||||
*/
|
|
||||||
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
|
|
||||||
history.moves.lastOption.flatMap: move =>
|
|
||||||
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
|
|
||||||
val isDoublePush = math.abs(rankDiff) == 2
|
|
||||||
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
|
|
||||||
if isDoublePush && isPawn then
|
|
||||||
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
|
|
||||||
Some(Square(move.to.file, Rank.values(midRankIdx)))
|
|
||||||
else None
|
|
||||||
|
|
||||||
/** True if moving from→to is an en passant capture. */
|
|
||||||
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
|
||||||
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
|
|
||||||
enPassantTarget(board, history).contains(to) &&
|
|
||||||
math.abs(to.file.ordinal - from.file.ordinal) == 1
|
|
||||||
|
|
||||||
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
|
|
||||||
* White captures upward → captured pawn is one rank below `to`.
|
|
||||||
* Black captures downward → captured pawn is one rank above `to`.
|
|
||||||
*/
|
|
||||||
def capturedPawnSquare(to: Square, color: Color): Square =
|
|
||||||
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
|
|
||||||
Square(to.file, Rank.values(capturedRankIdx))
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.Square
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
|
||||||
case class HistoryMove(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
castleSide: Option[CastleSide],
|
|
||||||
promotionPiece: Option[PromotionPiece] = None
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
|
||||||
*
|
|
||||||
* @param moves moves played so far, oldest first
|
|
||||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
|
||||||
*/
|
|
||||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
|
||||||
|
|
||||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
|
||||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
|
||||||
*/
|
|
||||||
def addMove(move: HistoryMove): GameHistory =
|
|
||||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
|
||||||
|
|
||||||
/** Add a move by coordinates.
|
|
||||||
*
|
|
||||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
|
||||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
|
||||||
*
|
|
||||||
* If neither flag is set the clock increments by 1.
|
|
||||||
*/
|
|
||||||
def addMove(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
castleSide: Option[CastleSide] = None,
|
|
||||||
promotionPiece: Option[PromotionPiece] = None,
|
|
||||||
wasPawnMove: Boolean = false,
|
|
||||||
wasCapture: Boolean = false
|
|
||||||
): GameHistory =
|
|
||||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
|
||||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
|
|
||||||
|
|
||||||
object GameHistory:
|
|
||||||
val empty: GameHistory = GameHistory()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
|
|
||||||
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 =
|
|
||||||
board.pieces
|
|
||||||
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
|
|
||||||
.exists { kingSq =>
|
|
||||||
board.pieces.exists { case (sq, piece) =>
|
|
||||||
piece.color != color &&
|
|
||||||
MoveValidator.legalTargets(board, sq).contains(kingSq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
|
||||||
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
|
|
||||||
board.pieces
|
|
||||||
.collect { case (from, piece) if piece.color == color => from }
|
|
||||||
.flatMap { from =>
|
|
||||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
|
||||||
.filter { to =>
|
|
||||||
val newBoard =
|
|
||||||
if MoveValidator.isCastle(board, from, to) then
|
|
||||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
|
||||||
else
|
|
||||||
board.withMove(from, to)._1
|
|
||||||
!isInCheck(newBoard, color)
|
|
||||||
}
|
|
||||||
.map(to => from -> to)
|
|
||||||
}
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
/** Position status for the side whose turn it is (`color`). */
|
|
||||||
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
|
|
||||||
val moves = legalMoves(board, history, 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
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
|
|
||||||
object MoveValidator:
|
|
||||||
|
|
||||||
/** Returns true if the move is geometrically legal for the piece on `from`,
|
|
||||||
* ignoring check/pin but respecting:
|
|
||||||
* - correct movement pattern for the piece type
|
|
||||||
* - cannot capture own pieces
|
|
||||||
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
|
|
||||||
*/
|
|
||||||
def isLegal(board: Board, from: Square, to: Square): Boolean =
|
|
||||||
legalTargets(board, from).contains(to)
|
|
||||||
|
|
||||||
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
|
|
||||||
def legalTargets(board: Board, from: Square): Set[Square] =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case None => Set.empty
|
|
||||||
case Some(piece) =>
|
|
||||||
piece.pieceType match
|
|
||||||
case PieceType.Pawn => pawnTargets(board, from, piece.color)
|
|
||||||
case PieceType.Knight => knightTargets(board, from, piece.color)
|
|
||||||
case PieceType.Bishop => slide(board, from, piece.color, diagonalDeltas)
|
|
||||||
case PieceType.Rook => slide(board, from, piece.color, orthogonalDeltas)
|
|
||||||
case PieceType.Queen => slide(board, from, piece.color, diagonalDeltas ++ orthogonalDeltas)
|
|
||||||
case PieceType.King => kingTargets(board, from, piece.color)
|
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val diagonalDeltas: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
|
||||||
private val orthogonalDeltas: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
||||||
private val knightDeltas: List[(Int, Int)] =
|
|
||||||
List((1, 2), (1, -2), (-1, 2), (-1, -2), (2, 1), (2, -1), (-2, 1), (-2, -1))
|
|
||||||
|
|
||||||
/** Try to construct a Square from integer file/rank indices (0-based). */
|
|
||||||
private def squareAt(fileIdx: Int, rankIdx: Int): Option[Square] =
|
|
||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
|
||||||
)
|
|
||||||
|
|
||||||
/** True when `sq` is occupied by a piece of `color`. */
|
|
||||||
private def isOwnPiece(board: Board, sq: Square, color: Color): Boolean =
|
|
||||||
board.pieceAt(sq).exists(_.color == color)
|
|
||||||
|
|
||||||
/** True when `sq` is occupied by a piece of the opposite color. */
|
|
||||||
private def isEnemyPiece(board: Board, sq: Square, color: Color): Boolean =
|
|
||||||
board.pieceAt(sq).exists(_.color != color)
|
|
||||||
|
|
||||||
/** Sliding move generation along a list of direction deltas.
|
|
||||||
* Each direction continues until the board edge, an own piece, or the first
|
|
||||||
* enemy piece (which is included as a capture target).
|
|
||||||
*/
|
|
||||||
private def slide(board: Board, from: Square, color: Color, deltas: List[(Int, Int)]): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
deltas.flatMap: (df, dr) =>
|
|
||||||
Iterator
|
|
||||||
.iterate((fi + df, ri + dr)) { case (f, r) => (f + df, r + dr) }
|
|
||||||
.takeWhile { case (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 }
|
|
||||||
.map { case (f, r) => Square(File.values(f), Rank.values(r)) }
|
|
||||||
.foldLeft((List.empty[Square], false)):
|
|
||||||
case ((acc, stopped), sq) =>
|
|
||||||
if stopped then (acc, true)
|
|
||||||
else if isOwnPiece(board, sq, color) then (acc, true) // blocked — stop, no capture
|
|
||||||
else if isEnemyPiece(board, sq, color) then (acc :+ sq, true) // capture — stop after
|
|
||||||
else (acc :+ sq, false) // empty — continue
|
|
||||||
._1
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
val dir = if color == Color.White then 1 else -1
|
|
||||||
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
|
|
||||||
|
|
||||||
val oneStep = squareAt(fi, ri + dir)
|
|
||||||
|
|
||||||
// Forward one square (only if empty)
|
|
||||||
val forward1: Set[Square] = oneStep match
|
|
||||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
|
||||||
case _ => Set.empty
|
|
||||||
|
|
||||||
// Forward two squares from starting rank (only if both intermediate squares are empty)
|
|
||||||
val forward2: Set[Square] =
|
|
||||||
if ri == startRank && forward1.nonEmpty then
|
|
||||||
squareAt(fi, ri + 2 * dir) match
|
|
||||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
|
||||||
case _ => Set.empty
|
|
||||||
else Set.empty
|
|
||||||
|
|
||||||
// Diagonal captures (only if enemy piece present)
|
|
||||||
val captures: Set[Square] =
|
|
||||||
List(-1, 1).flatMap: df =>
|
|
||||||
squareAt(fi + df, ri + dir).filter(sq => isEnemyPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
forward1 ++ forward2 ++ captures
|
|
||||||
|
|
||||||
private def knightTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
knightDeltas.flatMap: (df, dr) =>
|
|
||||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
private def kingTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
|
||||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
// ── 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(board: Board, history: GameHistory, color: Color): Set[Square] =
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
val kingSq = Square(File.E, rank)
|
|
||||||
val enemy = color.opposite
|
|
||||||
|
|
||||||
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
|
|
||||||
GameRules.isInCheck(board, color) then Set.empty
|
|
||||||
else
|
|
||||||
val kingsideSq = Option.when(
|
|
||||||
rights.kingSide &&
|
|
||||||
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
|
|
||||||
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
|
||||||
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
|
|
||||||
)(Square(File.G, rank))
|
|
||||||
|
|
||||||
val queensideSq = Option.when(
|
|
||||||
rights.queenSide &&
|
|
||||||
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
|
|
||||||
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
|
||||||
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
|
|
||||||
)(Square(File.C, rank))
|
|
||||||
|
|
||||||
kingsideSq.toSet ++ queensideSq.toSet
|
|
||||||
|
|
||||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
|
||||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
|
||||||
case Some(piece) if piece.pieceType == PieceType.Pawn =>
|
|
||||||
pawnTargets(board, history, from, piece.color)
|
|
||||||
case _ =>
|
|
||||||
legalTargets(board, from)
|
|
||||||
|
|
||||||
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
|
|
||||||
val existing = pawnTargets(board, from, color)
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
val dir = if color == Color.White then 1 else -1
|
|
||||||
val epCapture: Set[Square] =
|
|
||||||
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
|
|
||||||
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
|
|
||||||
.toSet
|
|
||||||
existing ++ epCapture
|
|
||||||
|
|
||||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
|
||||||
legalTargets(board, history, from).contains(to)
|
|
||||||
|
|
||||||
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
|
|
||||||
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case Some(Piece(_, PieceType.Pawn)) =>
|
|
||||||
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
|
|
||||||
(from.rank == Rank.R2 && to.rank == Rank.R1)
|
|
||||||
case _ => false
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
|
||||||
|
|
||||||
object PgnExporter:
|
|
||||||
|
|
||||||
/** Export a game with headers and history to PGN format. */
|
|
||||||
def exportGame(headers: Map[String, String], history: GameHistory): String =
|
|
||||||
val headerLines = headers.map { case (key, value) =>
|
|
||||||
s"""[$key "$value"]"""
|
|
||||||
}.mkString("\n")
|
|
||||||
|
|
||||||
val moveText = if history.moves.isEmpty then ""
|
|
||||||
else
|
|
||||||
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
|
|
||||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
|
||||||
val moveNum = moveNumber + 1
|
|
||||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
|
||||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
|
||||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
|
||||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
|
||||||
|
|
||||||
val termination = headers.getOrElse("Result", "*")
|
|
||||||
moveLines.mkString(" ") + s" $termination"
|
|
||||||
|
|
||||||
if headerLines.isEmpty then moveText
|
|
||||||
else if moveText.isEmpty then headerLines
|
|
||||||
else s"$headerLines\n\n$moveText"
|
|
||||||
|
|
||||||
/** Convert a HistoryMove to algebraic notation. */
|
|
||||||
private def moveToAlgebraic(move: HistoryMove): String =
|
|
||||||
move.castleSide match
|
|
||||||
case Some(CastleSide.Kingside) => "O-O"
|
|
||||||
case Some(CastleSide.Queenside) => "O-O-O"
|
|
||||||
case None =>
|
|
||||||
val base = s"${move.from}${move.to}"
|
|
||||||
move.promotionPiece match
|
|
||||||
case Some(PromotionPiece.Queen) => s"$base=Q"
|
|
||||||
case Some(PromotionPiece.Rook) => s"$base=R"
|
|
||||||
case Some(PromotionPiece.Bishop) => s"$base=B"
|
|
||||||
case Some(PromotionPiece.Knight) => s"$base=N"
|
|
||||||
case None => base
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
|
||||||
|
|
||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
|
||||||
case class PgnGame(
|
|
||||||
headers: Map[String, String],
|
|
||||||
moves: List[HistoryMove]
|
|
||||||
)
|
|
||||||
|
|
||||||
object PgnParser:
|
|
||||||
|
|
||||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
|
||||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
|
||||||
def parsePgn(pgn: String): Option[PgnGame] =
|
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
|
||||||
|
|
||||||
val headers = parseHeaders(headerLines)
|
|
||||||
val moveText = rest.mkString(" ")
|
|
||||||
val moves = parseMovesText(moveText)
|
|
||||||
|
|
||||||
Some(PgnGame(headers, moves))
|
|
||||||
|
|
||||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
|
||||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
|
||||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
|
|
||||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
|
||||||
|
|
||||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
|
||||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
|
||||||
|
|
||||||
// Fold over tokens, threading (board, history, currentColor, accumulator)
|
|
||||||
val (_, _, _, moves) = tokens.foldLeft(
|
|
||||||
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
|
|
||||||
):
|
|
||||||
case (state @ (board, history, color, acc), token) =>
|
|
||||||
// Skip move-number markers (e.g. "1.", "2.") and result tokens
|
|
||||||
if isMoveNumberOrResult(token) then state
|
|
||||||
else
|
|
||||||
parseAlgebraicMove(token, board, history, color) match
|
|
||||||
case None => state // unrecognised token — skip silently
|
|
||||||
case Some(move) =>
|
|
||||||
val newBoard = applyMoveToBoard(board, move, color)
|
|
||||||
val newHistory = history.addMove(move)
|
|
||||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
|
||||||
|
|
||||||
moves
|
|
||||||
|
|
||||||
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
|
||||||
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
|
||||||
move.castleSide match
|
|
||||||
case Some(side) => board.withCastle(color, side)
|
|
||||||
case None =>
|
|
||||||
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
|
||||||
move.promotionPiece match
|
|
||||||
case Some(pp) =>
|
|
||||||
val pieceType = pp match
|
|
||||||
case PromotionPiece.Queen => PieceType.Queen
|
|
||||||
case PromotionPiece.Rook => PieceType.Rook
|
|
||||||
case PromotionPiece.Bishop => PieceType.Bishop
|
|
||||||
case PromotionPiece.Knight => PieceType.Knight
|
|
||||||
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
|
||||||
case None => boardAfterMove
|
|
||||||
|
|
||||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
|
||||||
token.matches("""\d+\.""") ||
|
|
||||||
token == "*" ||
|
|
||||||
token == "1-0" ||
|
|
||||||
token == "0-1" ||
|
|
||||||
token == "1/2-1/2"
|
|
||||||
|
|
||||||
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
|
|
||||||
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
notation match
|
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
|
|
||||||
|
|
||||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
parseRegularMove(notation, board, history, color)
|
|
||||||
|
|
||||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
|
||||||
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
|
|
||||||
val clean = notation
|
|
||||||
.replace("+", "")
|
|
||||||
.replace("#", "")
|
|
||||||
.replace("x", "")
|
|
||||||
.replaceAll("=[NBRQ]$", "")
|
|
||||||
|
|
||||||
// The destination square is always the last two characters
|
|
||||||
if clean.length < 2 then None
|
|
||||||
else
|
|
||||||
val destStr = clean.takeRight(2)
|
|
||||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
|
||||||
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
|
|
||||||
|
|
||||||
// Determine required piece type: upper-case first char = piece letter; else pawn
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
|
||||||
else Some(PieceType.Pawn)
|
|
||||||
|
|
||||||
// Collect the disambiguation hint that remains after stripping the piece letter
|
|
||||||
val hint =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
|
||||||
else disambig // hint is file/rank info or empty
|
|
||||||
|
|
||||||
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
|
|
||||||
// We prefer pieces that can actually reach the target; if none can (positionally illegal
|
|
||||||
// PGN input), fall back to any piece of the matching type belonging to `color`.
|
|
||||||
val reachable: Set[Square] =
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color &&
|
|
||||||
MoveValidator.legalTargets(board, from).contains(toSquare) => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
val candidates: Set[Square] =
|
|
||||||
if reachable.nonEmpty then reachable
|
|
||||||
else
|
|
||||||
// Fallback for positionally-illegal but syntactically valid PGN notation:
|
|
||||||
// find any piece of `color` with the correct piece type on the board.
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
// Filter by required piece type
|
|
||||||
val byPiece = candidates.filter(from =>
|
|
||||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Apply disambiguation hint (file letter or rank digit)
|
|
||||||
val disambiguated =
|
|
||||||
if hint.isEmpty then byPiece
|
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
|
|
||||||
|
|
||||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
|
||||||
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
|
||||||
else true)
|
|
||||||
|
|
||||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
|
||||||
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
|
||||||
val promotionPattern = """=([A-Z])""".r
|
|
||||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
|
||||||
m.group(1) match
|
|
||||||
case "Q" => Some(PromotionPiece.Queen)
|
|
||||||
case "R" => Some(PromotionPiece.Rook)
|
|
||||||
case "B" => Some(PromotionPiece.Bishop)
|
|
||||||
case "N" => Some(PromotionPiece.Knight)
|
|
||||||
case _ => None
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert a piece-letter character to a PieceType. */
|
|
||||||
private def charToPieceType(c: Char): Option[PieceType] =
|
|
||||||
c match
|
|
||||||
case 'N' => Some(PieceType.Knight)
|
|
||||||
case 'B' => Some(PieceType.Bishop)
|
|
||||||
case 'R' => Some(PieceType.Rook)
|
|
||||||
case 'Q' => Some(PieceType.Queen)
|
|
||||||
case 'K' => Some(PieceType.King)
|
|
||||||
case _ => None
|
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
package de.nowchess.chess.observer
|
package de.nowchess.chess.observer
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Square}
|
import de.nowchess.api.board.{Color, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events.
|
||||||
* Events are immutable snapshots of game state changes.
|
* Events are immutable snapshots of game state changes.
|
||||||
*/
|
*/
|
||||||
sealed trait GameEvent:
|
sealed trait GameEvent:
|
||||||
def board: Board
|
def context: GameContext
|
||||||
def history: GameHistory
|
|
||||||
def turn: Color
|
|
||||||
|
|
||||||
/** Fired when a move is successfully executed. */
|
/** Fired when a move is successfully executed. */
|
||||||
case class MoveExecutedEvent(
|
case class MoveExecutedEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String]
|
||||||
@@ -23,62 +19,66 @@ case class MoveExecutedEvent(
|
|||||||
|
|
||||||
/** Fired when the current player is in check. */
|
/** Fired when the current player is in check. */
|
||||||
case class CheckDetectedEvent(
|
case class CheckDetectedEvent(
|
||||||
board: Board,
|
context: GameContext
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches checkmate. */
|
/** Fired when the game reaches checkmate. */
|
||||||
case class CheckmateEvent(
|
case class CheckmateEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
winner: Color
|
winner: Color
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches stalemate. */
|
/** Fired when the game reaches stalemate. */
|
||||||
case class StalemateEvent(
|
case class StalemateEvent(
|
||||||
board: Board,
|
context: GameContext
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is invalid. */
|
/** Fired when a move is invalid. */
|
||||||
case class InvalidMoveEvent(
|
case class InvalidMoveEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
reason: String
|
reason: String
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||||
case class PromotionRequiredEvent(
|
case class PromotionRequiredEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square
|
to: Square
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the board is reset. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
board: Board,
|
context: GameContext
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||||
case class FiftyMoveRuleAvailableEvent(
|
case class FiftyMoveRuleAvailableEvent(
|
||||||
board: Board,
|
context: GameContext
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||||
case class DrawClaimedEvent(
|
case class DrawClaimedEvent(
|
||||||
board: Board,
|
context: GameContext
|
||||||
history: GameHistory,
|
) extends GameEvent
|
||||||
turn: Color
|
|
||||||
|
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||||
|
case class MoveUndoneEvent(
|
||||||
|
context: GameContext,
|
||||||
|
pgnNotation: String
|
||||||
|
) extends GameEvent
|
||||||
|
|
||||||
|
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||||
|
case class MoveRedoneEvent(
|
||||||
|
context: GameContext,
|
||||||
|
pgnNotation: String,
|
||||||
|
fromSquare: String,
|
||||||
|
toSquare: String,
|
||||||
|
capturedPiece: Option[String]
|
||||||
|
) extends GameEvent
|
||||||
|
|
||||||
|
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||||
|
case class PgnLoadedEvent(
|
||||||
|
context: GameContext
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Observer trait: implement to receive game state updates. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
|
|||||||
@@ -1,216 +1,148 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
// ──── Helper: Command that always fails ────
|
|
||||||
private case class FailingCommand() extends Command:
|
private case class FailingCommand() extends Command:
|
||||||
override def execute(): Boolean = false
|
override def execute(): Boolean = false
|
||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Failing command"
|
override def description: String = "Failing command"
|
||||||
|
|
||||||
// ──── Helper: Command that conditionally fails on undo or execute ────
|
|
||||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||||
override def execute(): Boolean = !shouldFailOnExecute
|
override def execute(): Boolean = !shouldFailOnExecute
|
||||||
override def undo(): Boolean = !shouldFailOnUndo
|
override def undo(): Boolean = !shouldFailOnUndo
|
||||||
override def description: String = "Conditional fail"
|
override def description: String = "Conditional fail"
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||||
val cmd = MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
|
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
cmd
|
|
||||||
|
|
||||||
// ──── BRANCH: execute() returns false ────
|
test("execute rejects failing commands and keeps history unchanged"):
|
||||||
test("CommandInvoker.execute() with failing command returns false"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = FailingCommand()
|
val cmd = FailingCommand()
|
||||||
invoker.execute(cmd) shouldBe false
|
invoker.execute(cmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker.execute() does not add failed command to history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val failingCmd = FailingCommand()
|
val failingCmd = FailingCommand()
|
||||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(failingCmd) shouldBe false
|
invoker.execute(failingCmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
invoker.execute(successCmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.history(0) shouldBe successCmd
|
invoker.history.head shouldBe successCmd
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
|
test("undo redo and history trimming cover all command state transitions"):
|
||||||
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
// currentIndex starts at -1
|
invoker.undo() shouldBe false
|
||||||
invoker.undo() shouldBe false
|
invoker.canUndo shouldBe false
|
||||||
|
invoker.undo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns false when empty history"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
invoker.canUndo shouldBe false
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.undo() shouldBe false
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
invoker.execute(cmd1)
|
||||||
|
invoker.execute(cmd2)
|
||||||
|
invoker.undo()
|
||||||
|
invoker.undo()
|
||||||
|
invoker.undo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
|
{
|
||||||
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
val invoker = new CommandInvoker()
|
||||||
val invoker = new CommandInvoker()
|
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
invoker.execute(failingUndoCmd) shouldBe true
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
invoker.canUndo shouldBe true
|
||||||
|
invoker.undo() shouldBe false
|
||||||
invoker.execute(cmd1)
|
invoker.getCurrentIndex shouldBe 0
|
||||||
invoker.execute(cmd2)
|
}
|
||||||
// currentIndex now = 1, history.size = 2
|
|
||||||
|
|
||||||
invoker.undo() // currentIndex becomes 0
|
|
||||||
invoker.undo() // currentIndex becomes -1
|
|
||||||
invoker.undo() // currentIndex still -1, should fail
|
|
||||||
|
|
||||||
// ──── BRANCH: undo() command returns false ────
|
{
|
||||||
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
val invoker = new CommandInvoker()
|
||||||
val invoker = new CommandInvoker()
|
val successUndoCmd = ConditionalFailCommand()
|
||||||
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
invoker.execute(successUndoCmd) shouldBe true
|
||||||
|
invoker.undo() shouldBe true
|
||||||
invoker.execute(failingCmd) shouldBe true
|
invoker.getCurrentIndex shouldBe -1
|
||||||
invoker.canUndo shouldBe true
|
}
|
||||||
|
|
||||||
invoker.undo() shouldBe false
|
|
||||||
// Index should not change when undo fails
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
invoker.redo() shouldBe false
|
||||||
|
}
|
||||||
invoker.execute(successCmd) shouldBe true
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
|
|
||||||
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
|
{
|
||||||
test("CommandInvoker.redo() returns false when nothing to redo"):
|
val invoker = new CommandInvoker()
|
||||||
val invoker = new CommandInvoker()
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.redo() shouldBe false
|
invoker.execute(cmd)
|
||||||
|
invoker.canRedo shouldBe false
|
||||||
|
invoker.redo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns false when at end of history"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
val redoFailCmd = ConditionalFailCommand()
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd1)
|
||||||
// currentIndex = 0, history.size = 1
|
invoker.execute(redoFailCmd)
|
||||||
invoker.canRedo shouldBe false
|
invoker.undo()
|
||||||
invoker.redo() shouldBe false
|
invoker.canRedo shouldBe true
|
||||||
|
redoFailCmd.shouldFailOnExecute = true
|
||||||
|
invoker.redo() shouldBe false
|
||||||
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
invoker.execute(cmd) shouldBe true
|
||||||
|
invoker.undo() shouldBe true
|
||||||
invoker.execute(cmd1)
|
invoker.redo() shouldBe true
|
||||||
invoker.execute(cmd2)
|
invoker.getCurrentIndex shouldBe 0
|
||||||
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
|
}
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
|
|
||||||
// ──── BRANCH: redo() command returns false ────
|
{
|
||||||
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
val invoker = new CommandInvoker()
|
||||||
val invoker = new CommandInvoker()
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd2)
|
||||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
invoker.undo()
|
||||||
|
invoker.canRedo shouldBe true
|
||||||
invoker.undo()
|
invoker.execute(cmd3)
|
||||||
// currentIndex = 0, redoFailCmd is at index 1
|
invoker.canRedo shouldBe false
|
||||||
invoker.canRedo shouldBe true
|
invoker.history.size shouldBe 2
|
||||||
|
invoker.history(1) shouldBe cmd3
|
||||||
// Now modify to fail on next execute (redo)
|
}
|
||||||
redoFailCmd.shouldFailOnExecute = true
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
// currentIndex should not change
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
|
|
||||||
invoker.execute(cmd) shouldBe true
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.redo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
// ──── BRANCH: execute() with redo history discarding (while loop) ────
|
|
||||||
test("CommandInvoker.execute() discards redo history via while loop"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
|
|
||||||
invoker.undo()
|
|
||||||
// currentIndex = 0, size = 2
|
|
||||||
// Redo history exists: cmd2 is at index 1
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
// while loop should discard cmd2
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.history.size shouldBe 2
|
|
||||||
invoker.history(1) shouldBe cmd3
|
|
||||||
|
|
||||||
test("CommandInvoker.execute() discards multiple redo commands"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
|
||||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.execute(cmd4)
|
|
||||||
// currentIndex = 3, size = 4
|
|
||||||
|
|
||||||
invoker.undo()
|
|
||||||
invoker.undo()
|
|
||||||
// currentIndex = 1, size = 4
|
|
||||||
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
|
|
||||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
|
||||||
invoker.execute(newCmd)
|
|
||||||
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
|
|
||||||
invoker.history.size shouldBe 3
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
|
|
||||||
// ──── BRANCH: execute() with no redo history to discard ────
|
|
||||||
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
|
|
||||||
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
|
||||||
invoker.history.size shouldBe 3
|
|
||||||
|
|
||||||
|
{
|
||||||
|
val invoker = new CommandInvoker()
|
||||||
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||||
|
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
invoker.execute(cmd1)
|
||||||
|
invoker.execute(cmd2)
|
||||||
|
invoker.execute(cmd3)
|
||||||
|
invoker.execute(cmd4)
|
||||||
|
invoker.undo()
|
||||||
|
invoker.undo()
|
||||||
|
invoker.canRedo shouldBe true
|
||||||
|
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||||
|
invoker.execute(newCmd)
|
||||||
|
invoker.history.size shouldBe 3
|
||||||
|
invoker.canRedo shouldBe false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,81 +1,47 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||||
MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
test("CommandInvoker executes a command and adds it to history"):
|
test("execute appends commands and updates index"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker executes multiple commands in sequence"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
invoker.execute(cmd1) shouldBe true
|
|
||||||
invoker.execute(cmd2) shouldBe true
|
invoker.execute(cmd2) shouldBe true
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns false when empty"):
|
test("undo and redo update index and availability flags"):
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns true after execution"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
invoker.canUndo shouldBe false
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.undo decrements current index"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker.canRedo returns true after undo"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.redo re-executes a command"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
test("clear removes full history and resets index"):
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker clear removes all history"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
@@ -83,7 +49,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker discards all history when executing after undoing all"):
|
test("execute after undo discards redo history"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
@@ -91,33 +57,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.undo()
|
|
||||||
// After undoing twice, we're at the beginning (before any commands)
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
// Executing a new command from the beginning discards all redo history
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.history.size shouldBe 1
|
|
||||||
invoker.history(0) shouldBe cmd3
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("CommandInvoker discards redo history when executing mid-history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.undo()
|
|
||||||
// After one undo, we're at the end of cmd1
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
// Executing a new command discards cmd2 (the redo history)
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.history(0) shouldBe cmd1
|
invoker.history.head shouldBe cmd1
|
||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import scala.collection.mutable
|
|
||||||
|
|
||||||
class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
|
||||||
MoveCommand(
|
|
||||||
from = from,
|
|
||||||
to = to,
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
|
|
||||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
@volatile var raceDetected = false
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
|
|
||||||
// Thread 1: executes commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for i <- 1 to 1000 do
|
|
||||||
val cmd = createMoveCommand(
|
|
||||||
sq(File.E, Rank.R2),
|
|
||||||
sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
invoker.execute(cmd)
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: reads history during execution
|
|
||||||
val readerThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 1000 do
|
|
||||||
val _ = invoker.history
|
|
||||||
val _ = invoker.getCurrentIndex
|
|
||||||
Thread.sleep(0) // Yield to increase contention
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
executorThread.start()
|
|
||||||
readerThread.start()
|
|
||||||
executorThread.join()
|
|
||||||
readerThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
raceDetected shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
@volatile var raceDetected = false
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
|
|
||||||
// Pre-populate with some commands
|
|
||||||
for _ <- 1 to 5 do
|
|
||||||
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
|
||||||
|
|
||||||
// Thread 1: executes new commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: undoes commands
|
|
||||||
val undoThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
if invoker.canUndo then
|
|
||||||
invoker.undo()
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 3: redoes commands
|
|
||||||
val redoThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
if invoker.canRedo then
|
|
||||||
invoker.redo()
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
executorThread.start()
|
|
||||||
undoThread.start()
|
|
||||||
redoThread.start()
|
|
||||||
executorThread.join()
|
|
||||||
undoThread.join()
|
|
||||||
redoThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
raceDetected shouldBe false
|
|
||||||
@@ -1,52 +1,24 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandTest extends AnyFunSuite with Matchers:
|
class CommandTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("QuitCommand can be created"):
|
test("QuitCommand properties and behavior"):
|
||||||
val cmd = QuitCommand()
|
val cmd = QuitCommand()
|
||||||
cmd shouldNot be(null)
|
cmd shouldNot be(null)
|
||||||
|
|
||||||
test("QuitCommand execute returns true"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.execute() shouldBe true
|
cmd.execute() shouldBe true
|
||||||
|
|
||||||
test("QuitCommand undo returns false (cannot undo quit)"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.undo() shouldBe false
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
test("QuitCommand description"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.description shouldBe "Quit game"
|
cmd.description shouldBe "Quit game"
|
||||||
|
|
||||||
test("ResetCommand with no prior state"):
|
test("ResetCommand behavior depends on previousContext"):
|
||||||
val cmd = ResetCommand()
|
val noState = ResetCommand()
|
||||||
cmd.execute() shouldBe true
|
noState.execute() shouldBe true
|
||||||
cmd.undo() shouldBe false
|
noState.undo() shouldBe false
|
||||||
|
noState.description shouldBe "Reset board"
|
||||||
test("ResetCommand with prior state can undo"):
|
|
||||||
val cmd = ResetCommand(
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
cmd.undo() shouldBe true
|
|
||||||
|
|
||||||
test("ResetCommand with partial state cannot undo"):
|
|
||||||
val cmd = ResetCommand(
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = None, // missing
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("ResetCommand description"):
|
|
||||||
val cmd = ResetCommand()
|
|
||||||
cmd.description shouldBe "Reset board"
|
|
||||||
|
|
||||||
|
val withState = ResetCommand(previousContext = Some(GameContext.initial))
|
||||||
|
withState.execute() shouldBe true
|
||||||
|
withState.undo() shouldBe true
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create second command with filled state
|
|
||||||
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
|
|
||||||
val cmd2 = cmd1.copy(
|
|
||||||
moveResult = Some(result),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Original should be unchanged
|
|
||||||
cmd1.moveResult shouldBe None
|
|
||||||
cmd1.previousBoard shouldBe None
|
|
||||||
cmd1.previousHistory shouldBe None
|
|
||||||
cmd1.previousTurn shouldBe None
|
|
||||||
|
|
||||||
// New should have values
|
|
||||||
cmd2.moveResult shouldBe Some(result)
|
|
||||||
cmd2.previousBoard shouldBe Some(Board.initial)
|
|
||||||
cmd2.previousHistory shouldBe Some(GameHistory.empty)
|
|
||||||
cmd2.previousTurn shouldBe Some(Color.White)
|
|
||||||
|
|
||||||
test("MoveCommand equals and hashCode respect immutability"):
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
|
|
||||||
val cmd2 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
|
|
||||||
// Same values should be equal
|
|
||||||
cmd1 shouldBe cmd2
|
|
||||||
cmd1.hashCode shouldBe cmd2.hashCode
|
|
||||||
|
|
||||||
// Hash should be consistent (required for use as map keys)
|
|
||||||
val hash1 = cmd1.hashCode
|
|
||||||
val hash2 = cmd1.hashCode
|
|
||||||
hash1 shouldBe hash2
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
|
test("MoveCommand defaults to empty optional state and false execute/undo"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
cmd.moveResult shouldBe None
|
||||||
|
cmd.previousContext shouldBe None
|
||||||
|
cmd.execute() shouldBe false
|
||||||
|
cmd.undo() shouldBe false
|
||||||
|
cmd.description shouldBe "Move from e2 to e4"
|
||||||
|
|
||||||
|
test("MoveCommand execute/undo succeed when state is present"):
|
||||||
|
val executable = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
||||||
|
)
|
||||||
|
executable.execute() shouldBe true
|
||||||
|
|
||||||
|
val undoable = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
|
previousContext = Some(GameContext.initial)
|
||||||
|
)
|
||||||
|
undoable.undo() shouldBe true
|
||||||
|
|
||||||
|
test("MoveCommand is immutable and preserves equality/hash semantics"):
|
||||||
|
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
|
||||||
|
val result = MoveResult.Successful(GameContext.initial, None)
|
||||||
|
val cmd2 = cmd1.copy(
|
||||||
|
moveResult = Some(result),
|
||||||
|
previousContext = Some(GameContext.initial)
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd1.moveResult shouldBe None
|
||||||
|
cmd1.previousContext shouldBe None
|
||||||
|
|
||||||
|
cmd2.moveResult shouldBe Some(result)
|
||||||
|
cmd2.previousContext shouldBe Some(GameContext.initial)
|
||||||
|
|
||||||
|
val eq1 = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = None,
|
||||||
|
previousContext = None
|
||||||
|
)
|
||||||
|
|
||||||
|
val eq2 = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = None,
|
||||||
|
previousContext = None
|
||||||
|
)
|
||||||
|
|
||||||
|
eq1 shouldBe eq2
|
||||||
|
eq1.hashCode shouldBe eq2.hashCode
|
||||||
|
|
||||||
|
val hash1 = eq1.hashCode
|
||||||
|
val hash2 = eq1.hashCode
|
||||||
|
hash1 shouldBe hash2
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
package de.nowchess.chess.controller
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
import de.nowchess.chess.notation.FenParser
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameControllerTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
|
||||||
GameController.processMove(board, history, turn, raw)
|
|
||||||
|
|
||||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
|
||||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
|
||||||
|
|
||||||
// ──── processMove ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: 'quit' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: 'q' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: quit with surrounding whitespace returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: unparseable input returns InvalidFormat"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
|
||||||
|
|
||||||
test("processMove: valid format but empty square returns NoPiece"):
|
|
||||||
// E3 is empty in the initial position
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
|
||||||
|
|
||||||
test("processMove: piece of wrong color returns WrongColor"):
|
|
||||||
// E7 has a Black pawn; it is White's turn
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
|
||||||
|
|
||||||
test("processMove: geometrically illegal move returns IllegalMove"):
|
|
||||||
// White pawn at E2 cannot jump three squares to E5
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that leaves own king in check returns IllegalMove"):
|
|
||||||
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
|
|
||||||
// geometrically legal but does not resolve the check — must be rejected.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.D, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that resolves check is allowed"):
|
|
||||||
// White King E1 is in check from Black Rook E8 along the E-file.
|
|
||||||
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a5e5") match
|
|
||||||
case _: MoveResult.Moved => succeed
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
|
||||||
captured shouldBe None
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal capture returns Moved with the captured piece"):
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(board, GameHistory.empty, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
|
||||||
|
|
||||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
|
||||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
|
|
||||||
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, 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 position)
|
|
||||||
// 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
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, 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 position)
|
|
||||||
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
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "b1b6") match
|
|
||||||
case MoveResult.Stalemate => succeed
|
|
||||||
case other => fail(s"Expected Stalemate, got $other")
|
|
||||||
|
|
||||||
// ──── castling execution ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
|
||||||
newBoard.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 b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1c1") match
|
|
||||||
case MoveResult.Moved(newBoard, _, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── rights revocation ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 revokes both white castling rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h1 revokes white kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "h1h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving king from e1 revokes both white rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1e2") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on h1 revokes white kingside right"):
|
|
||||||
val b = 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
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rights revoked returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
|
|
||||||
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: moving king from e8 revokes both black rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from a8 revokes black queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h8 revokes black kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on a1 revokes white queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.A, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
// ──── en passant ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("en passant capture removes the captured pawn from the board"):
|
|
||||||
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
|
|
||||||
val result = GameController.processMove(b, h, Color.White, "e5d6")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
test("en passant capture by black removes the captured white pawn"):
|
|
||||||
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.D, Rank.R4) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R4) -> Piece.WhitePawn,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
|
||||||
val result = GameController.processMove(b, h, Color.Black, "d4e3")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.WhitePawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
// ──── pawn promotion detection ───────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R7))
|
|
||||||
to should be (sq(File.E, Rank.R8))
|
|
||||||
turn should be (Color.White)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R2))
|
|
||||||
to should be (sq(File.E, Rank.R1))
|
|
||||||
turn should be (Color.Black)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
// ──── completePromotion ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("completePromotion applies move and places queen"):
|
|
||||||
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
|
||||||
newHistory.moves should have length 1
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion"):
|
|
||||||
// Black king on h1: not attacked by rook on e8 (different file and rank)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Rook, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
||||||
case _ => fail("Expected Moved with Rook")
|
|
||||||
|
|
||||||
test("completePromotion with bishop underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Bishop, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
|
||||||
case _ => fail("Expected Moved with Bishop")
|
|
||||||
|
|
||||||
test("completePromotion with knight underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved with Knight")
|
|
||||||
|
|
||||||
test("completePromotion captures opponent piece"):
|
|
||||||
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected Moved with captured piece")
|
|
||||||
|
|
||||||
test("completePromotion for black pawn to R1"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
|
|
||||||
PromotionPiece.Knight, Color.Black
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion evaluates check after promotion"):
|
|
||||||
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
|
||||||
|
|
||||||
test("completePromotion full round-trip via processMove then completePromotion"):
|
|
||||||
// Black king on h1: not attacked by queen on e8
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
|
||||||
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
|
||||||
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
|
||||||
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("completePromotion results in checkmate when promotion delivers checkmate"):
|
|
||||||
// Black king a8, white pawn h7, white king b6.
|
|
||||||
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
|
|
||||||
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
|
|
||||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case MoveResult.Checkmate(_) => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Checkmate(winner) => winner should be (Color.White)
|
|
||||||
case _ => fail("Expected Checkmate")
|
|
||||||
|
|
||||||
test("completePromotion results in stalemate when promotion stalemates opponent"):
|
|
||||||
// Black king a8, white pawn b7, white bishop c7, white king b6.
|
|
||||||
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
|
|
||||||
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
|
|
||||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result should be (MoveResult.Stalemate)
|
|
||||||
|
|
||||||
// ──── half-move clock propagation ────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
|
||||||
// g1f3 is a knight move — not a pawn, not a capture
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 1
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: capture resets halfMoveClock to 0"):
|
|
||||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory(halfMoveClock = 10)
|
|
||||||
processMove(board, history, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
|
||||||
val history = GameHistory(halfMoveClock = 5)
|
|
||||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 6
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, Color}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.chess.observer.*
|
||||||
|
import de.nowchess.io.fen.FenParser
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
|
object EngineTestHelpers:
|
||||||
|
|
||||||
|
def makeEngine(): GameEngine =
|
||||||
|
new GameEngine(ruleSet = DefaultRules)
|
||||||
|
|
||||||
|
def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine =
|
||||||
|
GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||||
|
|
||||||
|
def loadFen(engine: GameEngine, fen: String): Unit =
|
||||||
|
engine.loadGame(FenParser, fen)
|
||||||
|
|
||||||
|
def captureEvents(engine: GameEngine): mutable.ListBuffer[GameEvent] =
|
||||||
|
val events = mutable.ListBuffer[GameEvent]()
|
||||||
|
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||||
|
events
|
||||||
|
|
||||||
|
class MockObserver extends Observer:
|
||||||
|
private val _events = mutable.ListBuffer[GameEvent]()
|
||||||
|
|
||||||
|
def events: mutable.ListBuffer[GameEvent] = _events
|
||||||
|
def eventCount: Int = _events.length
|
||||||
|
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
||||||
|
_events.exists(ct.runtimeClass.isInstance(_))
|
||||||
|
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||||
|
_events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
|
||||||
|
|
||||||
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
|
_events += event
|
||||||
|
|
||||||
|
def clear(): Unit =
|
||||||
|
_events.clear()
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package de.nowchess.chess.engine
|
|
||||||
|
|
||||||
import scala.collection.mutable
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
/** Tests for GameEngine edge cases and uncovered paths */
|
|
||||||
class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("GameEngine handles empty input"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("")
|
|
||||||
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("Please enter a valid move or command")
|
|
||||||
|
|
||||||
test("GameEngine processes quit command"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("quit")
|
|
||||||
// Quit just returns, no events
|
|
||||||
observer.events.isEmpty shouldBe true
|
|
||||||
|
|
||||||
test("GameEngine processes q command (short form)"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("q")
|
|
||||||
observer.events.isEmpty shouldBe true
|
|
||||||
|
|
||||||
test("GameEngine handles uppercase quit"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("QUIT")
|
|
||||||
observer.events.isEmpty shouldBe true
|
|
||||||
|
|
||||||
test("GameEngine handles undo on empty history"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.canUndo shouldBe false
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("Nothing to undo")
|
|
||||||
|
|
||||||
test("GameEngine handles redo on empty redo history"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.canRedo shouldBe false
|
|
||||||
engine.processUserInput("redo")
|
|
||||||
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("Nothing to redo")
|
|
||||||
|
|
||||||
test("GameEngine parses invalid move format"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("invalid_move_format")
|
|
||||||
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("Invalid move format")
|
|
||||||
|
|
||||||
test("GameEngine handles lowercase input normalization"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput(" UNDO ") // With spaces and uppercase
|
|
||||||
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent] // No moves to undo yet
|
|
||||||
|
|
||||||
test("GameEngine preserves board state on invalid move"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val initialBoard = engine.board
|
|
||||||
|
|
||||||
engine.processUserInput("invalid")
|
|
||||||
|
|
||||||
engine.board shouldBe initialBoard
|
|
||||||
|
|
||||||
test("GameEngine preserves turn on invalid move"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val initialTurn = engine.turn
|
|
||||||
|
|
||||||
engine.processUserInput("invalid")
|
|
||||||
|
|
||||||
engine.turn shouldBe initialTurn
|
|
||||||
|
|
||||||
test("GameEngine undo with no commands available"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
// Make a valid move
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
observer.events.clear()
|
|
||||||
|
|
||||||
// Undo it
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
|
|
||||||
// Board should be reset
|
|
||||||
engine.board shouldBe Board.initial
|
|
||||||
engine.turn shouldBe Color.White
|
|
||||||
|
|
||||||
test("GameEngine redo after undo"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
val boardAfterMove = engine.board
|
|
||||||
val turnAfterMove = engine.turn
|
|
||||||
observer.events.clear()
|
|
||||||
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.processUserInput("redo")
|
|
||||||
|
|
||||||
engine.board shouldBe boardAfterMove
|
|
||||||
engine.turn shouldBe turnAfterMove
|
|
||||||
|
|
||||||
test("GameEngine canUndo flag tracks state correctly"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
|
|
||||||
engine.canUndo shouldBe false
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.canUndo shouldBe true
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.canUndo shouldBe false
|
|
||||||
|
|
||||||
test("GameEngine canRedo flag tracks state correctly"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
|
|
||||||
engine.canRedo shouldBe false
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.canRedo shouldBe false
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.canRedo shouldBe true
|
|
||||||
|
|
||||||
test("GameEngine command history is accessible"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
|
|
||||||
engine.commandHistory.isEmpty shouldBe true
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.commandHistory.size shouldBe 1
|
|
||||||
|
|
||||||
test("GameEngine processes multiple moves in sequence"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
observer.events.clear()
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.processUserInput("e7e5")
|
|
||||||
|
|
||||||
observer.events.size shouldBe 2
|
|
||||||
engine.commandHistory.size shouldBe 2
|
|
||||||
|
|
||||||
test("GameEngine can undo multiple moves"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.processUserInput("e7e5")
|
|
||||||
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.turn shouldBe Color.Black
|
|
||||||
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.turn shouldBe Color.White
|
|
||||||
|
|
||||||
test("GameEngine thread-safe operations"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
|
|
||||||
// Access from synchronized methods
|
|
||||||
val board = engine.board
|
|
||||||
val history = engine.history
|
|
||||||
val turn = engine.turn
|
|
||||||
val canUndo = engine.canUndo
|
|
||||||
val canRedo = engine.canRedo
|
|
||||||
|
|
||||||
board shouldBe Board.initial
|
|
||||||
canUndo shouldBe false
|
|
||||||
canRedo shouldBe false
|
|
||||||
|
|
||||||
|
|
||||||
private class MockObserver extends Observer:
|
|
||||||
val events = mutable.ListBuffer[GameEvent]()
|
|
||||||
|
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
|
||||||
events += event
|
|
||||||