Compare commits

..

33 Commits

Author SHA1 Message Date
Janis 8959c3a849 refactor: NCS-24 update CLAUDE.md for improved structure and clarity (#20)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #20
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
2026-04-07 12:25:33 +02:00
TeamCity 47032378e2 ci: bump version with Build-31 2026-04-07 06:25:56 +00:00
lq64 217f14f899 refactor: NCS-19 Currying (#18)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Curried candidateMoves, legalMoves, and applyMove in the RuleSet trait to separate (context) as the world being
  operated on from the computation parameter
  - Updated DefaultRules overrides and all internal call sites
  - Updated all external call sites: GameEngine, PgnParser, PgnExporter, ChessBoardView, and all affected tests

  Test plan

  - All existing tests pass (./gradlew build)
  - No behaviour changes — pure style refactoring, existing test suite is the regression guard

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #18
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-06 21:03:17 +02:00
TeamCity 638139602c ci: bump version with Build-30 2026-04-06 07:21:42 +00:00
Janis 8f56a82104 refactor: NCS-22 NCS-23 reworked modules and tests (#17)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #17
2026-04-06 09:07:39 +02:00
TeamCity 51ffd7aac9 ci: bump version with Build-28 2026-04-03 09:09:16 +00:00
Janis 1b9eb471de fix: set PYTHONUTF8 environment variable for coverage scripts (#16)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #16
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-03 11:03:02 +02:00
TeamCity 45013c87a9 ci: bump version with Build-27 2026-04-02 19:15:54 +00:00
Janis 80518719d5 feat: NCS-21 Write Scripts to automate certain tasks (#15)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #15
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-02 21:11:21 +02:00
TeamCity 2d6ead7e47 ci: bump version with Build-26 2026-04-01 20:53:08 +00:00
Janis 3ff80318b4 feat: NCS-17 Implement basic ScalaFX UI (#14)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #14
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-01 22:48:30 +02:00
TeamCity 9fb743d135 ci: bump version with Build-25 2026-04-01 08:40:41 +00:00
lq64 412ed986a9 feat: NCS-11 50-move rule (#9)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Implements the FIDE 50-move draw rule: a player may claim a draw if no pawn move or capture has occurred in the last
   50 full moves (100 half-moves)
  - Draw is not automatic — the eligible player must claim it via a TUI menu shown at the start of their turn
  - halfMoveClock: Int is threaded through processMove and gameLoop; resets on pawn move, capture, or en passant;
  increments on all other moves

  Changes

  - GameController.scala: extended MoveResult.Moved and MoveResult.MovedInCheck with newHalfMoveClock: Int; added
  MoveResult.DrawClaimed; added halfMoveClock parameter to processMove and gameLoop; TUI menu shown when clock ≥ 100
  - Main.scala: initial gameLoop call passes halfMoveClock = 0
  - GameControllerTest.scala: updated all existing pattern matches; added 10 new tests covering clock reset, clock
  increment, draw claim, and TUI menu behaviour

  Test plan

  - processMove: 'draw' with halfMoveClock = 100 → DrawClaimed
  - processMove: 'draw' with halfMoveClock = 99 → InvalidFormat
  - Pawn move / capture / en passant → clock resets to 0
  - Quiet piece move → clock increments by 1
  - MovedInCheck carries updated clock
  - TUI menu appears when clock ≥ 100; option 1 claims draw, option 2 continues
  - No TUI menu when clock < 100
  - All 197 tests passing

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-01 10:36:24 +02:00
TeamCity 8bbeead702 ci: bump version with Build-24 2026-04-01 07:17:44 +00:00
Janis e5e20c566e fix: update move validation to check for king safety (#13)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #13
2026-04-01 09:07:06 +02:00
Janis 13bfc16cfe feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #12
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
2026-03-31 22:18:14 +02:00
TeamCity 85cbf95c18 ci: bump version with Build-22 2026-03-31 08:35:28 +00:00
shosho996 1361dfc895 feat: NCS-16 Core Separation via Patterns (#10)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: Janis <janis-e@gmx.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #10
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Shahd Lala <shosho996@blackhole.local>
Co-committed-by: Shahd Lala <shosho996@blackhole.local>
2026-03-31 10:31:02 +02:00
TeamCity 707c4826a4 ci: bump version with Build-21 2026-03-29 15:10:35 +00:00
lq64 919beb3b4b feat: NCS-9 En passant implementation (#8)
Build & Test (NowChessSystems) TeamCity build finished
- Add EnPassantCalculator to derive the en passant target square from GameHistory, detect en passant captures, and
  compute the captured pawn's square
  - Extend MoveValidator.legalTargets to include the en passant diagonal square in pawn legal targets
  - Extend GameController.processMove to remove the captured pawn from the board when an en passant capture is played

  Details

  En passant is derived purely from the last HistoryMove — no new state is introduced. If the last move was a double
  pawn push, the target square is the square the pawn passed through. The board mutation follows the same pattern as
  castling: board.withMove moves the capturing pawn, then board.removed removes the captured pawn from its actual square
   (which differs from the destination square).

  Test Plan

  - EnPassantCalculatorTest — 14 unit tests covering target derivation, captured square calculation, and capture
  detection for both colors
  - MoveValidatorTest — 5 new tests: ep target included/excluded based on history, adjacency filter, both colors, case _
   branch coverage
  - GameControllerTest — 2 integration tests: white and black en passant capture removes pawn from board and returns
  correct captured piece
  - 100% scoverage (line/branch/method) confirmed

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #8
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 17:06:50 +02:00
TeamCity ee79dc5b98 ci: bump version with Build-20 2026-03-29 12:06:38 +00:00
Janis f28e69dc18 feat: NCS-6 Implementing FEN & PGN (#7)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #7
Reviewed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 14:02:25 +02:00
TeamCity 5f485fed9b ci: bump version with Build-19 2026-03-28 17:12:54 +00:00
Janis f4c18d22d7 refactor: NCS-8 removed Context and replaced it with History (#6)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #6
2026-03-28 18:08:55 +01:00
TeamCity 4d800e88eb ci: bump version with Build-18 2026-03-28 12:39:59 +00:00
Janis 2df2fdeeb9 feat: add memory structure documentation and update index (#5)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #5
2026-03-28 13:35:49 +01:00
TeamCity 9190d1e5a0 ci: bump version with v17%
Build & Test (NowChessSystems) TeamCity build finished
2026-03-27 09:25:13 +00:00
Janis d675966436 refactor: replace return/var in castlingTargets with functional style (#4)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #4
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-03-25 08:48:49 +01:00
Janis b4116e9a82 test: add unit tests for API response, board, color, game state, move, piece, and square (#3)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #3
2026-03-25 08:19:43 +01:00
Janis 11efb1a42d docs: remove outdated JUnitSuiteLike issue from unresolved.md
Build & Test (NowChessSystems) TeamCity build finished
2026-03-24 18:12:53 +01:00
lq64 00d326c1ba feat: implement legal castling (#1)
Build & Test (NowChessSystems) TeamCity build finished
## Summary

  - Introduces `GameContext` wrapper (board + castling rights) threading through the entire engine pipeline
  - Extends `MoveValidator` with `castlingTargets`, context-aware `legalTargets`/`isLegal` overloads, and helpers (`isCastle`, `castleSide`)
  - Updates `GameRules.legalMoves` and `gameStatus` to use `GameContext`, preventing false stalemate when castling is the only legal move
  - Adds castle detection and atomic execution (`withCastle`) to `GameController.processMove`, plus full rights revocation via source- and
  destination-square tables

  ## Test Plan

  - [ ] 142 tests passing, 100% statement and branch coverage on `modules/core`
  - [ ] White/Black kingside (e1g1/e8g8) and queenside (e1c1/e8c8) castling moves execute correctly
  - [ ] All six legality conditions enforced (rights flags, home squares, empty transit, king not in check, transit/landing squares not attacked)
  - [ ] Rights revoked on king moves, own rook moves, castle moves, and enemy rook captures
  - [ ] False stalemate correctly prevented when castling is the only escape

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #1
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-03-24 17:55:00 +01:00
Janis d0289e16f4 fix: update unit test documentation to remove JUnitSuiteLike reference
Build & Test (NowChessSystems) TeamCity build finished
2026-03-24 12:25:42 +01:00
Janis 7b1f8b1176 fix: update main class path in build configuration and adjust VCS directory mapping
Build & Test (NowChessSystems) TeamCity build finished
2026-03-24 09:59:13 +01:00
175 changed files with 6280 additions and 6583 deletions
-120
View File
@@ -1,120 +0,0 @@
# Claude Code Working Agreement
## Workflow: Plan → Write Tests → Implement → Verify
### 1. Plan First
Before writing any code, produce an explicit plan:
- Restate the requirement in your own words to confirm understanding.
- List every file you intend to create or modify.
- Identify risks or unknowns upfront.
- Wait for confirmation **only** when the plan reveals an ambiguity that cannot be resolved from context. Otherwise proceed immediately.
### 2. Write Tests
Before implementing, write tests that should cover the new behaviour.
Only write tests for the new behaviour.
### 3. Implement
Follow the plan. Do not add scope beyond what was agreed.
### 4. Verify Every Requirement
After implementation, go through each requirement one-by-one and confirm it is satisfied:
- Run the relevant tests (unit, integration, or build check) for every changed module.
- If a requirement **cannot** be fulfilled, do **not** silently skip it — document it immediately (see *Unresolved Requirements* below).
---
## No Code Without Verification (Testing)
- Every new behaviour must be covered by at least one automated test before the task is considered done.
- Every bug fix must be accompanied by a regression test that fails before the fix and passes after.
- Run `./gradlew :modules:<module>:test` (or the appropriate Gradle task) and confirm a green build before marking work complete.
- If a test cannot be written for a legitimate reason, document it in `docs/unresolved.md` with an explanation.
---
## Automatic Bug Fixing
- When a test or build step fails, attempt to fix the root cause immediately — do **not** ask for permission.
- Apply the fix, re-run the verification, and continue until green.
- If the same failure persists after **three** fix attempts, stop, log the issue in `docs/unresolved.md`, and surface a concise summary.
---
## Unresolved Requirements → `docs/unresolved.md`
When a requirement or bug cannot be resolved, append an entry to `docs/unresolved.md`:
```markdown
## [YYYY-MM-DD] <Short title>
**Requirement / Bug:**
<What was requested or what failed>
**Root Cause (if known):**
<Why it cannot be resolved right now>
**Attempted Fixes:**
1. <What was tried>
2.
**Suggested Next Step:**
<What a human engineer should investigate>
```
Create the file if it does not exist. Never delete existing entries.
---
## Project Structure
```
. ← Repository root (multi-project Gradle setup)
├── build.gradle.kts ← Root build file (shared plugins, dependency versions)
├── settings.gradle.kts ← Gradle settings (declares all subprojects)
├── modules/ ← One subdirectory per microservice
│ └── <service>/
│ ├── build.gradle.kts
│ └── src/
└── docs/ ← Architecture Decision Records, API docs, unresolved issues
└── unresolved.md
```
### Conventions
- All microservices live under `modules/{service-name}`. Never place service code in the root.
- Shared configuration (dependency versions, plugin setup) belongs in the **root** `build.gradle.kts` or in `buildSrc` / a version catalog.
- `settings.gradle.kts` must include every module via `include(":modules:<service>")`.
- Architecture decisions go in `docs/adr/` as numbered Markdown files (`ADR-001-<title>.md`).
- API contracts live in `/docs/api/`.
- Unit tests extend `AnyFunSuite with Matchers with JUnitSuiteLike` — no `@Test` annotations, no `: Unit` requirement
- Integration tests use `@QuarkusTest` with JUnit 5 — `@Test` methods must be explicitly typed `: Unit`
- Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts
## Coverage Conventions
- Branch coverage must be at least 90% - unless there is a good reason not to.
- Line coverage must be at least 95% - unless there is a good reason not to.
- Method coverage must be at least 90% - unless there is a good reason not to.
- To check coverage use jacoco-reporter/scoverage_coverage_gaps.py modules/{service}/build/reports/scoverageTest/scoverage.xml
- IMPORTANT: modules/{service}/build/reports/scoverage/scoverage.xml is not used for coverage TEST calculation. Do not use it.
## Agent Routing Rules
### Use agents in PARALLEL when:
- Tasks touch different, independent microservices
- No shared files or state between tasks
- Example: "implement service-user AND service-orders simultaneously"
### Use agents SEQUENTIALLY when:
- Tasks have dependencies (architect → implementer → test-writer)
- Shared API contracts are involved
- Example: design API first, then implement, then test
## Quick-Reference Checklist
Before considering any task done, confirm:
- [ ] Plan was written and requirements restated
- [ ] All planned files were created / modified
- [ ] Automated tests cover the new behaviour
- [ ] `./gradlew build` (or scoped task) is green
- [ ] Each requirement has been explicitly verified
- [ ] Any unresolved items are logged in `docs/unresolved.md`
+1 -1
View File
@@ -23,7 +23,7 @@ report findings to team-leader, who re-invokes scala-implementer for fixes.
- `@QuarkusTest` methods (JUnit 5) must be explicitly typed `: Unit`
### Tests
- Unit tests must extend `AnyFunSuite with Matchers with JUnitSuiteLike`, not plain JUnit 5
- Unit tests must extend `AnyFunSuite with Matchers`
- Integration tests use `@QuarkusTest` with JUnit 5 `@Test` methods
- No raw `@Test` annotations on plain unit test classes
+2 -1
View File
@@ -2,9 +2,10 @@
name: scala-implementer
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
tools: Read, Write, Edit, Bash, Glob
model: sonnet
model: inherit
color: pink
---
You do not have permissions to write tests, just source code.
You are a Scala 3 expert specialising in Quarkus microservices.
Always read the relevant /docs/api/ file before implementing.
+3 -3
View File
@@ -2,9 +2,10 @@
name: test-writer
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
model: sonnet
model: haiku
color: purple
---
You do not have permissions to modify the source code, just write tests.
You write tests for Scala 3 + Quarkus services.
@@ -12,12 +13,11 @@ You write tests for Scala 3 + Quarkus services.
- Unit tests: `extends AnyFunSuite with Matchers` — use `test("description") { ... }` DSL, no `@Test` annotation, no `: Unit` return type needed.
- Integration tests: `@QuarkusTest` with JUnit 5 — `@Test` methods MUST be explicitly typed `: Unit`.
Target 95%+ conditional coverage.
Target 100% conditional coverage if possible.
When invoked BEFORE scala-implementer (no implementation exists yet):
Use the contract-first-test-writing skill — write failing tests from docs/api/{service}.yaml.
When invoked AFTER scala-implementer (implementation exists):
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
To regenerate the report run the tests first.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true,
"ui-ux-pro-max@ui-ux-pro-max-skill": true
"superpowers@claude-plugins-official": false,
"ui-ux-pro-max@ui-ux-pro-max-skill": false
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+3
View File
@@ -12,6 +12,9 @@
<option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/ui" />
</set>
</option>
</GradleProjectSettings>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.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="uncheckedWarnings" value="true" />
<parameters>
Generated
+11 -1
View File
@@ -6,7 +6,17 @@
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="IssueNavigationConfiguration">
<option name="links">
<list>
<IssueNavigationLink>
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
</IssueNavigationLink>
</list>
</option>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>
+7
View File
@@ -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/
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

+38 -46
View File
@@ -1,58 +1,50 @@
# CLAUDE.md
# Now-Chess
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scala 3.5.1 · Gradle 9
## Build & Test Commands
## Commands
```bash
# Build everything
./gradlew build
# Build a single module
./gradlew :modules:<service>:build
# Run tests for a single module
./gradlew :modules:<service>:test
# Run a specific test class
./gradlew :modules:<service>:test --tests "de.nowchess.<service>.<ClassName>"
```
./clean # Clear build dirs — only when necessary
./compile # Compile all modules — always run
./test # Run all tests
./coverage # Check coverage
```
Try to stick to these commands for consistency.
The only current module is `core` (`modules/core`).
## Modules
## Architecture
| 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 |
**NowChessSystems** is a chess platform built as a Scala 3 + Quarkus microservice system.
## Style
- Multi-module Gradle project; every service lives under `modules/{service-name}`.
- Shared dependency versions live in the root `build.gradle.kts` under `extra["VERSIONS"]`.
- Each module reads versions via `rootProject.extra["VERSIONS"] as Map<String, String>`.
- `settings.gradle.kts` must `include(":modules:<service>")` for every module.
- 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.
### Stack (ADR-001)
| Layer | Technology |
|---|---|
| Language | Scala 3.5.x |
| Backend framework | Quarkus + `quarkus-scala3` extension |
| Persistence | Hibernate / Jakarta Persistence |
| Frontend (TBD) | Vite; React/Angular/Vue under evaluation |
| TUI | Lanterna |
| Container orchestration | Kubernetes + ArgoCD + Kargo |
## Code Quality
### Key Scala 3 / Quarkus Rules
- Use `given`/`using`, not `implicit` (no Scala 2 idioms).
- Use `Option`/`Either`/`Try`, never `null` or `.get`.
- Jakarta annotations only (`jakarta.*`), never `javax.*`.
- Use reactive types (`Uni`, `Multi`) for I/O; no blocking calls on the event loop.
- **Always exclude `org.scala-lang:scala-library` from Quarkus BOM** to avoid Scala 2 conflicts.
- **Unit tests use `extends AnyFunSuite with Matchers with JUnitSuiteLike`** — ScalaTest DSL, no `@Test` annotations needed.
- **Integration tests use `@QuarkusTest` with JUnit 5** — explicit `: Unit` return type still required on `@Test` methods.
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
### Agent Workflow (for new services)
1. **architect** → writes OpenAPI contract to `docs/api/{service}.yaml` and ADR to `docs/adr/`.
2. **scala-implementer** → reads contract, implements service under `modules/{service}/`.
3. **test-writer** → writes `@QuarkusTest` integration tests and `AnyFunSuite with Matchers with JUnitSuiteLike` unit tests.
4. **gradle-builder** → resolves any build/dependency issues.
5. **code-reviewer** → reviews; reports findings back without self-fixing.
## Architecture Decisions
Detailed working agreement (plan/verify/unresolved workflow) is in `.claude/CLAUDE.MD`.
- **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.
+5 -1
View File
@@ -1,5 +1,6 @@
plugins {
id("org.sonarqube") version "7.2.3.7755"
id("org.scoverage") version "8.1" apply false
}
group = "de.nowchess"
@@ -28,7 +29,10 @@ val versions = mapOf(
"SCALA_LIBRARY" to "2.13.18",
"SCALATEST" to "3.2.19",
"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
+4
View File
@@ -0,0 +1,4 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew clean
Executable
+4
View File
@@ -0,0 +1,4 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew classes
Executable
+10
View File
@@ -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
-43
View File
@@ -1,43 +0,0 @@
# ADR-001: Technology Stack Selection
## Status
Accepted
## Context
The "NowChessSystems" project requires a modern, scalable,
and maintainable technology stack to support web-based interfaces.
The system is designed as a microservice architecture to allow for independent scaling and development of various components (e.g., engine, matchmaking, user management).
## Decision
We have decided to use the following technologies for the core system:
### Backend
- **Language:** [Scala 3](https://scala-lang.org/) for its powerful type system, functional programming capabilities, and seamless JVM integration.
- **Framework:** [Quarkus](https://quarkus.io/) with the `io.quarkiverse.scala:quarkus-scala3` extension to leverage GraalVM native compilation and fast startup times.
- **Persistence:** [Hibernate](https://hibernate.org/) and [Jakarta Persistence](https://jakarta.ee/specifications/persistence/) for standard-based ORM.
### Frontend
- **Build Tool:** [Vite](https://vitejs.dev/) for a fast development experience.
- **Framework:** TBD (Evaluation between React, Angular, and Vue).
- **Terminal UI:** [Lanterna](https://github.com/mabe02/lanterna) for a text-based user interface (TUI).
### DevOps & Infrastructure
- **Orchestration:** [Kubernetes](https://kubernetes.io/) for container orchestration.
- **GitOps & Delivery:** [ArgoCD](https://argoproj.github.io/cd/) for continuous delivery and [Kargo](https://kargo.io/) for multi-stage lifecycle management.
### AI-Assisted Development
- [Claude Code Pro](https://claude.ai/) and [Claude Agent Teams](https://claude.ai/team) for coding and reviews.
- [Google Stitch](https://stitch.google.com/) (Free) for UI design and prototyping.
## Consequences
### Positive
- **High Performance:** Quarkus and GraalVM enable low memory footprint and fast startup.
- **Developer Productivity:** Scala 3 and AI tools provide a high-level, expressive environment.
- **Robustness:** Kubernetes and ArgoCD ensure reliable deployment and scaling.
- **Accessibility:** Offering both a TUI and a web interface caters to different user preferences.
### Negative / Risks
- **Complexity:** Managing a microservices architecture with Kubernetes adds operational overhead.
- **Learning Curve:** Scala 3 and the specific Quarkus-Scala integration may require training for new developers.
- **Consistency:** Maintaining parity between the TUI and Web frontend functionality.
-86
View File
@@ -1,86 +0,0 @@
# ADR-002: Shared-Models Library (`modules/api`)
## Status
Accepted
## Context
NowChessSystems is a microservice platform. As soon as two or more services need to
exchange data — whether through REST, messaging, or internal function calls — they must
agree on common data types. Without a shared home for those types, the same case class
(e.g. `Square`, `Move`, `GameState`) is duplicated in every module, diverges over time,
and causes silent serialisation mismatches at runtime.
The `core` module currently owns the chess engine logic. Future modules (matchmaking,
game history, user management, notation export, etc.) will all need to refer to the
same chess domain vocabulary. A cross-cutting place to hold that vocabulary is therefore
required before any second service is built.
## Decision
We introduce `modules/api` as a **shared-models library**: a plain Scala 3 library
(no Quarkus, no Jakarta, no persistence) that contains only:
- Pure Scala 3 data types: `case class`, `sealed trait`, and `enum` definitions
- Value objects that model the chess domain (pieces, colors, squares, moves, game state)
- Cross-service API envelope types (`ApiResponse[A]`, `ApiError`, `Pagination`)
- Minimal player/user identity stubs (IDs and display names only)
Every service module that needs these types declares:
```kotlin
implementation(project(":modules:api"))
```
in its own `build.gradle.kts`. The `modules/api` module itself carries no runtime
dependencies beyond the Scala 3 standard library.
### Package layout
```
de.nowchess.api
├── board Color, PieceType, Piece, File, Rank, Square
├── game CastlingRights, GameState, GameResult, GameStatus
├── move MoveType, Move, PromotionPiece
├── player PlayerId, PlayerInfo
└── response ApiResponse, ApiError, Pagination
```
## What belongs in `modules/api`
| Belongs | Does NOT belong |
|---|---|
| `case class`, `sealed trait`, `enum` for chess domain | Quarkus `@ApplicationScoped` beans |
| API envelope types (`ApiResponse`, `ApiError`) | Jakarta Persistence entities (`@Entity`) |
| Player identity stubs (ID + display name) | REST resource classes |
| FEN/board-state representation types | Business logic, engine algorithms |
| Pure type aliases and value objects | Database queries or repositories |
The rule of thumb: if a type carries a framework annotation or requires I/O to produce,
it does not belong in `modules/api`.
## How other modules depend on it
1. `modules/api` is a regular Gradle subproject already declared in `settings.gradle.kts`.
2. Consuming modules add `implementation(project(":modules:api"))` — nothing else.
3. Because `modules/api` has no Quarkus BOM, consuming modules must not re-export Quarkus
transitive dependencies through it.
4. If a future module needs JSON serialisation, it adds its own JSON library (e.g.
`circe`, `jsoniter-scala`) as a dependency and derives codecs for the shared types
there — codec derivation stays out of `modules/api`.
## Consequences
### Positive
- Single source of truth for all chess domain vocabulary.
- Adding a new microservice requires only one `implementation(project(":modules:api"))`
line — no copy-paste of types.
- The library is fast to compile (no framework processing) and cheap to test in isolation.
- Enforces a strict boundary: if a type needs a framework annotation it is forced into the
correct service module.
### Negative / Risks
- Any breaking change to a shared type (rename, field removal) is a cross-cutting change
that touches every consuming module simultaneously.
- Developers must resist the temptation to add convenience methods or logic to these
types; discipline is required to keep the library pure.
@@ -1,244 +0,0 @@
# ScalaTest + Scoverage Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace JaCoCo with Scoverage and add ScalaTest (with its JUnit 5 bridge) as the test library across all modules.
**Architecture:** Three build files are modified — the root for shared dependency versions, and each module for plugins, dependencies, and task wiring. No source files are created. The Scoverage Gradle plugin is applied per-module with its version hardcoded inline (Gradle resolves `plugins {}` before `rootProject.extra` is available).
**Tech Stack:** Scala 3, Gradle (Kotlin DSL), ScalaTest 3.2.19, scalatestplus-junit-5-11 3.2.19.1, Scoverage Gradle plugin 8.1.
---
## File Map
| File | Change |
|---|---|
| `build.gradle.kts` (root) | Add `SCALATEST` and `SCALATESTPLUS_JUNIT5` version entries |
| `modules/core/build.gradle.kts` | Replace `jacoco` with `org.scoverage`; swap JUnit deps for ScalaTest; merge two `tasks.test {}` blocks |
| `modules/api/build.gradle.kts` | Same as core; also add missing `useJUnitPlatform()` |
---
### Task 1: Add ScalaTest version entries to root build
**Files:**
- Modify: `build.gradle.kts` (root)
- [ ] **Step 1: Add version entries**
Open `build.gradle.kts` at the root. The `versions` map currently looks like:
```kotlin
val versions = mapOf(
"QUARKUS_SCALA3" to "1.0.0",
"SCALA3" to "3.5.1",
"SCALA_LIBRARY" to "2.13.18"
)
```
Add two entries so it becomes:
```kotlin
val versions = mapOf(
"QUARKUS_SCALA3" to "1.0.0",
"SCALA3" to "3.5.1",
"SCALA_LIBRARY" to "2.13.18",
"SCALATEST" to "3.2.19",
"SCALATESTPLUS_JUNIT5" to "3.2.19.1"
)
```
- [ ] **Step 2: Verify the root build file parses**
```bash
./gradlew help --quiet
```
Expected: exits 0 with no errors.
- [ ] **Step 3: Commit**
```bash
git add build.gradle.kts
git commit -m "build: add ScalaTest version entries to root versions map"
```
---
### Task 2: Migrate `modules/core` to ScalaTest + Scoverage
**Files:**
- Modify: `modules/core/build.gradle.kts`
- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`**
In the `plugins {}` block, replace:
```kotlin
jacoco
```
with:
```kotlin
id("org.scoverage") version "8.1"
```
The full plugins block should be:
```kotlin
plugins {
id("scala")
id("org.scoverage") version "8.1"
application
}
```
- [ ] **Step 2: Swap JUnit dependencies for ScalaTest**
In the `dependencies {}` block, remove:
```kotlin
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
```
Add in their place:
```kotlin
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")
```
- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring**
The file currently has two separate `tasks.test {}` blocks and a `tasks.jacocoTestReport {}` block. Delete all three. Add the following single merged block **after** the `dependencies {}` block:
```kotlin
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
```
- [ ] **Step 4: Run the tests**
```bash
./gradlew :modules:core:test
```
Expected: BUILD SUCCESSFUL. (Zero tests is fine — there are no test files yet. The build must not fail with dependency resolution or plugin errors.)
- [ ] **Step 5: Run the coverage report**
```bash
./gradlew :modules:core:reportScoverage
```
Expected: BUILD SUCCESSFUL. A report is generated under `modules/core/build/reports/scoverage/`.
- [ ] **Step 6: Commit**
```bash
git add modules/core/build.gradle.kts
git commit -m "build(core): replace JaCoCo with Scoverage, add ScalaTest dependencies"
```
---
### Task 3: Migrate `modules/api` to ScalaTest + Scoverage
**Files:**
- Modify: `modules/api/build.gradle.kts`
- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`**
In the `plugins {}` block, replace:
```kotlin
jacoco
```
with:
```kotlin
id("org.scoverage") version "8.1"
```
The full plugins block should be:
```kotlin
plugins {
id("scala")
id("org.scoverage") version "8.1"
}
```
- [ ] **Step 2: Swap JUnit dependencies for ScalaTest**
In the `dependencies {}` block, remove:
```kotlin
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
```
Add in their place:
```kotlin
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")
```
- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring**
The `modules/api` file also has two `tasks.test {}` blocks and a `jacocoTestReport` block. Delete all three. Add the following merged block **after** the `dependencies {}` block:
```kotlin
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
```
> Note: `modules/api` did not previously have `useJUnitPlatform()` — it is being **added** here, not preserved.
- [ ] **Step 4: Run the tests**
```bash
./gradlew :modules:api:test
```
Expected: BUILD SUCCESSFUL.
- [ ] **Step 5: Run the coverage report**
```bash
./gradlew :modules:api:reportScoverage
```
Expected: BUILD SUCCESSFUL. A report is generated under `modules/api/build/reports/scoverage/`.
- [ ] **Step 6: Commit**
```bash
git add modules/api/build.gradle.kts
git commit -m "build(api): replace JaCoCo with Scoverage, add ScalaTest dependencies"
```
---
### Task 4: Full build verification
- [ ] **Step 1: Run the full build**
```bash
./gradlew build
```
Expected: BUILD SUCCESSFUL with no errors across all modules.
- [ ] **Step 2: Confirm no JaCoCo references remain**
```bash
grep -r "jacoco\|jacocoTestReport" --include="*.kts" .
```
Expected: no output (zero matches).
@@ -1,579 +0,0 @@
# Chess Check / Checkmate / Stalemate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add check detection, checkmate (win by opponent having no legal reply while in check), and stalemate (draw by opponent having no legal reply while not in check) to the chess game loop.
**Architecture:** A new `GameRules` object owns all check-aware logic; the existing `MoveValidator` keeps its geometric-only contract unchanged. `GameController.processMove` calls `GameRules.gameStatus` after each move and returns new `MoveResult` variants (`MovedInCheck`, `Checkmate`, `Stalemate`). Terminal states reset the board.
**Tech Stack:** Scala 3.5, ScalaTest (`AnyFunSuite with Matchers`), Gradle (`:modules:core:test`)
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | **Create** | `isInCheck`, `legalMoves`, `gameStatus`, `PositionStatus` enum |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | **Create** | Unit tests for all three `GameRules` methods |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | **Modify** | Add `MovedInCheck`/`Checkmate`/`Stalemate` to `MoveResult`; wire `processMove` and `gameLoop` |
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | **Modify** | Add `processMove` and `gameLoop` tests for the three new results |
---
## Task 1: Create `GameRules` stub
**Files:**
- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
- [ ] **Step 1: Create the stub file**
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
object GameRules:
/** True if `color`'s king is under attack on this board. */
def isInCheck(board: Board, color: Color): Boolean = false
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, color: Color): Set[(Square, Square)] = Set.empty
/** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, color: Color): PositionStatus = PositionStatus.Normal
```
- [ ] **Step 2: Verify the project compiles**
```bash
./gradlew :modules:core:compileScala
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 3: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
git commit -m "feat: add GameRules stub with PositionStatus enum"
```
---
## Task 2: Write `GameRulesTest` (all tests must fail)
**Files:**
- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala`
- [ ] **Step 1: Create the test file**
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameRulesTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
// ──── isInCheck ──────────────────────────────────────────────────────
test("isInCheck: king attacked by enemy rook on same rank"):
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.BlackRook
)
GameRules.isInCheck(b, Color.White) shouldBe true
test("isInCheck: king not attacked"):
// Black Rook A3 does not cover E1
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R3) -> Piece.BlackRook
)
GameRules.isInCheck(b, Color.White) shouldBe false
test("isInCheck: no king on board returns false"):
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
GameRules.isInCheck(b, Color.White) shouldBe false
// ──── legalMoves ─────────────────────────────────────────────────────
test("legalMoves: move that exposes own king to rook is excluded"):
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8
// Moving the White Rook off the E-file would expose the king
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook
)
val moves = GameRules.legalMoves(b, Color.White)
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
test("legalMoves: move that blocks check is included"):
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook
)
val moves = GameRules.legalMoves(b, Color.White)
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
// ──── gameStatus ──────────────────────────────────────────────────────
test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
val b = board(
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Mated
test("gameStatus: stalemate returns Drawn"):
// White Qb6, Kc6; Black Ka8
// Black king has no legal moves and is not in check (spec-verified position)
val b = board(
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Drawn
test("gameStatus: king in check with legal escape returns InCheck"):
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
val b = board(
sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.InCheck
test("gameStatus: normal starting position returns Normal"):
GameRules.gameStatus(Board.initial, Color.White) shouldBe PositionStatus.Normal
```
- [ ] **Step 2: Run the tests and confirm they all fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
```
Expected: all 8 tests FAIL (stubs always return `false` / `Set.empty` / `Normal`)
- [ ] **Step 3: Commit**
```bash
git add modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala
git commit -m "test: add failing GameRulesTest for check/checkmate/stalemate"
```
---
## Task 3: Implement `GameRules`
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
- [ ] **Step 1: Replace the stub bodies with real implementations**
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
object GameRules:
def isInCheck(board: Board, color: Color): Boolean =
board.pieces
.collectFirst { case (sq, Piece(`color`, PieceType.King)) => sq }
.exists { kingSq =>
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
}
}
def legalMoves(board: Board, color: Color): Set[(Square, Square)] =
board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(board, from)
.filter { to =>
val (newBoard, _) = board.withMove(from, to)
!isInCheck(newBoard, color)
}
.map(to => from -> to)
}
.toSet
def gameStatus(board: Board, color: Color): PositionStatus =
val moves = legalMoves(board, color)
val inCheck = isInCheck(board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
else PositionStatus.Normal
```
- [ ] **Step 2: Run the GameRules tests and confirm they all pass**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
```
Expected: all 8 tests PASS
- [ ] **Step 3: Run the full test suite to make sure nothing regressed**
```bash
./gradlew :modules:core:test
```
Expected: `BUILD SUCCESSFUL`, all existing tests still pass
- [ ] **Step 4: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
git commit -m "feat: implement GameRules with isInCheck, legalMoves, gameStatus"
```
---
## Task 4: Add new `MoveResult` variants and stub `processMove`
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
- [ ] **Step 1: Add three new variants to `MoveResult` and import `GameRules`**
In `GameController.scala`, update the `MoveResult` object and `processMove`. The new variants go after `Moved`. The import of `GameRules`/`PositionStatus` is added at the top. The stub `processMove` calls `GameRules.gameStatus` but always maps to `Moved` — this makes it compile while the new tests will fail:
```scala
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, Piece}
import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus}
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
object GameController:
def processMove(board: Board, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, from, to) then
MoveResult.IllegalMove
else
val (newBoard, captured) = board.withMove(from, to)
MoveResult.Moved(newBoard, captured, turn.opposite) // stub — Task 6 will fix
def gameLoop(board: Board, turn: Color): Unit =
println()
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, turn)
case MoveResult.Moved(newBoard, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newTurn)
case MoveResult.MovedInCheck(newBoard, captured, newTurn) => // stub — Task 6
gameLoop(newBoard, newTurn)
case MoveResult.Checkmate(winner) => // stub — Task 6
gameLoop(Board.initial, Color.White)
case MoveResult.Stalemate => // stub — Task 6
gameLoop(Board.initial, Color.White)
```
- [ ] **Step 2: Confirm everything still compiles and existing tests pass**
```bash
./gradlew :modules:core:test
```
Expected: `BUILD SUCCESSFUL` — existing tests still pass, no compilation errors
- [ ] **Step 3: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
git commit -m "feat: add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch)"
```
---
## Task 5: Write new `GameControllerTest` cases (all must fail)
**Files:**
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
- [ ] **Step 1: Append the following tests to the existing file**
Add after the last existing test (the `gameLoop: capture` test). Add the `captureOutput` helper alongside `withInput`:
```scala
// ──── helpers ────────────────────────────────────────────────────────
private def captureOutput(block: => Unit): String =
val out = java.io.ByteArrayOutputStream()
scala.Console.withOut(out)(block)
out.toString("UTF-8")
// ──── processMove: check / checkmate / stalemate ─────────────────────
test("processMove: legal move that delivers check returns MovedInCheck"):
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, putting Kh8 in check
// (Ra8 attacks along rank 8: b8..h8; king escapes to g7/g8/h7 — InCheck, not Mated)
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other")
test("processMove: legal move that results in checkmate returns Checkmate"):
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1-h8)
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified)
// Note: Qa1 does NOT currently attack Ka8 (path along file A is blocked by Ka6)
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "a1h8") match
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
case other => fail(s"Expected Checkmate(White), got $other")
test("processMove: legal move that results in stalemate returns Stalemate"):
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified)
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "b1b6") match
case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other")
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
test("gameLoop: checkmate prints winner message and resets to new game"):
// Same position as checkmate processMove test above; after Qa1-Qh8 game resets
// Second move "quit" exits the new game cleanly
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1h8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"):
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("b1b6\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
// White Rook A1 captures Black Pawn on A8, putting Black King (H8) in check
// Ra8 attacks rank 8 → Black Kh8 is in check; king can escape to g7/g8/h7
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackPawn,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("captures")
output should include("Black is in check!")
```
- [ ] **Step 2: Run only the new tests and confirm they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
```
Expected: the 7 new tests FAIL; the existing 17 tests PASS
- [ ] **Step 3: Commit**
```bash
git add modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "test: add failing GameControllerTest cases for check/checkmate/stalemate"
```
---
## Task 6: Implement `processMove` dispatch and `gameLoop` branches
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
- [ ] **Step 1: Replace the stub `processMove` else-branch and the three stub `gameLoop` cases**
Replace only the `else` branch inside `processMove` (keep everything else identical):
```scala
else
val (newBoard, captured) = board.withMove(from, to)
GameRules.gameStatus(newBoard, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
```
Replace the three stub `gameLoop` cases:
```scala
case MoveResult.MovedInCheck(newBoard, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, Color.White)
```
- [ ] **Step 2: Run all controller tests**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
```
Expected: all 24 tests PASS
- [ ] **Step 3: Run the full test suite**
```bash
./gradlew :modules:core:test
```
Expected: `BUILD SUCCESSFUL`, all tests pass
- [ ] **Step 4: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
git commit -m "feat: wire check/checkmate/stalemate into processMove and gameLoop"
```
---
## Task 7: Coverage check and final verification
- [ ] **Step 1: Run the full build with coverage**
```bash
./gradlew :modules:core:test
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 2: Check coverage gaps**
```bash
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
```
Review output. If any newly added method falls below the thresholds from `CLAUDE.md` (branch ≥ 90%, line ≥ 95%, method ≥ 90%), add targeted tests to close the gaps before considering the task done.
- [ ] **Step 3: Commit coverage fixes (if any)**
```bash
git add -p
git commit -m "test: improve coverage for GameRules and GameController"
```
File diff suppressed because it is too large Load Diff
@@ -1,85 +0,0 @@
# Design: Add ScalaTest + Replace JaCoCo with Scoverage
**Date:** 2026-03-22
**Status:** Approved
## Summary
Replace the current JUnit-only test setup and JaCoCo coverage with ScalaTest (via its JUnit 5 bridge) and Scoverage across both `modules/core` and `modules/api`.
## Motivation
- The CLAUDE.md working agreement prescribes `AnyFunSuite with Matchers with JUnitSuiteLike` as the unit test style, which requires ScalaTest.
- Scoverage is the standard Scala code coverage tool and understands Scala semantics; JaCoCo's JVM bytecode instrumentation is less accurate for Scala code.
## Scope
Two modules are affected: `modules/core` and `modules/api`. The root `build.gradle.kts` is updated for shared dependency versions only.
## Changes
### Root `build.gradle.kts`
Add to the `versions` map (dependency versions only — plugin version is hardcoded per module, see note below):
- `SCALATEST``3.2.19`
- `SCALATESTPLUS_JUNIT5``3.2.19.1`
> **Note on plugin versioning:** Gradle resolves the `plugins {}` block before `rootProject.extra` is available, so the Scoverage plugin version (`8.1`) must be declared inline in each module's `plugins {}` block. It cannot be read from the root versions map.
### `modules/core/build.gradle.kts` and `modules/api/build.gradle.kts`
Both modules require the same set of changes. Both currently have **two separate `tasks.test {}` blocks** that must be merged into one.
**Plugins block:**
- Remove `jacoco`
- Add `id("org.scoverage") version "8.1"`
**Dependencies block:**
- Remove `testImplementation(platform("org.junit:junit-bom:5.10.0"))`
- Remove `testImplementation("org.junit.jupiter:junit-jupiter")`
- Remove `testRuntimeOnly("org.junit.platform:junit-platform-launcher")`
- Add `testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")`
- Add `testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")`
**Task wiring — merge both `tasks.test {}` blocks into one and replace jacoco wiring:**
Both `modules/core` and `modules/api` currently have two `tasks.test {}` blocks. Delete both and replace with the following single merged block placed **after** the `dependencies {}` block (conventional position):
```kotlin
tasks.test {
useJUnitPlatform() // required — scalatestplus JUnit 5 bridge relies on this
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
```
> Note: `modules/api` does not currently have `useJUnitPlatform()` — it must be **added** (not just kept) in the merged block.
Remove the `jacocoTestReport` task block entirely from both modules.
**Task name confirmation:** The Scoverage Gradle plugin 8.1 registers `reportScoverage` as the HTML report task.
## Versions
| Artifact | Version | Notes |
|---|---|---|
| `org.scalatest:scalatest_3` | 3.2.19 | Core ScalaTest for Scala 3 |
| `org.scalatestplus:junit-5-11_3` | 3.2.19.1 | JUnit 5.11 runner bridge; `.1` = build 1 |
| Scoverage Gradle plugin | 8.1 | Hardcoded inline in `plugins {}` block |
## Testing the Change
After applying:
1. `./gradlew :modules:core:test` and `./gradlew :modules:api:test` must pass (green, even with zero test files).
2. `./gradlew :modules:core:reportScoverage` must produce a coverage report.
3. `./gradlew build` must be fully green.
## Files Modified
- `build.gradle.kts` (root) — add two version entries
- `modules/core/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, replace jacoco wiring
- `modules/api/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, add `useJUnitPlatform()`, replace jacoco wiring
No new source files are created.
@@ -1,169 +0,0 @@
# Chess Check / Checkmate / Stalemate — Design Spec
**Date:** 2026-03-23
**Status:** Approved
---
## Scope
Implement check detection, checkmate (win condition), and stalemate (draw) on top of the existing normal-move rules. En passant, castling, and pawn promotion are **out of scope** for this iteration.
---
## Architecture
### New: `GameRules` object
**File:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
Owns all check-aware game logic. `MoveValidator` retains its documented geometric-only contract ("ignoring check/pin").
```
GameRules
isInCheck(board, color): Boolean
legalMoves(board, color): Set[(Square, Square)]
gameStatus(board, color): PositionStatus
```
#### `isInCheck(board, color)`
Finds the king square for `color` by scanning `board.pieces` for a `Piece(color, PieceType.King)`. If no king is found (constructed/test boards), returns `false`.
Then checks whether any enemy piece's `MoveValidator.legalTargets` contains that square. This works correctly for all piece types, including the king: `kingTargets` returns the squares the king can move to, which are identical to the squares the king attacks, so using `legalTargets` for attack detection is correct by design.
Returns `true` if the king square is covered by at least one enemy piece.
#### `legalMoves(board, color)`
1. Filter `board.pieces` to entries where `piece.color == color`.
2. For each such `(from, piece)`, call `MoveValidator.legalTargets(board, from)` to get geometric candidates.
3. For each candidate `to`, apply `board.withMove(from, to)` to get `newBoard`.
4. Keep only moves where `isInCheck(newBoard, color)` is `false` (i.e., the move does not leave own king in check).
5. Return the full set of `(from, to)` pairs that survive this filter.
#### `gameStatus(board, color)`
Returns a `PositionStatus` enum value based on `legalMoves(board, color)` and `isInCheck(board, color)`:
- `Mated``legalMoves` is empty **and** king is in check → the side to move has been checkmated
- `Drawn``legalMoves` is empty **and** king is **not** in check → stalemate (draw)
- `InCheck``legalMoves` is non-empty **and** king is in check → game continues under check
- `Normal` — otherwise
#### Local `PositionStatus` enum
Defined in `GameRules.scala`. Names are intentionally distinct from `MoveResult` variants to avoid unqualified-name collisions in `GameController.scala`:
```scala
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
```
---
### Modified: `MoveResult` (in `GameController.scala`)
Three new variants; existing variants are unchanged:
| Variant | When used |
|---|---|
| `MovedInCheck(newBoard, captured, newTurn)` | Move was legal; opponent is now in check but has legal replies |
| `Checkmate(winner: Color)` | Move was legal; opponent is `Mated``winner` is the side that just moved |
| `Stalemate` | Move was legal; opponent is `Drawn` (no legal reply, not in check) |
`Moved` continues to be used when `gameStatus` returns `Normal`.
---
### Modified: `GameController.processMove`
After computing `(newBoard, captured)` from `board.withMove`:
1. Call `GameRules.gameStatus(newBoard, newTurn)`.
2. Map to the appropriate `MoveResult`:
```
PositionStatus.Normal → Moved(newBoard, captured, newTurn)
PositionStatus.InCheck → MovedInCheck(newBoard, captured, newTurn)
PositionStatus.Mated → Checkmate(turn) // turn = the side that just moved
PositionStatus.Drawn → Stalemate
```
---
### Modified: `GameController.gameLoop`
**New terminal branches** (both print a message then restart):
- `Checkmate(winner)` → print `"Checkmate! {winner.label} wins."`, then recurse with `(Board.initial, Color.White)`
- `Stalemate` → print `"Stalemate! The game is a draw."`, then recurse with `(Board.initial, Color.White)`
**New non-terminal branch:**
- `MovedInCheck(newBoard, captured, newTurn)` → print the same optional capture message as `Moved` (when `captured.isDefined`), then print `"{newTurn.label} is in check!"`, then recurse with `(newBoard, newTurn)`
**Restart vs. exit:** Checkmate and stalemate restart the game automatically (no prompt). This is intentionally asymmetric with `Quit`, which exits. `Quit` is an explicit user request to stop; Checkmate/Stalemate are natural game endings that should roll into a new game.
---
## Test Strategy
All tests are unit tests extending `AnyFunSuite with Matchers with JUnitSuiteLike`.
### `GameRulesTest` — new file
| Scenario | Method | Expected |
|---|---|---|
| King attacked by enemy rook on same rank | `isInCheck` | `true` |
| King not attacked (only own pieces nearby) | `isInCheck` | `false` |
| No king on board (constructed board) | `isInCheck` | `false` |
| Move that exposes own king to rook is excluded | `legalMoves` | does not contain that move |
| Move that blocks check is included | `legalMoves` | contains the blocking move |
| Checkmate: White Qh8, Ka6; Black Ka8 — Black king is in check (Qh8 along rank 8), cannot escape to a7 (Ka6), b7 (Ka6), or b8 (Qh8) | `gameStatus` | `Mated` |
| Stalemate: White Qb6, Kc6; Black Ka8 — Black king has no legal moves (a7/b7/b8 all controlled by Qb6), not in check | `gameStatus` | `Drawn` |
| King in check with at least one escape square | `gameStatus` | `InCheck` |
| Normal midgame position, not in check, has moves | `gameStatus` | `Normal` |
### `GameControllerTest` additions — new `processMove` cases
| Scenario | Expected `MoveResult` |
|---|---|
| Move leaves opponent in check (has escape) | `MovedInCheck` |
| Move results in checkmate | `Checkmate(winner)` where winner is the side that moved |
| Move results in stalemate | `Stalemate` |
### `GameControllerTest` additions — new `gameLoop` cases
| Scenario | Expected output / behavior |
|---|---|
| `gameLoop` receives `Checkmate(White)` | Prints "Checkmate! White wins." and continues (new game) |
| `gameLoop` receives `Stalemate` | Prints "Stalemate! The game is a draw." and continues (new game) |
| `gameLoop` receives `MovedInCheck` with a capture | Prints capture message AND check message |
| `gameLoop` receives `MovedInCheck` without a capture | Prints check message only |
---
## Development Workflow (TDD)
1. Create `GameRules.scala` with empty/stub method bodies that compile but return placeholder values (`false`, `Set.empty`, `PositionStatus.Normal`).
2. Write all `GameRulesTest` tests — they should **fail**.
3. Implement `GameRules` logic until `GameRulesTest` is green.
4. Add new `MoveResult` variants to `GameController.scala`; update `processMove` to call `GameRules.gameStatus` (stub the match arms initially).
5. Write new `GameControllerTest` cases — they should **fail**.
6. Implement `processMove` match arms and `gameLoop` new branches until all tests pass.
7. Run `./gradlew :modules:core:test` — full green build required.
---
## Files Changed
| File | Change |
|---|---|
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | New |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | New |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `MoveResult` variants; update `processMove` and `gameLoop` |
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add new test cases |
No changes to `modules/api` or `MoveValidator`.
@@ -1,255 +0,0 @@
# Castling Implementation Design
**Date:** 2026-03-24
**Status:** Approved (rev 2)
**Branch:** castling
---
## Context
The NowChessSystems chess engine currently operates on a raw `Board` (opaque `Map[Square, Piece]`) paired with a `Color` for turn tracking. Castling requires tracking whether the king and rooks have previously moved — state that does not exist in the current engine layer. The `CastlingRights` and `MoveType.Castle*` types are already defined in the `api` module but are not wired into the engine.
---
## Approach: `GameContext` Wrapper (Option B)
Introduce a thin `GameContext` wrapper in `modules/core` that bundles `Board` with castling rights for both sides. This is the single seam through which the engine learns about castling availability without pulling in the full FEN-structured `GameState` type.
---
## Section 1 — `GameContext` Type
**Location:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala`
```scala
case class GameContext(
board: Board,
whiteCastling: CastlingRights,
blackCastling: CastlingRights
):
def castlingFor(color: Color): CastlingRights =
if color == Color.White then whiteCastling else blackCastling
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
if color == Color.White then copy(whiteCastling = rights)
else copy(blackCastling = rights)
```
`GameContext.initial` wraps `Board.initial` with `CastlingRights.Both` for both sides.
`gameLoop` and `processMove` replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`. All `MoveResult` variants that previously carried `newBoard: Board` now carry `newCtx: GameContext`. The `gameLoop` render call becomes `Renderer.render(ctx.board)`, and all `gameLoop` pattern match arms that destructure `MoveResult.Moved(newBoard, ...)` or `MoveResult.MovedInCheck(newBoard, ...)` must be updated to destructure `newCtx` and pass it to the recursive `gameLoop` call.
---
## Section 2 — `CastleSide` and Board Extension for Castle Moves
### `CastleSide` enum
`CastleSide` is a two-value engine-internal enum defined in `core` (not in `api`). It is co-located in `GameContext.scala` — there is no separate `CastleSide.scala` file.
```scala
enum CastleSide:
case Kingside, Queenside
```
### `withCastle` extension
`Board.withMove(from, to)` moves a single piece. Castling moves two pieces atomically. To avoid a circular dependency (`api` must not import from `core`), `withCastle` is **not** added to `Board` in the `api` module. Instead it is defined as an extension method in `core`, co-located with `GameContext`:
```scala
// inside GameContext.scala or a BoardCastleOps.scala in core
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board = ...
```
Post-castle square assignments:
- **Kingside White:** King e1→g1, Rook h1→f1
- **Queenside White:** King e1→c1, Rook a1→d1
- **Kingside Black:** King e8→g8, Rook h8→f8
- **Queenside Black:** King e8→c8, Rook a8→d8
---
## Section 3 — `MoveValidator` Castling Logic
### Signature change
`legalTargets` and `isLegal` are extended to accept `GameContext` when the caller has full game context. To avoid breaking `GameRules.isInCheck` (which uses `legalTargets` with only a `Board` for attacked-square detection), the implementation retains a **board-only private helper** for sliding/jump/normal king targets, and a **public overload** that additionally unions castling targets when a `GameContext` is provided:
```scala
// board-only (used internally by isInCheck)
def legalTargets(board: Board, from: Square): Set[Square]
// context-aware (used by legalMoves and processMove)
def legalTargets(ctx: GameContext, from: Square): Set[Square]
```
The `GameContext` overload delegates to the `Board` overload for all piece types except King, where it additionally unions `castlingTargets(ctx, color)`.
`isLegal` is likewise overloaded:
```scala
// board-only (retained for callers that have no castling context)
def isLegal(board: Board, from: Square, to: Square): Boolean
// context-aware (used by processMove)
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean
```
The context-aware `isLegal(ctx, from, to)` calls `legalTargets(ctx, from).contains(to)` — using the context-aware overload — so castling targets are included in the legality check.
### `castlingTargets` method
```scala
def castlingTargets(ctx: GameContext, color: Color): Set[Square]
```
For each side (kingside, queenside), checks all six conditions in order (failing fast):
1. `CastlingRights` flag is `true` for that side (`ctx.castlingFor(color)`)
2. King is on its home square (e1 for White, e8 for Black)
3. Relevant rook is on its home square (h-file for kingside, a-file for queenside)
4. All squares between king and rook are empty
5. King is **not currently in check** — calls `GameRules.isInCheck(ctx.board, color)` using the board-only path (no castling recursion)
6. Each square the king **passes through and lands on** is not attacked — checks that no enemy `legalTargets(board, enemySq)` (board-only) covers those squares
Transit and landing squares:
- **Kingside:** f-file, g-file (White: f1, g1; Black: f8, g8)
- **Queenside:** d-file, c-file (White: d1, c1; Black: d8, c8). Note: b1/b8 must be empty (condition 4) but the king does not pass through them, so they are not checked for attacks.
---
## Section 4 — `GameRules` Changes
`GameRules.legalMoves` must accept `GameContext` (not just `Board`) so it can enumerate castling moves as part of the legal move set. This is required for correct stalemate and checkmate detection — a position where the only legal move is to castle must not be evaluated as stalemate.
```scala
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)]
```
Internally it calls `MoveValidator.legalTargets(ctx, from)` (the context-aware overload) for all pieces of `color`, then filters to moves that do not leave the king in check.
`isInCheck` retains its `(board: Board, color: Color)` signature — it does not need castling context.
`gameStatus` is updated to accept `GameContext`:
```scala
def gameStatus(ctx: GameContext, color: Color): PositionStatus
```
---
## Section 5 — `GameController` Changes
### Move detection and execution
`processMove` identifies a castling move by the king occupying its home square and moving exactly two files laterally:
- White: e1→g1 (kingside) or e1→c1 (queenside)
- Black: e8→g8 (kingside) or e8→c8 (queenside)
Legality is confirmed via `MoveValidator.isLegal(ctx, from, to)` (the context-aware overload, which includes castling targets). When a castling move is legal and executed:
1. Call `ctx.board.withCastle(color, side)` to move both pieces atomically.
2. Revoke **both** castling rights for the moving color in the new `GameContext`.
### Rights revocation rules (applied on every move)
After every move `(from, to)` is applied, revoke rights based on both the **source square** and the **destination square**. Both tables are checked independently and all triggered revocations are applied.
**Source square → revocation** (piece leaves its home square):
| Source square | Rights revoked |
|---------------|---------------|
| `e1` | Both White castling rights |
| `e8` | Both Black castling rights |
| `a1` | White queenside |
| `h1` | White kingside |
| `a8` | Black queenside |
| `h8` | Black kingside |
**Destination square → revocation** (a piece — including an enemy piece — arrives on a rook home square, meaning a capture removed the rook):
| Destination square | Rights revoked |
|--------------------|---------------|
| `a1` | White queenside |
| `h1` | White kingside |
| `a8` | Black queenside |
| `h8` | Black kingside |
This covers the following cases:
- **King normal move** — source square e1/e8 fires; both rights revoked.
- **King castle move** — the castle-specific step 2 revokes both rights for the moving color. Additionally, the source-square table fires (king departs e1/e8), revoking the same rights a second time. This double-revocation is idempotent and harmless. The king's destination (g1/c1/g8/c8) does not appear in the destination table, so no extra revocation fires there.
- **Own rook move** — source square a1/h1/a8/h8 fires.
- **Enemy capture on a rook home square** — destination square a1/h1/a8/h8 fires, revoking the side that lost the rook.
`processMove` also calls `GameRules.gameStatus(newCtx, turn.opposite)` — note this call passes the full `GameContext`, not just a `Board`, because `gameStatus` now accepts `GameContext`.
The revocation is applied to the `GameContext` that results from the move, before it is returned in `MoveResult`.
### Signatures
```scala
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult
def gameLoop(ctx: GameContext, turn: Color): Unit
```
`MoveResult.Moved` and `MoveResult.MovedInCheck` carry `newCtx: GameContext` instead of `newBoard: Board`. All `gameLoop` pattern match arms are updated to use `newCtx`. The render call uses `newCtx.board`.
On checkmate/stalemate reset, `GameContext.initial` is used.
---
## Section 6 — Move Notation
The player types standard coordinate notation:
- `e1g1` → White kingside castle
- `e1c1` → White queenside castle
- `e8g8` → Black kingside castle
- `e8c8` → Black queenside castle
No parser changes required. The controller identifies castling by the king moving 2 files from the home square.
---
## Section 7 — Testing
### `MoveValidatorTest`
- Castling target (g1) is returned when all kingside conditions are met (White)
- Castling target (c1) is returned when all queenside conditions are met (White)
- Castling targets returned for Black kingside (g8) and queenside (c8)
- Castling blocked when transit square is occupied (piece between king and rook)
- Castling blocked when king is in check (condition 5)
- Castling blocked when **transit square** is attacked (e.g., f1 attacked for White kingside)
- Castling blocked when **landing square** is attacked (e.g., g1 attacked for White kingside)
- Castling blocked when `kingSide = false` in `CastlingRights`
- Castling blocked when `queenSide = false` in `CastlingRights`
- Castling blocked when relevant rook is not on its home square
### `GameControllerTest`
- `processMove` with `e1g1` returns `Moved` with king on g1, rook on f1, and both White castling rights revoked in `newCtx`
- `processMove` with `e1c1` returns `Moved` with king on c1, rook on d1, and both White castling rights revoked in `newCtx`
- `processMove` castle attempt after king has moved returns `IllegalMove`
- `processMove` castle attempt after rook has moved returns `IllegalMove`
- Normal rook move from h1 revokes White kingside right in the returned `newCtx`
- Normal king move from e1 revokes both White rights in the returned `newCtx`
- Enemy capture on h1 (e.g., Black rook captures White rook on h1) revokes White kingside right in the returned `newCtx`
### `GameRulesTest`
- `legalMoves` includes castling destinations when available
- `legalMoves` excludes castling when king is in check
- `gameStatus` returns `Normal` (not `Drawn`) when the only legal move available is to castle — verifying that the `GameContext` signature change correctly prevents a false stalemate
---
## Files to Create / Modify
| Action | File |
|--------|------|
| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` — includes `CastleSide` enum and `withCastle` Board extension |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` — add `castlingTargets`, board-only + context-aware `legalTargets`/`isLegal` overloads |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` — update `legalMoves` and `gameStatus` to accept `GameContext` |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` — use `GameContext`; castling detection, execution, rights revocation |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` — use `GameContext.initial` |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` — new castling tests |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` — update signatures + new castling tests |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` — update signatures + new castling tests |
-23
View File
@@ -1,23 +0,0 @@
# Unresolved Issues
## [2026-03-24] JUnitSuiteLike mixin not available for ScalaTest 3.2.19 with Scala 3
**Requirement / Bug:**
CLAUDE.md prescribes that all unit tests should extend `AnyFunSuite with Matchers with JUnitSuiteLike`. However, the `JUnitSuiteLike` trait cannot be resolved in the current build configuration.
**Root Cause (if known):**
- ScalaTest 3.2.19 for Scala 3 does not provide `JUnitSuiteLike` in any public package.
- The `co.helmethair:scalatest-junit-runner:0.1.11` dependency does not expose this trait.
- There is no `org.scalatest:scalatest-junit_3` artifact available for version 3.2.19.
- The trait may have been removed or changed in the ScalaTest 3.x → Scala 3 migration.
**Attempted Fixes:**
1. Tried importing from `org.scalatest.junit.JUnitSuiteLike` — not found
2. Tried importing from `org.scalatestplus.junit.JUnitSuiteLike` — not found
3. Tried importing from `co.helmethair.scalatest.junit.JUnitSuiteLike` — not found
4. Attempted to add `org.scalatest:scalatest-junit_3:3.2.19` dependency — artifact does not exist in Maven Central
**Suggested Next Step:**
1. Either find the correct ScalaTest artifact/import for Scala 3 JUnit integration, or
2. Update CLAUDE.md to reflect the actual constraint that unit tests should extend `AnyFunSuite with Matchers` (without `JUnitSuiteLike`), or
3. Investigate whether a different test runner or configuration is needed to achieve JUnit integration with ScalaTest 3 in Scala 3
Binary file not shown.
File diff suppressed because it is too large Load Diff
-594
View File
@@ -1,594 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://schema.gradle.org/dependency-verification https://schema.gradle.org/dependency-verification/dependency-verification-1.3.xsd">
<configuration>
<verify-metadata>true</verify-metadata>
<verify-signatures>true</verify-signatures>
<ignored-keys>
<ignored-key id="01D9B9C7952C4A1F" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="1B6E3BDDD4415872" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="29967E804D85663F" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="41CB98F33B06146E" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="60BE32B1404779E5" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="63BB5E152DFF95F0" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="7090AF43A5E10D0B" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="7DC3076FE22D4F88" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="84E913A8E3A748C0" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="85911F425EC61B51" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="9AEE152CDCCEBFCB" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="9DAADC1C9FCC82D0" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="BCF4173966770193" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="BFFA420097F49C8A" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="C03EF1D7D692BCFF" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="C2952540150670BE" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="D364ABAA39A47320" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="DCD5181297A43D24" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="ED2378CD09A08CDE" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="F42E87F9665015C9" reason="Key couldn't be downloaded from any key server"/>
</ignored-keys>
<trusted-keys>
<trusted-key id="0181A4828FA27B6BE6F1F5A68611CD28F472E006" group="org.jline"/>
<trusted-key id="073F7A9345756F3B40CDB99E6C70A3B7599C5736" group="org.jline"/>
<trusted-key id="120D6F34E627ED3A772EBBFE55C7E5E701832382" group="org.yaml" name="snakeyaml" version="2.0"/>
<trusted-key id="1FA868A348719E88B6D0DE24C03EF1D7D692BCFF" group="org.scala-lang"/>
<trusted-key id="23D4275AC69688098AF3997BA6C4333204634502" group="org.scoverage"/>
<trusted-key id="28118C070CB22A0175A2E8D43D12CA2AC19F3181" group="^com[.]fasterxml($|([.].*))" regex="true"/>
<trusted-key id="2A5E8B338438CAC7033F9D8FB8A045C0A6EC398E" group="org.scala-lang"/>
<trusted-key id="2BE67AC00D699E04E840B7FE29967E804D85663F" group="com.eed3si9n"/>
<trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
<trusting group="commons-codec"/>
<trusting group="commons-io"/>
<trusting group="org.apache.commons"/>
</trusted-key>
<trusted-key id="2E3A1AFFE42B5F53AF19F780BCF4173966770193" group="org.jetbrains" name="annotations" version="15.0"/>
<trusted-key id="3F3633D644494880818AD64601D9B9C7952C4A1F" group="org.scala-lang.modules" name="scala-asm" version="9.6.0-scala-1"/>
<trusted-key id="4008F9DFF7DBC968F35F9E712642156411CCE8B3" group="com.vladsch.flexmark"/>
<trusted-key id="50B670A8DE1F3CD89583895241CB98F33B06146E">
<trusting group="nl.big-o"/>
<trusting group="ua.co.k"/>
</trusted-key>
<trusted-key id="58DF461CAAC5F4E5FB2BE32CBFFA420097F49C8A" group="com.lmax" name="disruptor" version="3.4.2"/>
<trusted-key id="600D21219963F228200A72375365A8A69292AF1A" group="org.scala-lang.modules" name="scala-xml_3" version="2.1.0"/>
<trusted-key id="624B96CEB9896889C97B258F7DC3076FE22D4F88" group="org.nibor.autolink" name="autolink" version="0.6.0"/>
<trusted-key id="6766B3EC6ECC2FFD5F899F7C63BB5E152DFF95F0">
<trusting group="org.scalactic"/>
<trusting group="org.scalatest"/>
</trusted-key>
<trusted-key id="6E601AC418304FD7DCB373CA3D30EF3598565988" group="org.scoverage"/>
<trusted-key id="7B121B76A7ED6CE6E60AD51784E913A8E3A748C0" group="org.bouncycastle" name="bcprov-jdk18on" version="1.83"/>
<trusted-key id="7CEAC05AFEB808AD75C2097D60BE32B1404779E5" group="co.helmethair" name="scalatest-junit-runner" version="0.1.11"/>
<trusted-key id="84789D24DF77A32433CE1F079EB80E92EB2135B1" group="org.apache" name="apache" version="35"/>
<trusted-key id="8A10792983023D5D14C93B488D7F1BEC1E2ECAE7">
<trusting group="^com[.]fasterxml($|([.].*))" regex="true"/>
</trusted-key>
<trusted-key id="9D0A56AAA0D60E0C0C7DCCC0B4C70893B62BABE8" group="^org[.]apache[.]logging($|([.].*))" regex="true"/>
<trusted-key id="A7D8BE3D575D6C5040E889331B6E3BDDD4415872" group="net.openhft"/>
<trusted-key id="ACF39CCDED38E2C6F0898BF28F7F6C0451967B84" group="org.scala-lang" name="scala3-library_3"/>
<trusted-key id="B9611F878B0CE6D92145157FA6ED77BB4C0EAE26" group="org.scoverage" name="gradle-scoverage" version="8.1"/>
<trusted-key id="C44A68FD10FF456C91E2757D18088D07854014B3" group="org.scala-lang.modules" name="scala-parallel-collections_2.13" version="0.2.0"/>
<trusted-key id="C7BE5BCC9FEC15518CFDA882B0F3710FA64900E7" group="com.google.code.gson"/>
<trusted-key id="CD5464315F0B98C77E6E8ECD9DAADC1C9FCC82D0" group="commons-io" name="commons-io" version="2.6"/>
<trusted-key id="D1436C0DBACEA48702AF97C363F1DD7753B8B315" group="^org[.]sonarsource($|([.].*))" regex="true"/>
<trusted-key id="D477D51812E692011DB11E66A6EA2E2BF22E0543" group="io.github.java-diff-utils"/>
<trusted-key id="D54A395B5CF3F86EB45F6E426B1B008864323B92" group="org.antlr"/>
<trusted-key id="DBE61B6BA51DFCAEDED256477090AF43A5E10D0B" group="org.scala-lang.modules" name="scala-parser-combinators_2.13" version="1.1.2"/>
<trusted-key id="DC98224C6421A7A5BB87F346ED2378CD09A08CDE" group="org.fusesource.jansi" name="jansi" version="2.4.0"/>
<trusted-key id="EA313384CA0EBA950EA017E937890E298D9A2BFA">
<trusting group="com.eed3si9n"/>
<trusting group="^org[.]scala-sbt($|([.].*))" regex="true"/>
</trusted-key>
<trusted-key id="EE2CFEB6A2AECF44C781C5C3DCD5181297A43D24" group="com.swoval" name="file-tree-views" version="2.1.12"/>
<trusted-key id="F3184BCD55F4D016E30D4C9BF42E87F9665015C9" group="org.jsoup" name="jsoup" version="1.17.2"/>
<trusted-key id="F3D9FF1EE50634CC57D1E380C2952540150670BE" group="org.scala-lang.modules"/>
<trusted-key id="FA7929F83AD44C4590F6CC6815C71C0A4E0B8EDD" group="net.java.dev.jna" name="jna" version="5.14.0"/>
</trusted-keys>
</configuration>
<components>
<component group="co.helmethair" name="scalatest-junit-runner" version="0.1.11">
<artifact name="scalatest-junit-runner-0.1.11.jar">
<sha256 value="d2528b296efc33c8aef2175ea7da9cb41252eddfe24b62a29b7ec7fbe5f664d7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-junit-runner-0.1.11.module">
<sha256 value="673aedd69783976df2c0a15c55f6bf12870a3edf05b3e13921752fa81c02195b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.eed3si9n" name="shaded-scalajson_2.13" version="1.0.0-M4">
<artifact name="shaded-scalajson_2.13-1.0.0-M4.jar">
<sha256 value="7b6b6d85727bd8abab940b559de8e32aa5081add29f7531c855bb0761ae8de67" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="shaded-scalajson_2.13-1.0.0-M4.pom">
<sha256 value="256d22f6d5634dc4be9358c6ab692d81e511d468c3e7836db2833a3ed88a84f8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.eed3si9n" name="sjson-new-core_2.13" version="0.9.0">
<artifact name="sjson-new-core_2.13-0.9.0.pom">
<sha256 value="185df6fb71d7d900e960277896adb790b36ba65d299ff29f27060ab3d65323ee" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.41.0">
<artifact name="error_prone_annotations-2.41.0.jar">
<sha256 value="a56e782b5b50811ac204073a355a21d915a2107fce13ec711331ad036f660fcc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="error_prone_annotations-2.41.0.pom">
<sha256 value="a151df1e2e0b48618d8b06a180748a29b3abb39b1b2396f6a1c879a727488c6e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_parent" version="2.41.0">
<artifact name="error_prone_parent-2.41.0.pom">
<sha256 value="c538388d760a5c1c98dcf06f6ed3cfe5f11a651827db5cbd2ed8288c795cad42" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.lmax" name="disruptor" version="3.4.2">
<artifact name="disruptor-3.4.2.jar">
<sha256 value="f412ecbb235c2460b45e63584109723dea8d94b819c78c9bfc38f50cba8546c0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="disruptor-3.4.2.pom">
<sha256 value="7311e5e261ca62f259b2d14e6d6f1ce375a64718731a730fd7cec0228d50f5da" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.swoval" name="file-tree-views" version="2.1.12">
<artifact name="file-tree-views-2.1.12.jar">
<sha256 value="fd7373889b7a92cf3e97db36c920ba272aec158a9387b3259fca9f2dfaeda914" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="file-tree-views-2.1.12.pom">
<sha256 value="edd270dc776d1d85dd300e415cff9e0609757d7afeb223a6b187bad5b0abe746" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="commons-io" name="commons-io" version="2.6">
<artifact name="commons-io-2.6.jar">
<sha256 value="f877d304660ac2a142f3865badfc971dec7ed73c747c7f8d5d2f5139ca736513" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="commons-io-2.6.pom">
<sha256 value="0c23863893a2291f5a7afdbd8d15923b3948afd87e563fa341cdcf6eae338a60" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="io.github.hakky54" name="ayza" version="10.0.2">
<artifact name="ayza-10.0.2.jar">
<sha256 value="9aa06304993aff5677dba769c677e578364f5793cbaf1569b2b5f39b71119a7b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="ayza-10.0.2.pom">
<sha256 value="441136232173d5eb533feffc96daa0353f8f0cb695033ca9631ecc8e68ddd335" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="io.github.hakky54" name="ayza-bom" version="10.0.2">
<artifact name="ayza-bom-10.0.2.pom">
<sha256 value="817c3e174101e3d843bae65a13130dad8e1b93396e1355b5ad676941045d8dd2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="io.github.hakky54" name="ayza-parent" version="10.0.2">
<artifact name="ayza-parent-10.0.2.pom">
<sha256 value="f021993e03a484a8bd1d06dc6c7f2a6007a92ac1102bd5ddac9be13059df1cdf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="io.github.hakky54" name="sude" version="2.0.2">
<artifact name="sude-2.0.2.jar">
<sha256 value="f88e3d031dbbd2fea1b98481df0646a25b8d63d92796d6f30f907d5187595b39" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="sude-2.0.2.pom">
<sha256 value="c38a30206ea8b95811805348ecb79cb3d3517df835ba10c660d395ec76181441" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="net.openhft" name="java-parent-pom" version="1.1.28">
<artifact name="java-parent-pom-1.1.28.pom">
<sha256 value="02199c347a9d2b9e6f5dbf8e13d4c34e8febfab90c9b81fba13a16e8208809bf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="net.openhft" name="root-parent-pom" version="1.2.12">
<artifact name="root-parent-pom-1.2.12.pom">
<sha256 value="31802b4c86422d91ac5337dad705113535ca986f7cd7bc239701b9f9df967ccf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="net.openhft" name="zero-allocation-hashing" version="0.16">
<artifact name="zero-allocation-hashing-0.16.jar">
<sha256 value="3bc39c640cc8314575de4ebcb1a0bca540516d3c60d49f8de7d638b09868553d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="zero-allocation-hashing-0.16.pom">
<sha256 value="0949496963193655f81afb9dba28743444dd2a23a6f4933638a6991cfd728fc6" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="nl.big-o" name="liqp" version="0.8.2">
<artifact name="liqp-0.8.2.jar">
<sha256 value="a948c26558e31fb445b5f1a2561c4518136a5d74046e4ab12bfb6010f6b0cc5b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="liqp-0.8.2.pom">
<sha256 value="846da39098c20be8631523c62928e4dd2b4cf7686d428678aae50060eef009b8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.apache" name="apache" version="18">
<artifact name="apache-18.pom">
<pgp value="190D5A957FF22273E601F7A7C92C5FEC70161C62"/>
</artifact>
</component>
<component group="org.apache" name="apache" version="23">
<artifact name="apache-23.pom">
<pgp value="FA77DCFEF2EE6EB2DEBEDD2C012579464D01C06A"/>
</artifact>
</component>
<component group="org.apache" name="apache" version="35">
<artifact name="apache-35.pom">
<sha256 value="ea297dcd114136e8b8e8b630230d52a76c2fc69f6c5db25d672b1857000728b8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.apache.commons" name="commons-parent" version="42">
<artifact name="commons-parent-42.pom">
<pgp value="CE8075A251547BEE249BC151A2115AE15F6B8B72"/>
</artifact>
</component>
<component group="org.apache.commons" name="commons-parent" version="85">
<artifact name="commons-parent-85.pom">
<sha256 value="d189ff2c0027e96bb65d31e6f227ed2af966169b36af1e973dd5ba08926dc7b5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.apiguardian" name="apiguardian-api" version="1.1.2">
<artifact name="apiguardian-api-1.1.2.jar">
<sha256 value="b509448ac506d607319f182537f0b35d71007582ec741832a1f111e5b5b70b38" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="apiguardian-api-1.1.2.module">
<sha256 value="e08028131375b357d1d28734e9a4fb4216da84b240641cb3ef7e7c7d628223fc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.bouncycastle" name="bcprov-jdk18on" version="1.83">
<artifact name="bcprov-jdk18on-1.83.jar">
<sha256 value="82cf3a2af766c3bc874f6d36b9f20a8b99a8f09762dc776e8a227a45d8daaafb" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="bcprov-jdk18on-1.83.pom">
<sha256 value="c87cf06f5aac4656380f1d441b2459fbe066ec812b29469bd0b3fcb8bb20574a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.fusesource" name="fusesource-pom" version="1.12">
<artifact name="fusesource-pom-1.12.pom">
<sha256 value="c40d960daadcef7b01c1b1c6657afbac4fffb5e53168f8fcb0b28b84e6fdcca1" origin="Generated by Gradle" reason="Artifact is not signed"/>
</artifact>
</component>
<component group="org.fusesource.jansi" name="jansi" version="2.4.0">
<artifact name="jansi-2.4.0.jar">
<sha256 value="6cd91991323dd7b2fb28ca93d7ac12af5a86a2f53279e2b35827b30313fd0b9f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="jansi-2.4.0.pom">
<sha256 value="ac40a9f2d0c1ee631fc3b08ef8e2f0bd14ba22011ca76ff1bcf65fb569eadf35" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.jetbrains" name="annotations" version="15.0">
<artifact name="annotations-15.0.jar">
<sha256 value="d74599cef2b363fdb3cdd3198515aca090e3ea3e98b2ba473c6e46f114dab272" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="annotations-15.0.pom">
<sha256 value="6726678ac07b481b5e35d3aeefce526b95fd18ede33d0d85cb1c688bcdf0e840" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.jsoup" name="jsoup" version="1.17.2">
<artifact name="jsoup-1.17.2.jar">
<sha256 value="f60b33b38e9d7ac93eaaa68a6c70f706bb99036494b2e2add2bfee11d09ac6f5" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="jsoup-1.17.2.pom">
<sha256 value="7a349d217790c3730be308ced1ea9ee32c4e74f72058e83c2b60e5a28954dd0d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.9.2">
<artifact name="junit-bom-5.9.2.module">
<sha256 value="ab137ba5a8e32c9b066bf9126a1c76dd5614b724ba5c0b02549772b5e9f4cf1f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-bom-5.9.2.pom">
<sha256 value="2ed07d65845131f5336a86476c9a4056b59d0b58b9815ab3679bb0f36f35f705" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.13.1">
<artifact name="junit-bom-5.13.1.module">
<sha256 value="33c07ab9724790a6e5859ba07d69117ac530439724545a81c4179e3272c75de8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-bom-5.13.1.pom">
<sha256 value="fa68451ea830572ed43ffe51d75b6a05f7a5e665a602a51f49d6be02063a65f3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.13.4">
<artifact name="junit-bom-5.13.4.module">
<sha256 value="e959288fde1b1b050d9bc082fc786789128da5d2853091468fca504104bdf400" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-bom-5.13.4.pom">
<sha256 value="d7a08a99b2502f0bb68cd4e1f984f0bf69324aaa208bd0f73366c03fc3548a42" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.14.1">
<artifact name="junit-bom-5.14.1.module">
<sha256 value="278acb11ccc9998694224386f96fb4941a22edb42cb446c92e0f1f33014b6b48" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-bom-5.14.1.pom">
<sha256 value="01b01dfa366550b40ac5760548a7d728b6109d17c451e83864d1e5e0ce862c94" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter" version="5.13.4">
<artifact name="junit-jupiter-5.13.4.jar">
<sha256 value="b960f79217dd01c863031b678f07df4730bbf1eac650c74ad6b0c61faad78379" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-jupiter-5.13.4.module">
<sha256 value="46946227c2967d1659e955f53d34ec8731811d4af401c2ac7d646f793c78e1f9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-api" version="5.13.4">
<artifact name="junit-jupiter-api-5.13.4.jar">
<sha256 value="d1bb81abfd9e03418306b4e6a3390c8db52c58372e749c2980ac29f0c08278f1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-jupiter-api-5.13.4.module">
<sha256 value="fe464d37f5c810a805ff319198165cac33c2558e2261021d8f312a825a48671f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-engine" version="5.13.4">
<artifact name="junit-jupiter-engine-5.13.4.jar">
<sha256 value="027404a92fe618b72465792a257951495c503a7d5751e2791e0f51c87f67f5bc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-jupiter-engine-5.13.4.module">
<sha256 value="ceeee6d0034a738135bd9f3820cfe089c6569163c623ba8e3e9b44f7208fd21a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-params" version="5.13.4">
<artifact name="junit-jupiter-params-5.13.4.jar">
<sha256 value="3a8c6365716dbb698c0d49a05456c1e1ad05c406613c550f9dd50037872efc41" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-jupiter-params-5.13.4.module">
<sha256 value="fc366fbe607999afc8cf02b9dca95d1e02a06b0ce872a45605a9d968c246f4b4" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-commons" version="1.13.4">
<artifact name="junit-platform-commons-1.13.4.jar">
<sha256 value="1c25ca641ebaae44ff3ad21ca1b2ef68d0dd84bfeb07c4805ba7840899b77408" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-platform-commons-1.13.4.module">
<sha256 value="1a7a2de7c798995fb97b244d6ef9e99c3a5799b57a0fbacd68496ba7ee8159d7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-engine" version="1.13.4">
<artifact name="junit-platform-engine-1.13.4.jar">
<sha256 value="390c5f77b84283a64b644f88251b397e0b0debb80bdcc50f899881aecff43a5a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-platform-engine-1.13.4.module">
<sha256 value="35e4fd68ebf314e62660148936436b39ae105d355beaac1aa54eb91c2066ca77" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-launcher" version="1.13.4">
<artifact name="junit-platform-launcher-1.13.4.jar">
<sha256 value="0b0beaeb6880a31149641d2d848b863712885469670c12099586d7f798522564" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="junit-platform-launcher-1.13.4.module">
<sha256 value="115f77455740e0c3c5398bcdd841c8aa699c2d8002b1100f2ae7a643d9405928" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.nibor.autolink" name="autolink" version="0.6.0">
<artifact name="autolink-0.6.0.jar">
<sha256 value="a80be030f6386f18111cad9161c0b6983157352a1b59a59e6002172f0d321c04" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="autolink-0.6.0.pom">
<sha256 value="916755647a34ccb367e11834d28380198c834adfcf660e0d983e375b8f5c28f2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.opentest4j" name="opentest4j" version="1.3.0">
<artifact name="opentest4j-1.3.0.jar">
<sha256 value="48e2df636cab6563ced64dcdff8abb2355627cb236ef0bf37598682ddf742f1b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="opentest4j-1.3.0.module">
<sha256 value="48bf1d6c8b5dc94f74652bd17900f654deb714350248cf5e8fca27b9090c8e0d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang" name="scala-compiler" version="2.13.15">
<artifact name="scala-compiler-2.13.15.jar">
<sha256 value="4c200cd193c082bec14a2a2dffe6a1ba5f8130b1b27c79ee54c936dfcafc8ed9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-compiler-2.13.15.pom">
<sha256 value="6ae13081e950a55545e53e7e6f9bf6754ed0ec17af331772ae8fae4fb406f697" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang" name="scala-library" version="2.13.14">
<artifact name="scala-library-2.13.14.jar">
<sha256 value="43e0ca1583df1966eaf02f0fbddcfb3784b995dd06bfc907209347758ce4b7e3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-library-2.13.14.pom">
<sha256 value="cee86c6df5653aaf55403666902fcbb0aaaf400eb2cffb27f09ca5d75ec703bc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang" name="scala-library" version="2.13.15">
<artifact name="scala-library-2.13.15.jar">
<sha256 value="8e4dbc3becf70d59c787118f6ad06fab6790136a0699cd6412bc9da3d336944e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-library-2.13.15.pom">
<sha256 value="f81d6f32917a0e931daa6559a8500be1c62ff8c6c82db071dcdbebf60bbd4786" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang" name="scala-library" version="2.13.16">
<artifact name="scala-library-2.13.16.jar">
<sha256 value="1ebb2b6f9e4eb4022497c19b1e1e825019c08514f962aaac197145f88ed730f1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-library-2.13.16.pom">
<sha256 value="b25b72ba96eb30934868d86d307298d24d3ac154d362e7a4eeb37ba51ba86853" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang" name="scala-reflect" version="2.13.15">
<artifact name="scala-reflect-2.13.15.jar">
<sha256 value="78d0cc350e1ee42d87c6e11cf5b0dc7bf0b70829c00aa38f27bfb019d439dc11" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-reflect-2.13.15.pom">
<sha256 value="aa9cac59324824e5e73dc3456fd3c3ab5f504df63d2f1ddb6413783abb1cecd6" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang.modules" name="scala-asm" version="9.6.0-scala-1">
<artifact name="scala-asm-9.6.0-scala-1.jar">
<sha256 value="bf16f8b69e89cadab550bce266a052780af7f1eb29dd1c04c3bd014113752c12" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-asm-9.6.0-scala-1.pom">
<sha256 value="48bb35622e019293c52c850a528e7bf1c1ba798562ed7829a0b30b37fd38251d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang.modules" name="scala-parser-combinators_2.13" version="1.1.2">
<artifact name="scala-parser-combinators_2.13-1.1.2.jar">
<sha256 value="5c285b72e6dc0a98e99ae0a1ceeb4027dab9adfa441844046bd3f19e0efdcb54" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-parser-combinators_2.13-1.1.2.pom">
<sha256 value="5c856fefc046a88de0118ac5e45cddf638975fa980c007d242633276f7266f02" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang.modules" name="scala-xml_2.13" version="2.3.0">
<artifact name="scala-xml_2.13-2.3.0.jar">
<sha256 value="4b4d6698c74bff84a105102bbf58390980dc7bb8c40bdea4bc727040b3f966bd" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-xml_2.13-2.3.0.pom">
<sha256 value="9e52b1e093853e146b0b75605af98543219193cad4ea50d07b94465f4afa815c" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scala-lang.modules" name="scala-xml_3" version="2.3.0">
<artifact name="scala-xml_3-2.3.0.jar">
<sha256 value="3220723238102107ab83182468e5dbe351b081a0601386710ef46c81a95d38d0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scala-xml_3-2.3.0.pom">
<sha256 value="b83f69d158032e9a83781a0c0a0f99fa8b929411f7198703734a1213c37f095f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalactic" name="scalactic_3" version="3.2.19">
<artifact name="scalactic_3-3.2.19.jar">
<sha256 value="26ef71a6d0993301d28d9693bada18ff81b373336b70368fcff01ed4eb4b958e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalactic_3-3.2.19.pom">
<sha256 value="af2e7bff0e0e7dfbb175b9f109917307d4cde9c56bed23893cfbe6a336780024" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-compatible" version="3.2.19">
<artifact name="scalatest-compatible-3.2.19.jar">
<sha256 value="5dc6b8fa5396fe9e1a7c2b72df174a8eb3e92770cdc3e70636d3eba673cd0da3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-compatible-3.2.19.pom">
<sha256 value="e7f309922cb6d072bd6098674e72e948c2738c0ac7470a63e20bd15614daa3c0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-core_3" version="3.2.19">
<artifact name="scalatest-core_3-3.2.19.jar">
<sha256 value="f6e3d38c2034a9cab7313f644d8a933bf1b5241ff35002cc76916a427a826223" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-core_3-3.2.19.pom">
<sha256 value="069655a6db966a255690c5d9048d4e799c17026055d60d76869e0103da9c1fdb" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-diagrams_3" version="3.2.19">
<artifact name="scalatest-diagrams_3-3.2.19.jar">
<sha256 value="835acf8ec2cb0d39beb1052ee2139029fdac28d172fc867db89ff49d640b255e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-diagrams_3-3.2.19.pom">
<sha256 value="cbc5724b8607cbc9d3852c5bde9c09c9d29e86ac3a5c396bf112c757cdf048f2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-featurespec_3" version="3.2.19">
<artifact name="scalatest-featurespec_3-3.2.19.jar">
<sha256 value="3d49deeede2cd01578e037065862d7734afd3a6330c35dc3c4906f53f57302db" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-featurespec_3-3.2.19.pom">
<sha256 value="589b5d533e9080491301c175e510422e98de22c6f92364a4c0dc598a0664ed83" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-flatspec_3" version="3.2.19">
<artifact name="scalatest-flatspec_3-3.2.19.jar">
<sha256 value="85a6fb2285f20445615c6780a498c3bca99e4c2aad32fab6f74202bdc61e56a9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-flatspec_3-3.2.19.pom">
<sha256 value="d5bcba3b01fdb316c9608a397b8af6f60fd4ffe83ee73479ef9b7acc4cf5a770" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-freespec_3" version="3.2.19">
<artifact name="scalatest-freespec_3-3.2.19.jar">
<sha256 value="ebc8573874766368316366495dcdfe0cca6d8082dc9cc08b5a2fd0834cdaecc0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-freespec_3-3.2.19.pom">
<sha256 value="0b64ca3b958c2cc35eff6a082b4654e87b6b20aaf47afd2377c2d830da8d857b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-funspec_3" version="3.2.19">
<artifact name="scalatest-funspec_3-3.2.19.jar">
<sha256 value="872b6889fac777aa813d21fb5f1e89710407785a61eb18a570142b6be10389a7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-funspec_3-3.2.19.pom">
<sha256 value="25bafeabb74f734eb36ddee6f178c631a65346019d41285844d9ef2895ee2bc1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-funsuite_3" version="3.2.19">
<artifact name="scalatest-funsuite_3-3.2.19.jar">
<sha256 value="42129cc156bd8978d9a438abd57001fc42ababf18f6178cbee91d0a9489334e0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-funsuite_3-3.2.19.pom">
<sha256 value="4045d7402436a35bb87baf447427598892f77280a356c5b670352426e4293478" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-matchers-core_3" version="3.2.19">
<artifact name="scalatest-matchers-core_3-3.2.19.jar">
<sha256 value="723fecdf0ea4542947ef5174068c4e05cd2145a3dcb6ffc797079368c94a187e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-matchers-core_3-3.2.19.pom">
<sha256 value="8b1f6a246ff1914f44550f3f98a95293a06b1d3cf9e505f7be1a8fe901620016" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-mustmatchers_3" version="3.2.19">
<artifact name="scalatest-mustmatchers_3-3.2.19.jar">
<sha256 value="837f76b73ff299fb6748ba0aff4eb7c9d9c00252741ad2bc15af3998d2e0558c" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-mustmatchers_3-3.2.19.pom">
<sha256 value="16bff93b9c86d1c43ab945c111167081e80c1968ca541e670b33f2cfe6b35b9e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-propspec_3" version="3.2.19">
<artifact name="scalatest-propspec_3-3.2.19.jar">
<sha256 value="6b033e73f3a53717a32a0d4d35ae2021a0afe8a028c42da62fb937932934bce3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-propspec_3-3.2.19.pom">
<sha256 value="fc65de4813534fa43a6de25dc09e76eb51dcc4b507c3fda79242c1851dc2d326" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-refspec_3" version="3.2.19">
<artifact name="scalatest-refspec_3-3.2.19.jar">
<sha256 value="827b78a65c25a1dc4af747a7711e24c785fae92c39600fd357a7d486fcce2e7a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-refspec_3-3.2.19.pom">
<sha256 value="16b9e907ccff48dc7d331bad1a6239dbcb89babdb5c969f99c322a5f7923073a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-shouldmatchers_3" version="3.2.19">
<artifact name="scalatest-shouldmatchers_3-3.2.19.jar">
<sha256 value="76ddce37f710ea96bdb3eebcb4bb0a0125fc70fb2ebaa7cc74c9bd28284b6a23" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-shouldmatchers_3-3.2.19.pom">
<sha256 value="826ebb218593a34770e1c77834cfe0bb6315fabc8b32406c8d6dbb8b26a05a75" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest-wordspec_3" version="3.2.19">
<artifact name="scalatest-wordspec_3-3.2.19.jar">
<sha256 value="c6acce0958b086cb857c4da6107f903b6166a46dfa251f54d3a0869212e229c7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest-wordspec_3-3.2.19.pom">
<sha256 value="aec178b094f2176c1ad340be467184176065a3ba04cde4c187947cf750f643de" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scalatest" name="scalatest_3" version="3.2.19">
<artifact name="scalatest_3-3.2.19.jar">
<sha256 value="cd886ba42615fe0d730dd57197e6ee53eeb062cfd0b4d8c5d9757c977c0fdcf8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="scalatest_3-3.2.19.pom">
<sha256 value="b26fcbf4ff2cdbda2654d3da86e7ad7e6fde16ccc46a81ec40247e068ae9326f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="org.scoverage" name="org.scoverage.gradle.plugin" version="8.1">
<artifact name="org.scoverage.gradle.plugin-8.1.pom">
<sha256 value="099b26b0039c24fd4026aabcf0c191fc160bb7881d9e988b7ab480d0d16f85c5" origin="Generated by Gradle" reason="Artifact is not signed"/>
</artifact>
</component>
<component group="org.sonarqube" name="org.sonarqube.gradle.plugin" version="7.2.3.7755">
<artifact name="org.sonarqube.gradle.plugin-7.2.3.7755.pom">
<sha256 value="7b9a2bfb6b7929f789dd2c729569ba7cba5fc8572bd8e9a72c64279da299f0e8" origin="Generated by Gradle" reason="Artifact is not signed"/>
</artifact>
</component>
<component group="org.sonatype.oss" name="oss-parent" version="9">
<artifact name="oss-parent-9.pom">
<pgp value="44FBDBBC1A00FE414F1C1873586654072EAD6677"/>
</artifact>
</component>
<component group="ua.co.k" name="strftime4j" version="1.0.5">
<artifact name="strftime4j-1.0.5.jar">
<sha256 value="8ee3be181a1d3871d2b14e1e145cbc48918abbbf3596268fdd4b3d7292b07fc9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="strftime4j-1.0.5.pom">
<sha256 value="df50d06823a4f87c3fd739ebd1718f09bc126bb8206dfde8c477b3a816edf500" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
</components>
</verification-metadata>
Vendored Regular → Executable
View File
-411
View File
@@ -1,411 +0,0 @@
#!/usr/bin/env python3
"""
JaCoCo Coverage Gap Reporter
Parses a JaCoCo XML report and outputs missing line & branch (conditional)
coverage in a structured format that Claude Code agents can act on directly.
Usage:
python jacoco_coverage_gaps.py <jacoco-report.xml> [--min-coverage 80]
python jacoco_coverage_gaps.py <jacoco-report.xml> --output json
python jacoco_coverage_gaps.py <jacoco-report.xml> --output markdown
python jacoco_coverage_gaps.py <jacoco-report.xml> --output agent (default)
"""
import xml.etree.ElementTree as ET
import sys
import argparse
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class LineCoverage:
line_number: int
hits: int # 0 = not executed
branch_total: int = 0 # 0 = not a branch point
branch_covered: int = 0
@property
def is_uncovered(self) -> bool:
return self.hits == 0
@property
def is_partial_branch(self) -> bool:
return self.branch_total > 0 and self.branch_covered < self.branch_total
@dataclass
class MethodCoverage:
name: str
descriptor: str
first_line: Optional[int]
missed_instructions: int
covered_instructions: int
missed_branches: int
covered_branches: int
uncovered_lines: list[int] = field(default_factory=list)
partial_branch_lines: list[int] = field(default_factory=list)
@property
def total_branches(self) -> int:
return self.missed_branches + self.covered_branches
@property
def is_fully_covered(self) -> bool:
return self.missed_instructions == 0 and self.missed_branches == 0
@property
def branch_coverage_pct(self) -> float:
total = self.total_branches
return 100.0 * self.covered_branches / total if total else 100.0
@property
def line_coverage_pct(self) -> float:
total = self.missed_instructions + self.covered_instructions
return 100.0 * self.covered_instructions / total if total else 100.0
@dataclass
class ClassCoverage:
class_name: str # e.g. com/example/Foo
source_file: Optional[str]
methods: list[MethodCoverage] = field(default_factory=list)
all_lines: list[LineCoverage] = field(default_factory=list)
@property
def java_class_name(self) -> str:
return self.class_name.replace("/", ".")
@property
def source_path(self) -> Optional[str]:
"""Best-guess relative source path."""
if self.source_file:
package = "/".join(self.class_name.split("/")[:-1])
return f"src/main/java/{package}/{self.source_file}" if package else f"src/main/java/{self.source_file}"
return None
@property
def uncovered_lines(self) -> list[int]:
return sorted({l.line_number for l in self.all_lines if l.is_uncovered})
@property
def partial_branch_lines(self) -> list[int]:
return sorted({l.line_number for l in self.all_lines if l.is_partial_branch})
@property
def missed_branches(self) -> int:
return sum(max(l.branch_total - l.branch_covered, 0) for l in self.all_lines)
@property
def total_branches(self) -> int:
return sum(l.branch_total for l in self.all_lines)
@property
def covered_branches(self) -> int:
return self.total_branches - self.missed_branches
@property
def missed_lines(self) -> int:
return len(self.uncovered_lines)
@property
def total_lines(self) -> int:
return len(self.all_lines)
@property
def covered_lines(self) -> int:
return self.total_lines - self.missed_lines
@property
def has_gaps(self) -> bool:
return bool(self.uncovered_lines or self.partial_branch_lines)
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def parse_jacoco_xml(xml_path: str) -> list[ClassCoverage]:
"""Parse a JaCoCo XML report into ClassCoverage objects."""
tree = ET.parse(xml_path)
root = tree.getroot()
results: list[ClassCoverage] = []
for package in root.iter("package"):
for cls_elem in package.findall("class"):
class_name = cls_elem.get("name", "")
source_file = cls_elem.get("sourcefilename")
# Build method map from <method> children
methods: list[MethodCoverage] = []
for m in cls_elem.findall("method"):
counters = {c.get("type"): c for c in m.findall("counter")}
def _missed(t): return int(counters[t].get("missed", 0)) if t in counters else 0
def _covered(t): return int(counters[t].get("covered", 0)) if t in counters else 0
methods.append(MethodCoverage(
name=m.get("name", ""),
descriptor=m.get("desc", ""),
first_line=int(m.get("line")) if m.get("line") else None,
missed_instructions=_missed("INSTRUCTION"),
covered_instructions=_covered("INSTRUCTION"),
missed_branches=_missed("BRANCH"),
covered_branches=_covered("BRANCH"),
))
cc = ClassCoverage(
class_name=class_name,
source_file=source_file,
methods=methods,
)
# Per-line data lives in the matching <sourcefile> element
source_file_elem = package.find(f"sourcefile[@name='{source_file}']") if source_file else None
if source_file_elem is not None:
for line_elem in source_file_elem.findall("line"):
nr = int(line_elem.get("nr", 0))
mi = int(line_elem.get("mi", 0)) # missed instructions
ci = int(line_elem.get("ci", 0)) # covered instructions
mb = int(line_elem.get("mb", 0)) # missed branches
cb = int(line_elem.get("cb", 0)) # covered branches
hits = ci # ci > 0 means line was executed at least once
cc.all_lines.append(LineCoverage(
line_number=nr,
hits=hits,
branch_total=mb + cb,
branch_covered=cb,
))
if cc.has_gaps:
results.append(cc)
return results
# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------
def _compact_ranges(numbers: list[int]) -> str:
"""Turn [1,2,3,5,7,8,9] -> '1-3, 5, 7-9'"""
if not numbers:
return ""
ranges = []
start = prev = numbers[0]
for n in numbers[1:]:
if n == prev + 1:
prev = n
else:
ranges.append(f"{start}-{prev}" if start != prev else str(start))
start = prev = n
ranges.append(f"{start}-{prev}" if start != prev else str(start))
return ", ".join(ranges)
def format_agent(classes: list[ClassCoverage]) -> str:
"""
Output optimised for Claude Code agents:
structured, machine-readable yet human-legible
uses file paths and line numbers agents can act on
groups by file, sorts by severity (most gaps first)
"""
lines: list[str] = []
lines.append("# JaCoCo Coverage Gaps — Agent Action Report")
lines.append("")
lines.append("## Summary")
total_uncovered = sum(c.missed_lines for c in classes)
total_partial = sum(len(c.partial_branch_lines) for c in classes)
total_missed_branches = sum(c.missed_branches for c in classes)
lines.append(f"- Files with gaps : {len(classes)}")
lines.append(f"- Uncovered lines : {total_uncovered}")
lines.append(f"- Partial branches: {total_partial} lines affected")
lines.append(f"- Missed branches : {total_missed_branches} branch paths")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Files Requiring Tests")
lines.append("")
lines.append("> Each entry lists the SOURCE FILE PATH, the LINE NUMBERS that need")
lines.append("> coverage, and the METHODS that contain those gaps.")
lines.append("> Write or extend unit/integration tests to exercise these paths.")
lines.append("")
# Sort: most uncovered lines first
sorted_classes = sorted(classes, key=lambda c: -(c.missed_lines + len(c.partial_branch_lines)))
for cls in sorted_classes:
source = cls.source_path or f"(source unknown) {cls.java_class_name}"
lines.append(f"### `{source}`")
lines.append(f"**Class**: `{cls.java_class_name}`")
lines.append("")
if cls.uncovered_lines:
lines.append(f"#### ❌ Uncovered Lines")
lines.append(f"Lines not executed at all: `{_compact_ranges(cls.uncovered_lines)}`")
lines.append("")
lines.append("**Methods with uncovered lines:**")
for method in cls.methods:
uncov = [l for l in cls.uncovered_lines
if method.first_line and l >= method.first_line]
# heuristic: only attribute if there are uncovered lines near the method start
if method.missed_instructions > 0:
sig = f"`{method.name}{method.descriptor}`"
pct = method.line_coverage_pct
lines.append(f" - {sig}{pct:.0f}% instruction coverage")
lines.append("")
if cls.partial_branch_lines:
lines.append(f"#### ⚠️ Partial Branch Coverage (Missing Conditional Paths)")
lines.append(f"Lines where not all branches are taken: `{_compact_ranges(cls.partial_branch_lines)}`")
lines.append("")
lines.append("**Methods with branch gaps:**")
for method in cls.methods:
if method.missed_branches > 0:
sig = f"`{method.name}{method.descriptor}`"
pct = method.branch_coverage_pct
missing = method.missed_branches
lines.append(f" - {sig}{pct:.0f}% branch coverage ({missing} branch path(s) never taken)")
lines.append("")
lines.append("**Action**: Add tests that exercise the above lines/branches.")
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:
src = cls.source_path or cls.java_class_name
if cls.uncovered_lines:
for ln in cls.uncovered_lines:
lines.append(f"{src}:{ln} # uncovered line")
if cls.partial_branch_lines:
for ln in cls.partial_branch_lines:
lines.append(f"{src}:{ln} # partial branch")
lines.append("```")
return "\n".join(lines)
def format_json(classes: list[ClassCoverage]) -> str:
out = []
for cls in classes:
out.append({
"class": cls.java_class_name,
"source_path": cls.source_path,
"uncovered_lines": cls.uncovered_lines,
"partial_branch_lines": cls.partial_branch_lines,
"missed_branches": cls.missed_branches,
"methods": [
{
"name": m.name,
"descriptor": m.descriptor,
"first_line": m.first_line,
"line_coverage_pct": round(m.line_coverage_pct, 1),
"branch_coverage_pct": round(m.branch_coverage_pct, 1),
"missed_branches": m.missed_branches,
"missed_instructions": m.missed_instructions,
}
for m in cls.methods
if not m.is_fully_covered
],
})
return json.dumps(out, indent=2)
def format_markdown(classes: list[ClassCoverage]) -> str:
lines: list[str] = []
lines.append("# JaCoCo Missing Coverage Report\n")
for cls in sorted(classes, key=lambda c: cls.java_class_name):
lines.append(f"## {cls.java_class_name}")
if cls.source_path:
lines.append(f"**File**: `{cls.source_path}`\n")
if cls.uncovered_lines:
lines.append(f"**Uncovered lines**: {_compact_ranges(cls.uncovered_lines)}\n")
if cls.partial_branch_lines:
lines.append(f"**Partial branches at lines**: {_compact_ranges(cls.partial_branch_lines)}\n")
lines.append("| Method | Line Coverage | Branch Coverage | Missed Branches |")
lines.append("|--------|--------------|-----------------|-----------------|")
for m in cls.methods:
if not m.is_fully_covered:
lines.append(
f"| `{m.name}` | {m.line_coverage_pct:.0f}% | "
f"{m.branch_coverage_pct:.0f}% | {m.missed_branches} |"
)
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Report missing line & branch coverage from a JaCoCo XML report."
)
parser.add_argument("xml_file", help="Path to jacoco.xml report file")
parser.add_argument(
"--output", "-o",
choices=["agent", "json", "markdown"],
default="json",
help="Output format (default: agent)",
)
parser.add_argument(
"--min-coverage",
type=float,
default=0.0,
help="Only report classes below this %% line coverage (0 = report all gaps)",
)
parser.add_argument(
"--package-filter", "-p",
default=None,
help="Only report classes in this package prefix (e.g. com/example/service)",
)
args = parser.parse_args()
xml_path = Path(args.xml_file)
if not xml_path.exists():
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
sys.exit(1)
classes = parse_jacoco_xml(str(xml_path))
# Apply package filter
if args.package_filter:
prefix = args.package_filter.replace(".", "/")
classes = [c for c in classes if c.class_name.startswith(prefix)]
# Apply min-coverage filter
if args.min_coverage > 0:
def _line_pct(c: ClassCoverage) -> float:
total = c.total_lines
return 100.0 * c.covered_lines / total if total else 100.0
classes = [c for c in classes if _line_pct(c) < args.min_coverage]
if not classes:
print("✅ No coverage gaps found matching the given filters.")
return
if args.output == "agent":
print(format_agent(classes))
elif args.output == "json":
print(format_json(classes))
elif args.output == "markdown":
print(format_markdown(classes))
if __name__ == "__main__":
main()
+159 -111
View File
@@ -19,6 +19,9 @@ Usage:
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> --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
@@ -26,7 +29,8 @@ import sys
import argparse
import json
import re
from pathlib import Path, PureWindowsPath
import glob
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
@@ -112,7 +116,6 @@ class ClassGap:
@property
def uncovered_branch_lines(self) -> list[int]:
"""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
by_line: dict[int, list[Statement]] = defaultdict(list)
for s in self.statements:
@@ -120,10 +123,7 @@ class ClassGap:
by_line[s.line].append(s)
partial = []
for line, stmts in by_line.items():
has_covered = any(s.is_covered 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:
if any(s.is_uncovered for s in stmts):
partial.append(line)
return sorted(partial)
@@ -169,20 +169,10 @@ class ClassGap:
# ---------------------------------------------------------------------------
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("\\", "/")
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
if match:
return match.group(1)
# Fallback: just the filename portion
return normalised.split("/")[-1]
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
# 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)
root = tree.getroot()
# ── Authoritative project-level totals from <scoverage> root element ──────
project_stats = {
"total_statements": int(root.get("statement-count", 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)),
}
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 cls_elem in package.findall("classes/class"):
class_name = cls_elem.get("name", "")
filename = cls_elem.get("filename", "")
# Authoritative per-class totals from <class> attributes
cls_total = int(cls_elem.get("statement-count", 0))
cls_invoked = int(cls_elem.get("statements-invoked", 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"):
method_name = method_elem.get("name", "")
# Authoritative per-method totals from <method> attributes
m_total = int(method_elem.get("statement-count", 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))
m_total = int(method_elem.get("statement-count", 0))
m_invoked = int(method_elem.get("statements-invoked", 0))
for stmt_elem in method_elem.findall("statements/statement"):
raw_source = stmt_elem.get("source", filename)
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
method=method_name,
))
# Register method-level gap using authoritative XML stats
cg = next(
(v for v in class_map.values() if v.class_name == class_name),
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_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
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)
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
mg = MethodGap(
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
)
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]
@@ -310,103 +292,60 @@ def _compact_ranges(numbers: list[int]) -> str:
# 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:
"""
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.append("# scoverage Coverage Gaps — Agent Action Report")
lines.append("")
# ---- Project-level totals (authoritative from <scoverage> root element) ----
total_stmts = project_stats["total_statements"]
covered_stmts = project_stats["covered_statements"]
missed_stmts = project_stats["missed_statements"]
total_stmts = project_stats["total_statements"]
covered_stmts = project_stats["covered_statements"]
missed_stmts = project_stats["missed_statements"]
overall_stmt_pct = project_stats["stmt_coverage_pct"]
overall_branch_pct = project_stats["branch_coverage_pct"]
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes)
# Branch totals: count from statement data (scoverage root has no branch count attr)
total_branches = sum(c.total_branches for c in classes)
covered_branches = sum(c.covered_branches for c in classes)
missed_branches = sum(c.missed_branches for c in classes)
total_branches = sum(c.total_branches for c in classes)
covered_branches = sum(c.covered_branches for c in classes)
missed_branches = total_branches - covered_branches
lines.append("## Project Coverage Summary")
lines.append("")
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
lines.append(f"|-------------------|---------|-------|--------|----------|")
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |")
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("# scoverage Coverage Gaps")
lines.append(
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
f"files with gaps: {len(classes)}"
)
lines.append("")
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
for cls in sorted_classes:
lines.append(f"### `{cls.source_path}`")
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("")
uncov = cls.all_uncovered_lines
branch_lines = cls.uncovered_branch_lines
if branch_lines:
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)")
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`")
lines.append("")
lines.append(f"## {cls.source_path}")
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:
lines.append("#### Methods with Gaps")
lines.append("")
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
lines.append("methods:")
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)"
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a"
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else ""
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else ""
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |")
lines.append("")
parts = [f" {mg.short_name}"]
if mg.uncovered_lines:
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
if mg.uncovered_branch_lines:
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
lines.append(" ".join(parts))
lines.append("**Action**: Add tests that exercise the lines/branches listed above.")
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)
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
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
# ---------------------------------------------------------------------------
@@ -519,7 +539,13 @@ def main() -> None:
parser = argparse.ArgumentParser(
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(
"--output", "-o",
choices=["agent", "json", "markdown"],
@@ -537,8 +563,30 @@ def main() -> None:
default=None,
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()
# ── 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)
if not xml_path.exists():
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
@@ -565,4 +613,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+12
View File
@@ -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)
+288
View File
@@ -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()
+23
View File
@@ -0,0 +1,23 @@
## (2026-03-27)
## (2026-03-28)
## (2026-03-28)
## (2026-03-29)
## (2026-03-31)
## (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))
+3 -2
View File
@@ -49,16 +49,17 @@ dependencies {
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("passed", "skipped", "failed")
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -8,10 +8,15 @@ object Board:
extension (b: Board)
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
def removed(sq: Square): Board = b.removed(sq)
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
val captured = b.get(to)
val updated = b.removed(from).updated(to, b(from))
(updated, captured)
val updatedBoard = b.removed(from).updated(to, b(from))
(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
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
)
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. */
enum MoveType:
/** A normal move or capture with no special rule. */
case Normal
case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */
case CastleKingside
/** Queenside castling (O-O-O). */
@@ -29,5 +29,5 @@ enum MoveType:
final case class Move(
from: Square,
to: Square,
moveType: MoveType = MoveType.Normal
moveType: MoveType = MoveType.Normal()
)
@@ -0,0 +1,108 @@
package de.nowchess.api.board
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class BoardTest extends AnyFunSuite with Matchers:
private val e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4)
test("pieceAt resolves occupied and empty squares") {
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
Board.initial.pieceAt(e4) shouldBe None
}
test("withMove moves piece and vacates origin") {
val (board, captured) = Board.initial.withMove(e2, e4)
captured shouldBe None
board.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
board.pieceAt(e2) shouldBe None
}
test("withMove returns captured piece when destination is occupied") {
val from = Square(File.A, Rank.R1)
val to = Square(File.A, Rank.R8)
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
val (board, captured) = b.withMove(from, to)
captured shouldBe Some(Piece.BlackRook)
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
board.pieceAt(from) shouldBe None
}
test("Board.apply and pieces expose the wrapped map") {
val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map)
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
b.pieces shouldBe map
}
test("initial board has expected material and pawn placement") {
Board.initial.pieces should have size 32
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
File.values.foreach { file =>
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
}
}
test("initial board white back rank") {
val expectedBackRank = Vector(
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
)
File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
Some(Piece(Color.White, expectedBackRank(i)))
}
}
test("initial board black back rank") {
val expectedBackRank = Vector(
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
)
File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
Some(Piece(Color.Black, expectedBackRank(i)))
}
}
test("ranks 3-6 are empty on initial board") {
val emptyRanks = Seq(Rank.R3, Rank.R4, Rank.R5, Rank.R6)
for
rank <- emptyRanks
file <- File.values
do
Board.initial.pieceAt(Square(file, rank)) shouldBe None
}
test("updated adds and replaces piece at squares") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val added = b.updated(e4, Piece.WhiteKnight)
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
val replaced = b.updated(e2, Piece.WhiteKnight)
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
}
test("removed deletes piece from board") {
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
val removed = b.removed(e2)
removed.pieceAt(e2) shouldBe None
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
@@ -0,0 +1,17 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class ColorTest extends AnyFunSuite with Matchers:
test("Color values expose opposite and label consistently"):
val cases = List(
(Color.White, Color.Black, "White"),
(Color.Black, Color.White, "Black")
)
cases.foreach { (color, opposite, label) =>
color.opposite shouldBe opposite
color.label shouldBe label
}
@@ -0,0 +1,33 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PieceTest extends AnyFunSuite with Matchers:
test("Piece holds color and pieceType") {
val p = Piece(Color.White, PieceType.Queen)
p.color shouldBe Color.White
p.pieceType shouldBe PieceType.Queen
}
test("all convenience constants map to expected color and piece type") {
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)
)
expected.foreach { case (actual, wanted) =>
actual shouldBe wanted
}
}
@@ -0,0 +1,20 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PieceTypeTest extends AnyFunSuite with Matchers:
test("PieceType values expose the expected labels"):
val expectedLabels = List(
PieceType.Pawn -> "Pawn",
PieceType.Knight -> "Knight",
PieceType.Bishop -> "Bishop",
PieceType.Rook -> "Rook",
PieceType.Queen -> "Queen",
PieceType.King -> "King"
)
expectedLabels.foreach { (pieceType, expectedLabel) =>
pieceType.label shouldBe expectedLabel
}
@@ -0,0 +1,37 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class SquareTest extends AnyFunSuite with Matchers:
test("toString renders algebraic notation for edge and middle squares") {
Square(File.A, Rank.R1).toString shouldBe "a1"
Square(File.E, Rank.R4).toString shouldBe "e4"
Square(File.H, Rank.R8).toString shouldBe "h8"
}
test("fromAlgebraic parses valid coordinates including case-insensitive files") {
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 rejects malformed coordinates") {
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
Square.fromAlgebraic(raw) shouldBe None
}
}
test("offset returns Some in-bounds and None out-of-bounds") {
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
}
@@ -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)
@@ -0,0 +1,34 @@
package de.nowchess.api.move
import de.nowchess.api.board.{File, Rank, Square}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveTest extends AnyFunSuite with Matchers:
private val e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4)
test("Move defaults to Normal and keeps from/to squares") {
val m = Move(e2, e4)
m.from shouldBe e2
m.to shouldBe e4
m.moveType shouldBe MoveType.Normal()
}
test("Move accepts all supported move types") {
val moveTypes = List(
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)
)
moveTypes.foreach { moveType =>
Move(e2, e4, moveType).moveType shouldBe moveType
}
}
@@ -0,0 +1,18 @@
package de.nowchess.api.player
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PlayerInfoTest extends AnyFunSuite with Matchers:
test("PlayerId and PlayerInfo preserve constructor values") {
val raw = "player-123"
val id = PlayerId(raw)
id.value shouldBe raw
val playerId = PlayerId("p1")
val info = PlayerInfo(playerId, "Magnus")
info.id.value shouldBe "p1"
info.displayName shouldBe "Magnus"
}
@@ -0,0 +1,36 @@
package de.nowchess.api.response
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class ApiResponseTest extends AnyFunSuite with Matchers:
test("ApiResponse factories and payload wrappers keep values") {
val r = ApiResponse.Success(42)
r.data shouldBe 42
val err = ApiError("CODE", "msg")
ApiResponse.Failure(List(err)).errors shouldBe List(err)
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
val e = ApiError("CODE", "message")
e.code shouldBe "CODE"
e.message shouldBe "message"
e.field shouldBe None
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
}
test("Pagination.totalPages handles normal and guarded inputs") {
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
}
test("PagedResponse holds items and pagination") {
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
val pr = PagedResponse(List("a", "b"), pagination)
pr.items shouldBe List("a", "b")
pr.pagination shouldBe pagination
}
+3
View File
@@ -0,0 +1,3 @@
MAJOR=0
MINOR=3
PATCH=0
+213
View File
@@ -0,0 +1,213 @@
## (2026-03-27)
### 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))
* 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))
## (2026-03-28)
### 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))
* 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))
## (2026-03-29)
### 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-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* 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))
## (2026-03-29)
### 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-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))
## (2026-03-31)
### 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-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))
## (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-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-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))
+5 -12
View File
@@ -1,7 +1,6 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
application
}
group = "de.nowchess"
@@ -22,19 +21,10 @@ scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
application {
mainClass.set("de.nowchess.chess.chessMain")
}
tasks.withType<ScalaCompile> {
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 {
implementation("org.scala-lang:scala3-compiler_3") {
@@ -49,19 +39,22 @@ dependencies {
}
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("passed", "skipped", "failed")
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -1,11 +0,0 @@
package de.nowchess.chess
import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameContext
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(GameContext.initial, Color.White)
}
@@ -0,0 +1,61 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Piece}
import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
*/
trait Command:
/** Execute the command and return true if successful, false otherwise. */
def execute(): Boolean
/** Undo the command and return true if successful, false otherwise. */
def undo(): Boolean
/** A human-readable description of this command. */
def description: String
/** Command to move a piece from one square to another.
* Stores the move result so undo can restore previous state.
*/
case class MoveCommand(
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None,
notation: String = ""
) extends Command:
override def execute(): Boolean =
moveResult.isDefined
override def undo(): Boolean =
previousContext.isDefined
override def description: String = s"Move from $from to $to"
// Sealed hierarchy of move outcomes (for tracking state changes)
sealed trait MoveResult
object MoveResult:
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult
/** Command to quit the game. */
case class QuitCommand() extends Command:
override def execute(): Boolean = true
override def undo(): Boolean = false
override def description: String = "Quit game"
/** Command to reset the board to initial position. */
case class ResetCommand(
previousContext: Option[GameContext] = None
) extends Command:
override def execute(): Boolean = true
override def undo(): Boolean =
previousContext.isDefined
override def description: String = "Reset board"
@@ -0,0 +1,73 @@
package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */
class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
private var currentIndex = -1
/** Execute a command and add it to history.
* Discards any redo history if not at the end of the stack.
*/
def execute(command: Command): Boolean = synchronized {
if command.execute() then
// Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do
executedCommands.remove(executedCommands.size - 1)
executedCommands += command
currentIndex += 1
true
else
false
}
/** Undo the last executed command if possible. */
def undo(): Boolean = synchronized {
if currentIndex >= 0 && currentIndex < executedCommands.size then
val command = executedCommands(currentIndex)
if command.undo() then
currentIndex -= 1
true
else
false
else
false
}
/** Redo the next command in history if available. */
def redo(): Boolean = synchronized {
if currentIndex + 1 < executedCommands.size then
val command = executedCommands(currentIndex + 1)
if command.execute() then
currentIndex += 1
true
else
false
else
false
}
/** Get the history of all executed commands. */
def history: List[Command] = synchronized {
executedCommands.toList
}
/** Get the current position in command history. */
def getCurrentIndex: Int = synchronized {
currentIndex
}
/** Clear all command history. */
def clear(): Unit = synchronized {
executedCommands.clear()
currentIndex = -1
}
/** Check if undo is available. */
def canUndo: Boolean = synchronized {
currentIndex >= 0
}
/** Check if redo is available. */
def canRedo: Boolean = synchronized {
currentIndex + 1 < executedCommands.size
}
@@ -1,100 +0,0 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, Piece}
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus}
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newCtx: GameContext, 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(ctx: GameContext, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
ctx.board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(ctx, from, to) then
MoveResult.IllegalMove
else
val (newBoard, captured) = ctx.board.withMove(from, to)
val newCtx = ctx.copy(board = newBoard)
GameRules.gameStatus(newCtx, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(ctx: GameContext, turn: Color): Unit =
println()
print(Renderer.render(ctx.board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(ctx, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(ctx, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(ctx, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(ctx, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(ctx, turn)
case MoveResult.Moved(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newCtx, newTurn)
case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newCtx, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(GameContext.initial, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(GameContext.initial, Color.White)
@@ -0,0 +1,318 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
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.
* All rule queries delegate to the injected RuleSet.
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules
) extends Observable:
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
// Synchronized accessors for current state
def board: Board = synchronized { currentContext.board }
def turn: Color = synchronized { currentContext.turn }
def context: GameContext = synchronized { currentContext }
/** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo }
/** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo }
/** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
/** Process a raw move input string and update game state if valid.
* Notifies all observers of the outcome via GameEvent.
*/
def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase
trimmed match
case "quit" | "q" =>
()
case "undo" =>
performUndo()
case "redo" =>
performRedo()
case "draw" =>
if currentContext.halfMoveClock >= 100 then
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
else
notifyObservers(InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
case moveInput =>
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
case Some((from, to)) =>
handleParsedMove(from, to)
}
private def handleParsedMove(from: Square, to: Square): Unit =
currentContext.board.pieceAt(from) match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
case Some(piece) if piece.color != currentContext.turn =>
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
case Some(piece) =>
val legal = ruleSet.legalMoves(currentContext)(from)
// Find all legal moves going to `to`
val candidates = legal.filter(_.to == to)
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)
private def isPromotionMove(piece: Piece, to: Square): Boolean =
piece.pieceType == PieceType.Pawn && {
val promoRank = if piece.color == Color.White then 7 else 0
to.rank.ordinal == promoRank
}
/** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true.
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
case Some(pending) =>
pendingPromotion = None
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then
executeMove(move)
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Undo the last move. */
def undo(): Unit = synchronized { performUndo() }
/** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() }
/** Load a game using the provided importer.
* If the imported context has moves, they are replayed through the command system.
* Otherwise, the position is set directly.
* Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
notifyObservers(PgnLoadedEvent(currentContext))
}
}
private def replayGame(ctx: GameContext): Either[String, Unit] =
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. */
def reset(): Unit = synchronized {
currentContext = GameContext.initial
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
// ──── 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 =
if invoker.canUndo then
val cmd = invoker.history(invoker.getCurrentIndex)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
val cmd = invoker.history(invoker.getCurrentIndex + 1)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
currentContext = nextCtx
invoker.redo()
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
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -1,47 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.CastlingRights
enum CastleSide:
case Kingside, Queenside
case class GameContext(
board: Board,
whiteCastling: CastlingRights,
blackCastling: CastlingRights
):
def castlingFor(color: Color): CastlingRights =
if color == Color.White then whiteCastling else blackCastling
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
if color == Color.White then copy(whiteCastling = rights)
else copy(blackCastling = rights)
object GameContext:
/** Convenience constructor for test boards: no castling rights on either side. */
def apply(board: Board): GameContext =
GameContext(board, CastlingRights.None, CastlingRights.None)
val initial: GameContext =
GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both)
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match
case (Color.White, CastleSide.Kingside) =>
(Square(File.E, Rank.R1), Square(File.G, Rank.R1),
Square(File.H, Rank.R1), Square(File.F, Rank.R1))
case (Color.White, CastleSide.Queenside) =>
(Square(File.E, Rank.R1), Square(File.C, Rank.R1),
Square(File.A, Rank.R1), Square(File.D, Rank.R1))
case (Color.Black, CastleSide.Kingside) =>
(Square(File.E, Rank.R8), Square(File.G, Rank.R8),
Square(File.H, Rank.R8), Square(File.F, Rank.R8))
case (Color.Black, CastleSide.Queenside) =>
(Square(File.E, Rank.R8), Square(File.C, Rank.R8),
Square(File.A, Rank.R8), Square(File.D, Rank.R8))
val king = Piece(color, PieceType.King)
val rook = Piece(color, PieceType.Rook)
Board(b.pieces.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king).updated(rookTo, rook))
@@ -1,47 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameContext
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(ctx: GameContext, color: Color): Set[(Square, Square)] =
ctx.board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling
.filter { to =>
val newBoard =
if MoveValidator.isCastle(ctx.board, from, to) then
ctx.board.withCastle(color, MoveValidator.castleSide(from, to))
else
ctx.board.withMove(from, to)._1
!isInCheck(newBoard, color)
}
.map(to => from -> to)
}
.toSet
/** Position status for the side whose turn it is (`color`). */
def gameStatus(ctx: GameContext, color: Color): PositionStatus =
val moves = legalMoves(ctx, color)
val inCheck = isInCheck(ctx.board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
else PositionStatus.Normal

Some files were not shown because too many files have changed in this diff Show More