+
+
+
\ No newline at end of file
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 3af8876..a99d21e 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/CLAUDE.md b/CLAUDE.md
index b5ab597..d75a177 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@ Scala 3.5.1 · Gradle 9
./test # Run all tests
./coverage # Check coverage
```
-Try to stick to these commands for consistency.
+Use consistently.
## Modules
@@ -25,14 +25,14 @@ Try to stick to these commands for consistency.
## Style
-- Use immutable data and pure functions.
-- Keep functions under 30 lines. If you need "and" to describe it, split it.
-- Keep cyclomatic complexity under 15.
-- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
-- Scan for duplicated logic before finishing. Extract it.
+- Immutable data, pure functions.
+- Functions under 30 lines. Need "and"? Split it.
+- Cyclomatic complexity under 15.
+- No comments. Names carry intent. Comment non-obvious algorithms only.
+- Scan duplicated logic. Extract.
- 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.
+- `Option`/`Either` for fallible ops. Skip exceptions for control flow.
+- Naming: types PascalCase, functions/values camelCase.
## Code Quality
@@ -40,23 +40,23 @@ Try to stick to these commands for consistency.
### Linters
-- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
-- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
+- **scalafmt** — Enforces formatting. Check: `./gradlew spotlessScalaCheck`. Refactor: `./gradlew spotlessScalaApply`.
+- **scalafix** — Enforces style, detects unused imports/code. Run: `./gradlew scalafix`.
## Architecture Decisions
-- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
-- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
-- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
-- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
-- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
-- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
+- **Immutable state as primary model:** GameContext (api) holds board, history, player state—immutable throughout. Each move → new GameContext. Enables undo/redo without side effects.
+- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens (no polling). GameEngine never imports UI.
+- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as black box; rules don't know rest of core.
+- **Polyglot hash must follow spec index layout:** Piece keys use interleaved mapping `(pieceType * 2 + colorBit)` (black=0, white=1). Castling keys: `768..771`. En-passant file keys: `772..779`, XORed only if side-to-move has capturable en passant. Side-to-move key: `780` (white).
+- **Alpha-beta uses sequential PV search by default:** Parallel split disabled (fixed-window futures removed pruning effectiveness). Sequential PV default. Correctness + pruning quality > speculative parallelism.
+- **Search hash is updated incrementally per move:** Bot search updates Zobrist keys from parent hash with move deltas, not recomputing piece scans per node.
## Rules
-- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
+- **Tests are the spec.** Don't modify to pass. Fix requirements/code. Update only if requirements change.
- Never read build folders. Ask permission if needed.
-- Keep this file up to date with any important decisions or conventions.
+- Keep file current with decisions + conventions.
---
@@ -64,11 +64,9 @@ Try to stick to these commands for consistency.
### Two-Step Rule (mandatory)
**Step 1 — Orient:** Use wiki articles to find WHERE things live.
-**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
+**Step 2 — Verify:** Read source files from wiki BEFORE coding.
-Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
-They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
-**Never write or modify code based solely on wiki content — always read source files first.**
+Wiki = structural summaries (routes, models, file locations). No function logic, middleware internals, runtime behavior. Don't code from wiki alone—read sources.
Read in order at session start:
1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
@@ -76,8 +74,7 @@ Read in order at session start:
3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
4. `.codesight/CODESIGHT.md` — full context map for deep exploration
-Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
-If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
+`[inferred]` routes = regex-detected. Verify sources. ⚠ in wiki? Re-run `codesight --wiki`.
Or use the codesight MCP server for on-demand queries:
- `codesight_get_wiki_article` — read a specific wiki article by name
@@ -87,13 +84,13 @@ Or use the codesight MCP server for on-demand queries:
- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
- `codesight_get_schema --model users` — specific model details
-Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
+Consult codesight context first. Saves ~16.893 tokens/conversation.
## graphify
-This project has a graphify knowledge graph at graphify-out/.
+graphify knowledge graph at graphify-out/.
Rules:
-- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
-- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
-- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
+- Architecture/codebase questions? Read graphify-out/GRAPH_REPORT.md (god nodes, communities).
+- graphify-out/wiki/index.md exists? Use it (not raw files).
+- Code modified? Run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to sync graph.
\ No newline at end of file
diff --git a/CLAUDE.original.md b/CLAUDE.original.md
new file mode 100644
index 0000000..b5ab597
--- /dev/null
+++ b/CLAUDE.original.md
@@ -0,0 +1,99 @@
+# Now-Chess
+
+Scala 3.5.1 · Gradle 9
+
+## Commands
+
+```
+./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.
+
+## Modules
+
+| Module | Role | Depends on |
+|--------|------|-----------|
+| `api` | Model / shared types | (none) |
+| `core` | Primary business logic | api, rule |
+| `rule` | Game rules | api |
+| `bot` | Bots and AI | api,rule,io |
+| `io` | Export formats | api, core |
+| `ui` | Entrypoint & UI | core, io |
+
+## Style
+
+- Use immutable data and pure functions.
+- Keep functions under 30 lines. If you need "and" to describe it, split it.
+- Keep cyclomatic complexity under 15.
+- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
+- Scan for duplicated logic before finishing. Extract it.
+- Follow default Sonar style for Scala.
+- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
+- Naming: types are PascalCase, functions/values are camelCase.
+
+## Code Quality
+
+- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
+
+### Linters
+
+- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
+- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
+
+## Architecture Decisions
+
+- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
+- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
+- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
+- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
+- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
+- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
+
+## 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.
+
+---
+
+## Instructions for Claude Code
+
+### Two-Step Rule (mandatory)
+**Step 1 — Orient:** Use wiki articles to find WHERE things live.
+**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
+
+Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
+They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
+**Never write or modify code based solely on wiki content — always read source files first.**
+
+Read in order at session start:
+1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
+2. `.codesight/wiki/overview.md` — architecture overview (~500 tokens)
+3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
+4. `.codesight/CODESIGHT.md` — full context map for deep exploration
+
+Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
+If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
+
+Or use the codesight MCP server for on-demand queries:
+- `codesight_get_wiki_article` — read a specific wiki article by name
+- `codesight_get_wiki_index` — get the wiki index
+- `codesight_get_summary` — quick project overview
+- `codesight_get_routes --prefix /api/users` — filtered routes
+- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
+- `codesight_get_schema --model users` — specific model details
+
+Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
+
+## graphify
+
+This project has a graphify knowledge graph at graphify-out/.
+
+Rules:
+- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
+- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
+- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
diff --git a/bruno/bruno.json b/bruno/bruno.json
new file mode 100644
index 0000000..95fa1d4
--- /dev/null
+++ b/bruno/bruno.json
@@ -0,0 +1,6 @@
+{
+ "version": "1",
+ "name": "NowChess API",
+ "type": "collection",
+ "ignore": []
+}
diff --git a/bruno/collection.bru b/bruno/collection.bru
new file mode 100644
index 0000000..e69de29
diff --git a/bruno/draw/01 Offer Draw.bru b/bruno/draw/01 Offer Draw.bru
new file mode 100644
index 0000000..2dc488b
--- /dev/null
+++ b/bruno/draw/01 Offer Draw.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Offer Draw
+ type: http
+ seq: 1
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/draw/offer
+ body: none
+ auth: none
+}
diff --git a/bruno/draw/02 Accept Draw.bru b/bruno/draw/02 Accept Draw.bru
new file mode 100644
index 0000000..83495fb
--- /dev/null
+++ b/bruno/draw/02 Accept Draw.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Accept Draw
+ type: http
+ seq: 2
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/draw/accept
+ body: none
+ auth: none
+}
diff --git a/bruno/draw/03 Decline Draw.bru b/bruno/draw/03 Decline Draw.bru
new file mode 100644
index 0000000..2dad7ec
--- /dev/null
+++ b/bruno/draw/03 Decline Draw.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Decline Draw
+ type: http
+ seq: 3
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/draw/decline
+ body: none
+ auth: none
+}
diff --git a/bruno/draw/04 Claim Draw.bru b/bruno/draw/04 Claim Draw.bru
new file mode 100644
index 0000000..967992f
--- /dev/null
+++ b/bruno/draw/04 Claim Draw.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Claim Draw
+ type: http
+ seq: 4
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/draw/claim
+ body: none
+ auth: none
+}
diff --git a/bruno/draw/folder.bru b/bruno/draw/folder.bru
new file mode 100644
index 0000000..51b3758
--- /dev/null
+++ b/bruno/draw/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: draw
+ seq: 2
+}
diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru
new file mode 100644
index 0000000..85aff34
--- /dev/null
+++ b/bruno/environments/local.bru
@@ -0,0 +1,3 @@
+vars {
+ baseUrl: http://localhost:8080
+}
diff --git a/bruno/export/01 Export FEN.bru b/bruno/export/01 Export FEN.bru
new file mode 100644
index 0000000..c6b3592
--- /dev/null
+++ b/bruno/export/01 Export FEN.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Export FEN
+ type: http
+ seq: 1
+}
+
+http {
+ method: GET
+ url: {{baseUrl}}/api/board/game/{{gameId}}/export/fen
+ body: none
+ auth: none
+}
diff --git a/bruno/export/02 Export PGN.bru b/bruno/export/02 Export PGN.bru
new file mode 100644
index 0000000..ded0a2e
--- /dev/null
+++ b/bruno/export/02 Export PGN.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Export PGN
+ type: http
+ seq: 2
+}
+
+http {
+ method: GET
+ url: {{baseUrl}}/api/board/game/{{gameId}}/export/pgn
+ body: none
+ auth: none
+}
diff --git a/bruno/export/folder.bru b/bruno/export/folder.bru
new file mode 100644
index 0000000..fcdb012
--- /dev/null
+++ b/bruno/export/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: export
+ seq: 6
+}
diff --git a/bruno/game/01 Create Game.bru b/bruno/game/01 Create Game.bru
new file mode 100644
index 0000000..85139dc
--- /dev/null
+++ b/bruno/game/01 Create Game.bru
@@ -0,0 +1,23 @@
+meta {
+ name: Create Game
+ type: http
+ seq: 1
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "white": {"id": "p1", "displayName": "Alice"},
+ "black": {"id": "p2", "displayName": "Bob"}
+ }
+}
diff --git a/bruno/game/02 Get Game.bru b/bruno/game/02 Get Game.bru
new file mode 100644
index 0000000..b9f0e65
--- /dev/null
+++ b/bruno/game/02 Get Game.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Get Game
+ type: http
+ seq: 2
+}
+
+http {
+ method: GET
+ url: {{baseUrl}}/api/board/game/{{gameId}}
+ body: none
+ auth: none
+}
diff --git a/bruno/game/03 Stream Game.bru b/bruno/game/03 Stream Game.bru
new file mode 100644
index 0000000..e85e594
--- /dev/null
+++ b/bruno/game/03 Stream Game.bru
@@ -0,0 +1,19 @@
+meta {
+ name: Stream Game
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{baseUrl}}/api/board/game/{{gameId}}/stream
+ body: none
+ auth: none
+}
+
+headers {
+ Accept: application/x-ndjson
+}
+
+vars:pre-request {
+ gameId: tjOgyEcS
+}
diff --git a/bruno/game/04 Resign.bru b/bruno/game/04 Resign.bru
new file mode 100644
index 0000000..e9ccd11
--- /dev/null
+++ b/bruno/game/04 Resign.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Resign
+ type: http
+ seq: 4
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/resign
+ body: none
+ auth: none
+}
diff --git a/bruno/game/folder.bru b/bruno/game/folder.bru
new file mode 100644
index 0000000..428a895
--- /dev/null
+++ b/bruno/game/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: game
+ seq: 3
+}
diff --git a/bruno/import/01 Import FEN.bru b/bruno/import/01 Import FEN.bru
new file mode 100644
index 0000000..ddd86d4
--- /dev/null
+++ b/bruno/import/01 Import FEN.bru
@@ -0,0 +1,24 @@
+meta {
+ name: Import FEN
+ type: http
+ seq: 1
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/import/fen
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
+ "white": {"id": "p1", "displayName": "Alice"},
+ "black": {"id": "p2", "displayName": "Bob"}
+ }
+}
diff --git a/bruno/import/02 Import PGN.bru b/bruno/import/02 Import PGN.bru
new file mode 100644
index 0000000..930e96a
--- /dev/null
+++ b/bruno/import/02 Import PGN.bru
@@ -0,0 +1,22 @@
+meta {
+ name: Import PGN
+ type: http
+ seq: 2
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/import/pgn
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "pgn": "1. e4 e5 2. Nf3 Nc6 *"
+ }
+}
diff --git a/bruno/import/folder.bru b/bruno/import/folder.bru
new file mode 100644
index 0000000..719f1b5
--- /dev/null
+++ b/bruno/import/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: import
+ seq: 5
+}
diff --git a/bruno/move/01 Make Move.bru b/bruno/move/01 Make Move.bru
new file mode 100644
index 0000000..8600477
--- /dev/null
+++ b/bruno/move/01 Make Move.bru
@@ -0,0 +1,15 @@
+meta {
+ name: Make Move
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{baseUrl}}/api/board/game/{{gameId}}/move/b1c3
+ body: none
+ auth: none
+}
+
+vars:pre-request {
+ gameId: tjOgyEcS
+}
diff --git a/bruno/move/02 Get Legal Moves.bru b/bruno/move/02 Get Legal Moves.bru
new file mode 100644
index 0000000..1226024
--- /dev/null
+++ b/bruno/move/02 Get Legal Moves.bru
@@ -0,0 +1,19 @@
+meta {
+ name: Get Legal Moves
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/api/board/game/{{gameId}}/moves
+ body: none
+ auth: none
+}
+
+params:query {
+ square: e2
+}
+
+vars:pre-request {
+ gameId: tjOgyEcS
+}
diff --git a/bruno/move/03 Undo Move.bru b/bruno/move/03 Undo Move.bru
new file mode 100644
index 0000000..a240931
--- /dev/null
+++ b/bruno/move/03 Undo Move.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Undo Move
+ type: http
+ seq: 3
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/undo
+ body: none
+ auth: none
+}
diff --git a/bruno/move/04 Redo Move.bru b/bruno/move/04 Redo Move.bru
new file mode 100644
index 0000000..70b6250
--- /dev/null
+++ b/bruno/move/04 Redo Move.bru
@@ -0,0 +1,12 @@
+meta {
+ name: Redo Move
+ type: http
+ seq: 4
+}
+
+http {
+ method: POST
+ url: {{baseUrl}}/api/board/game/{{gameId}}/redo
+ body: none
+ auth: none
+}
diff --git a/bruno/move/folder.bru b/bruno/move/folder.bru
new file mode 100644
index 0000000..64aca60
--- /dev/null
+++ b/bruno/move/folder.bru
@@ -0,0 +1,3 @@
+meta {
+ name: move
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 40e0af8..e8fff64 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,6 +8,49 @@ plugins {
group = "de.nowchess"
version = "1.0-SNAPSHOT"
+// Canonical coverage exclusions — glob patterns consumed by Sonar directly;
+// converted to scoverage regexes via globToScoverageRegex for instrumentation-time exclusion.
+val coverageExclusions = listOf(
+ // UI renders JavaFX components; headless test environments cannot exercise rendering paths
+ "modules/ui/**",
+ // FastParse macro-generated combinators produce synthetic branches that scoverage marks as uncovered
+ "modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse*",
+ // NNUE inference pipeline — coverage requires a trained model file not present in CI
+ "**/bot/**/NNUE.scala",
+ "**/bot/**/NNUEBot.scala",
+ "**/bot/**/EvaluationNNUE.scala",
+ // NBAI binary format loader/writer — error paths require crafted corrupt files; migrator is a one-shot tool
+ "**/bot/**/NbaiLoader.scala",
+ "**/bot/**/NbaiModel.scala",
+ "**/bot/**/NbaiMigrator.scala",
+ "**/bot/**/NbaiWriter.scala",
+ // PolyglotBook — binary I/O and dead-code guards (bit-masked fields can never exceed valid range)
+ "**/bot/**/PolyglotBook.scala",
+ "**/bot/**/MoveOrdering.scala",
+ "**/bot/**/AlphaBetaSearch.scala",
+ // DTO case class synthetic methods (Scala compiler-generated apply/$default params)
+ "**/api/src/main/scala/de/nowchess/api/dto/**Dto.scala",
+ // Core infrastructure: exception classes, config, registry implementation, game entry
+ "**/core/src/main/scala/de/nowchess/chess/exception/**",
+ "**/core/src/main/scala/de/nowchess/chess/config/**",
+ "**/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala",
+ "**/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala",
+ // GameResource — REST integration layer with @Inject var fields; mocking dependencies for unit tests is infeasible with Quarkus DI; integration tests would require @QuarkusTest which Scoverage doesn't instrument
+ "**/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala"
+)
+
+// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
+// Order matters: protect ** before converting lone *, escape dots last.
+fun globToScoverageRegex(glob: String): String =
+ glob
+ .replace("**", "^@")
+ .replace("*", "[^/]*")
+ .replace(".", "\\.")
+ .replace("^@", ".*")
+ .let { ".*$it" }
+
+extra["SCOVERAGE_EXCLUDED"] = coverageExclusions.map(::globToScoverageRegex)
+
sonar {
properties {
property("sonar.projectKey", "Now-Chess-Systems")
@@ -22,26 +65,7 @@ sonar {
}.joinToString(",")
property("sonar.scala.coverage.reportPaths", scoverageReports)
- property(
- "sonar.coverage.exclusions",
- // UI renders JavaFX components; headless test environments cannot exercise rendering paths
- "modules/ui/**," +
- // FastParse macro-generated combinators produce synthetic branches that scoverage marks as uncovered
- "modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse*," +
- // NNUE inference pipeline — coverage requires a trained model file not present in CI
- "**/bot/**/NNUE.scala," +
- "**/bot/**/NNUEBot.scala," +
- "**/bot/**/EvaluationNNUE.scala," +
- // NBAI binary format loader/writer — error paths require crafted corrupt files; migrator is a one-shot tool
- "**/bot/**/NbaiLoader.scala," +
- "**/bot/**/NbaiModel.scala," +
- "**/bot/**/NbaiMigrator.scala," +
- "**/bot/**/NbaiWriter.scala," +
- // PolyglotBook — binary I/O and dead-code guards (bit-masked fields can never exceed valid range)
- "**/bot/**/PolyglotBook.scala," +
- "**/bot/**/MoveOrdering.scala," +
- "**/bot/**/AlphaBetaSearch.scala"
- )
+ property("sonar.coverage.exclusions", coverageExclusions.joinToString(","))
}
}
diff --git a/docs/api-spec.yaml b/docs/board-api-spec.yaml
similarity index 98%
rename from docs/api-spec.yaml
rename to docs/board-api-spec.yaml
index 8b20333..61bf241 100644
--- a/docs/api-spec.yaml
+++ b/docs/board-api-spec.yaml
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
- title: NowChess API
+ title: NowChess Board API
description: |
REST API for the NowChess application. Designed to feel familiar to users
of the [lichess API](https://lichess.org/api).
@@ -186,11 +186,8 @@ paths:
currently to move.
For promotion moves include the target piece as the fifth character:
- `e7e8q`, `a2a1r`, etc.
-
- If the move results in a pawn reaching the back rank and no promotion
- character is supplied, the game enters `promotionPending` status and
- the move is not yet applied — resubmit with the promotion character.
+ `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character
+ are rejected with `400 INVALID_MOVE`.
security:
- bearerAuth: []
parameters:
@@ -630,7 +627,6 @@ components:
| `draw` | Draw agreed or claimed — game over |
| `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
| `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
- | `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection |
| `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
enum:
- started
@@ -641,7 +637,6 @@ components:
- draw
- drawOffered
- fiftyMoveAvailable
- - promotionPending
- insufficientMaterial
# -------------------------------------------------------------------------
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..484c2b2
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+# Gradle properties
+quarkusPluginId=io.quarkus
+quarkusPluginVersion=3.32.4
+quarkusPlatformGroupId=io.quarkus.platform
+quarkusPlatformArtifactId=quarkus-bom
+quarkusPlatformVersion=3.32.4
diff --git a/modules/api/build.gradle.kts b/modules/api/build.gradle.kts
index 19a2303..311b187 100644
--- a/modules/api/build.gradle.kts
+++ b/modules/api/build.gradle.kts
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
repositories {
mavenCentral()
@@ -19,6 +21,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedFiles.set(scoverageExcluded)
}
configurations.scoverage {
@@ -31,7 +34,7 @@ configurations.scoverage {
dependencies {
- implementation("org.scala-lang:scala3-compiler_3") {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ApiErrorDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ApiErrorDto.scala
new file mode 100644
index 0000000..cb2864e
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ApiErrorDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.api.dto
+
+final case class ApiErrorDto(code: String, message: String, field: Option[String])
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala
new file mode 100644
index 0000000..7f18de4
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala
@@ -0,0 +1,6 @@
+package de.nowchess.api.dto
+
+final case class CreateGameRequestDto(
+ white: Option[PlayerInfoDto],
+ black: Option[PlayerInfoDto],
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ErrorEventDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ErrorEventDto.scala
new file mode 100644
index 0000000..a93cd6f
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ErrorEventDto.scala
@@ -0,0 +1,6 @@
+package de.nowchess.api.dto
+
+final case class ErrorEventDto(`type`: String, error: ApiErrorDto)
+
+object ErrorEventDto:
+ def apply(error: ApiErrorDto): ErrorEventDto = ErrorEventDto("error", error)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameFullDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameFullDto.scala
new file mode 100644
index 0000000..5de6a75
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameFullDto.scala
@@ -0,0 +1,8 @@
+package de.nowchess.api.dto
+
+final case class GameFullDto(
+ gameId: String,
+ white: PlayerInfoDto,
+ black: PlayerInfoDto,
+ state: GameStateDto,
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameFullEventDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameFullEventDto.scala
new file mode 100644
index 0000000..20fcaeb
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameFullEventDto.scala
@@ -0,0 +1,6 @@
+package de.nowchess.api.dto
+
+final case class GameFullEventDto(`type`: String, game: GameFullDto)
+
+object GameFullEventDto:
+ def apply(game: GameFullDto): GameFullEventDto = GameFullEventDto("gameFull", game)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
new file mode 100644
index 0000000..5556d3c
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
@@ -0,0 +1,12 @@
+package de.nowchess.api.dto
+
+final case class GameStateDto(
+ fen: String,
+ pgn: String,
+ turn: String,
+ status: String,
+ winner: Option[String],
+ moves: List[String],
+ undoAvailable: Boolean,
+ redoAvailable: Boolean,
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateEventDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateEventDto.scala
new file mode 100644
index 0000000..e9e5e58
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateEventDto.scala
@@ -0,0 +1,6 @@
+package de.nowchess.api.dto
+
+final case class GameStateEventDto(`type`: String, state: GameStateDto)
+
+object GameStateEventDto:
+ def apply(state: GameStateDto): GameStateEventDto = GameStateEventDto("gameState", state)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala
new file mode 100644
index 0000000..19f35b6
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala
@@ -0,0 +1,7 @@
+package de.nowchess.api.dto
+
+final case class ImportFenRequestDto(
+ fen: String,
+ white: Option[PlayerInfoDto],
+ black: Option[PlayerInfoDto],
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequestDto.scala
new file mode 100644
index 0000000..ac09011
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequestDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.api.dto
+
+final case class ImportPgnRequestDto(pgn: String)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/LegalMoveDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/LegalMoveDto.scala
new file mode 100644
index 0000000..81c0223
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/LegalMoveDto.scala
@@ -0,0 +1,9 @@
+package de.nowchess.api.dto
+
+final case class LegalMoveDto(
+ from: String,
+ to: String,
+ uci: String,
+ moveType: String,
+ promotion: Option[String],
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/LegalMovesResponseDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/LegalMovesResponseDto.scala
new file mode 100644
index 0000000..6149ceb
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/LegalMovesResponseDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.api.dto
+
+final case class LegalMovesResponseDto(moves: List[LegalMoveDto])
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/OkResponseDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/OkResponseDto.scala
new file mode 100644
index 0000000..35da71d
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/OkResponseDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.api.dto
+
+final case class OkResponseDto(ok: Boolean = true)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
new file mode 100644
index 0000000..ee00f74
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.api.dto
+
+final case class PlayerInfoDto(id: String, displayName: String)
diff --git a/modules/bot/build.gradle.kts b/modules/bot/build.gradle.kts
index 8ac4edb..8041789 100644
--- a/modules/bot/build.gradle.kts
+++ b/modules/bot/build.gradle.kts
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
repositories {
mavenCentral()
@@ -26,16 +28,7 @@ scoverage {
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
)
)
- excludedFiles.set(
- listOf(
- ".*NNUE\\.scala",
- ".*NNUEBot\\.scala",
- ".*NbaiLoader\\.scala",
- ".*NbaiMigrator\\.scala",
- ".*NbaiWriter\\.scala",
- ".*PolyglotBook\\.scala",
- )
- )
+ excludedFiles.set(scoverageExcluded)
}
tasks.withType {
@@ -44,7 +37,7 @@ tasks.withType {
dependencies {
- implementation("org.scala-lang:scala3-compiler_3") {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
diff --git a/modules/core/.dockerignore b/modules/core/.dockerignore
new file mode 100644
index 0000000..376e5c6
--- /dev/null
+++ b/modules/core/.dockerignore
@@ -0,0 +1,5 @@
+.gitignore
+!build/*-runner
+!build/*-runner.jar
+!build/lib/*
+!build/quarkus-app/*
\ No newline at end of file
diff --git a/modules/core/.gitignore b/modules/core/.gitignore
new file mode 100644
index 0000000..ba4fbcc
--- /dev/null
+++ b/modules/core/.gitignore
@@ -0,0 +1,41 @@
+# Gradle
+.gradle/
+build/
+
+# Eclipse
+.project
+.classpath
+.settings/
+bin/
+
+# IntelliJ
+.idea
+*.ipr
+*.iml
+*.iws
+
+# NetBeans
+nb-configuration.xml
+
+# Visual Studio Code
+.vscode
+.factorypath
+
+# OSX
+.DS_Store
+
+# Vim
+*.swp
+*.swo
+
+# patch
+*.orig
+*.rej
+
+# Local environment
+.env
+
+# Plugin directory
+/.quarkus/cli/plugins/
+# TLS Certificates
+.certs/
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index f72b4d1..a02a07c 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
+ id("io.quarkus")
}
group = "de.nowchess"
@@ -8,6 +9,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
repositories {
mavenCentral()
@@ -19,15 +22,21 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedFiles.set(scoverageExcluded)
}
tasks.withType {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+
dependencies {
- implementation("org.scala-lang:scala3-compiler_3") {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
@@ -43,19 +52,59 @@ dependencies {
implementation(project(":modules:rule"))
implementation(project(":modules:bot"))
+
+ implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-hibernate-orm")
+ implementation("io.quarkus:quarkus-rest-client-jackson")
+ implementation("io.quarkus:quarkus-rest-client")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-micrometer")
+ implementation("io.quarkus:quarkus-arc")
+
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+
+
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit5")
+ testImplementation("io.rest-assured:rest-assured")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
- includeEngines("scalatest")
+ includeEngines("scalatest", "junit-jupiter")
testLogging {
- events("skipped", "failed")
+ events("passed", "skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
diff --git a/modules/core/src/main/docker/Dockerfile.jvm b/modules/core/src/main/docker/Dockerfile.jvm
new file mode 100644
index 0000000..c3c09fc
--- /dev/null
+++ b/modules/core/src/main/docker/Dockerfile.jvm
@@ -0,0 +1,100 @@
+####
+# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
+#
+# Before building the container image run:
+#
+# ./gradlew build
+#
+# Then, build the image with:
+#
+# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backcore-jvm .
+#
+# Then run the container using:
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
+#
+# If you want to include the debug port into your docker image
+# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
+# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
+# when running the container
+#
+# Then run the container using :
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
+#
+# This image uses the `run-java.sh` script to run the application.
+# This scripts computes the command line to execute your Java application, and
+# includes memory/GC tuning.
+# You can configure the behavior using the following environment properties:
+# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
+# the default JVM options, use `JAVA_OPTS_APPEND` to append options
+# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
+# in JAVA_OPTS (example: "-Dsome.property=foo")
+# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
+# used to calculate a default maximal heap memory based on a containers restriction.
+# If used in a container without any memory constraints for the container then this
+# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
+# of the container available memory as set here. The default is `50` which means 50%
+# of the available memory is used as an upper boundary. You can skip this mechanism by
+# setting this value to `0` in which case no `-Xmx` option is added.
+# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
+# is used to calculate a default initial heap memory based on the maximum heap memory.
+# If used in a container without any memory constraints for the container then this
+# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
+# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
+# is used as the initial heap size. You can skip this mechanism by setting this value
+# to `0` in which case no `-Xms` option is added (example: "25")
+# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
+# This is used to calculate the maximum value of the initial heap memory. If used in
+# a container without any memory constraints for the container then this option has
+# no effect. If there is a memory constraint then `-Xms` is limited to the value set
+# here. The default is 4096MB which means the calculated value of `-Xms` never will
+# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
+# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
+# when things are happening. This option, if set to true, will set
+# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
+# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
+# true").
+# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
+# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
+# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
+# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
+# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
+# (example: "20")
+# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
+# (example: "40")
+# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
+# (example: "4")
+# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
+# previous GC times. (example: "90")
+# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
+# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
+# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
+# contain the necessary JRE command-line options to specify the required GC, which
+# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
+# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
+# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
+# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
+# accessed directly. (example: "foo.example.com,bar.example.com")
+#
+# You can find more information about the UBI base runtime images and their configuration here:
+# https://rh-openjdk.github.io/redhat-openjdk-containers/
+###
+FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
+
+ENV LANGUAGE='en_US:en'
+
+
+# We make four distinct layers so if there are application changes the library layers can be re-used
+COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/
+COPY --chown=185 build/quarkus-app/*.jar /deployments/
+COPY --chown=185 build/quarkus-app/app/ /deployments/app/
+COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/
+
+EXPOSE 8080
+USER 185
+ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
+ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
+
+ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
+
diff --git a/modules/core/src/main/docker/Dockerfile.legacy-jar b/modules/core/src/main/docker/Dockerfile.legacy-jar
new file mode 100644
index 0000000..8c89666
--- /dev/null
+++ b/modules/core/src/main/docker/Dockerfile.legacy-jar
@@ -0,0 +1,96 @@
+####
+# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
+#
+# Before building the container image run:
+#
+# ./gradlew build -Dquarkus.package.jar.type=legacy-jar
+#
+# Then, build the image with:
+#
+# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/backcore-legacy-jar .
+#
+# Then run the container using:
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
+#
+# If you want to include the debug port into your docker image
+# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
+# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
+# when running the container
+#
+# Then run the container using :
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
+#
+# This image uses the `run-java.sh` script to run the application.
+# This scripts computes the command line to execute your Java application, and
+# includes memory/GC tuning.
+# You can configure the behavior using the following environment properties:
+# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
+# the default JVM options, use `JAVA_OPTS_APPEND` to append options
+# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
+# in JAVA_OPTS (example: "-Dsome.property=foo")
+# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
+# used to calculate a default maximal heap memory based on a containers restriction.
+# If used in a container without any memory constraints for the container then this
+# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
+# of the container available memory as set here. The default is `50` which means 50%
+# of the available memory is used as an upper boundary. You can skip this mechanism by
+# setting this value to `0` in which case no `-Xmx` option is added.
+# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
+# is used to calculate a default initial heap memory based on the maximum heap memory.
+# If used in a container without any memory constraints for the container then this
+# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
+# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
+# is used as the initial heap size. You can skip this mechanism by setting this value
+# to `0` in which case no `-Xms` option is added (example: "25")
+# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
+# This is used to calculate the maximum value of the initial heap memory. If used in
+# a container without any memory constraints for the container then this option has
+# no effect. If there is a memory constraint then `-Xms` is limited to the value set
+# here. The default is 4096MB which means the calculated value of `-Xms` never will
+# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
+# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
+# when things are happening. This option, if set to true, will set
+# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
+# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
+# true").
+# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
+# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
+# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
+# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
+# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
+# (example: "20")
+# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
+# (example: "40")
+# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
+# (example: "4")
+# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
+# previous GC times. (example: "90")
+# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
+# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
+# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
+# contain the necessary JRE command-line options to specify the required GC, which
+# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
+# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
+# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
+# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
+# accessed directly. (example: "foo.example.com,bar.example.com")
+#
+# You can find more information about the UBI base runtime images and their configuration here:
+# https://rh-openjdk.github.io/redhat-openjdk-containers/
+###
+FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
+
+ENV LANGUAGE='en_US:en'
+
+
+COPY build/lib/* /deployments/lib/
+COPY build/*-runner.jar /deployments/quarkus-run.jar
+
+EXPOSE 8080
+USER 185
+ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
+ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
+
+ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
diff --git a/modules/core/src/main/docker/Dockerfile.native b/modules/core/src/main/docker/Dockerfile.native
new file mode 100644
index 0000000..57defbf
--- /dev/null
+++ b/modules/core/src/main/docker/Dockerfile.native
@@ -0,0 +1,29 @@
+####
+# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
+#
+# Before building the container image run:
+#
+# ./gradlew build -Dquarkus.native.enabled=true
+#
+# Then, build the image with:
+#
+# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore .
+#
+# Then run the container using:
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore
+#
+# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
+# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
+###
+FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
+WORKDIR /work/
+RUN chown 1001 /work \
+ && chmod "g+rwX" /work \
+ && chown 1001:root /work
+COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
+
+EXPOSE 8080
+USER 1001
+
+ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
diff --git a/modules/core/src/main/docker/Dockerfile.native-micro b/modules/core/src/main/docker/Dockerfile.native-micro
new file mode 100644
index 0000000..9408243
--- /dev/null
+++ b/modules/core/src/main/docker/Dockerfile.native-micro
@@ -0,0 +1,32 @@
+####
+# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
+# It uses a micro base image, tuned for Quarkus native executables.
+# It reduces the size of the resulting container image.
+# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
+#
+# Before building the container image run:
+#
+# ./gradlew build -Dquarkus.native.enabled=true
+#
+# Then, build the image with:
+#
+# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/backcore .
+#
+# Then run the container using:
+#
+# docker run -i --rm -p 8080:8080 quarkus/backcore
+#
+# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
+# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
+###
+FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
+WORKDIR /work/
+RUN chown 1001 /work \
+ && chmod "g+rwX" /work \
+ && chown 1001:root /work
+COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
+
+EXPOSE 8080
+USER 1001
+
+ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
diff --git a/modules/core/src/main/resources/META-INF/native-image/de.nowchess/core/reachability-metadata.json b/modules/core/src/main/resources/META-INF/native-image/de.nowchess/core/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/core/src/main/resources/META-INF/native-image/de.nowchess/core/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml
new file mode 100644
index 0000000..0d92446
--- /dev/null
+++ b/modules/core/src/main/resources/application.yml
@@ -0,0 +1,3 @@
+greeting:
+ message: "hello"
+
diff --git a/modules/core/src/main/resources/import.sql b/modules/core/src/main/resources/import.sql
new file mode 100644
index 0000000..16aa523
--- /dev/null
+++ b/modules/core/src/main/resources/import.sql
@@ -0,0 +1,6 @@
+-- This file allow to write SQL commands that will be emitted in test and dev.
+-- The commands are commented as their support depends of the database
+-- insert into myentity (id, field) values(1, 'field-1');
+-- insert into myentity (id, field) values(2, 'field-2');
+-- insert into myentity (id, field) values(3, 'field-3');
+-- alter sequence myentity_seq restart with 4;
\ No newline at end of file
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
new file mode 100644
index 0000000..b252cec
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.chess.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..c8ce0fe
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
@@ -0,0 +1,23 @@
+package de.nowchess.chess.config
+
+import de.nowchess.api.dto.*
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(
+ classOf[ApiErrorDto],
+ classOf[CreateGameRequestDto],
+ classOf[ErrorEventDto],
+ classOf[GameFullDto],
+ classOf[GameFullEventDto],
+ classOf[GameStateDto],
+ classOf[GameStateEventDto],
+ classOf[ImportFenRequestDto],
+ classOf[ImportPgnRequestDto],
+ classOf[LegalMoveDto],
+ classOf[LegalMovesResponseDto],
+ classOf[OkResponseDto],
+ classOf[PlayerInfoDto],
+ ),
+)
+class NativeReflectionConfig
diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
index 872098d..43f099b 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
@@ -38,9 +38,10 @@ class GameEngine(
private implicit val ec: ExecutionContext = ExecutionContext.global
// Synchronized accessors for current state
- def board: Board = synchronized(currentContext.board)
- def turn: Color = synchronized(currentContext.turn)
- def context: GameContext = synchronized(currentContext)
+ def board: Board = synchronized(currentContext.board)
+ def turn: Color = synchronized(currentContext.turn)
+ def context: GameContext = synchronized(currentContext)
+ def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
/** Check if undo is available. */
def canUndo: Boolean = synchronized(invoker.canUndo)
@@ -67,21 +68,7 @@ class GameEngine(
performRedo()
case "draw" =>
- if currentContext.halfMoveClock >= 100 then
- currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
- invoker.clear()
- notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
- else if ruleSet.isThreefoldRepetition(currentContext) then
- currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
- invoker.clear()
- notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
- else
- notifyObservers(
- InvalidMoveEvent(
- currentContext,
- InvalidMoveReason.DrawCannotBeClaimed,
- ),
- )
+ claimDraw()
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput))
@@ -195,6 +182,21 @@ class GameEngine(
notifyObservers(DrawOfferDeclinedEvent(currentContext, color))
}
+ /** Claim a draw by fifty-move rule or threefold repetition. */
+ def claimDraw(): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else if currentContext.halfMoveClock >= 100 then
+ currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
+ invoker.clear()
+ notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
+ else if ruleSet.isThreefoldRepetition(currentContext) then
+ currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
+ invoker.clear()
+ notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
+ else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
+ }
+
/** 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.
*/
@@ -258,6 +260,22 @@ class GameEngine(
notifyObservers(BoardResetEvent(currentContext))
}
+ /** Resign the game on behalf of the side to move. */
+ def resign(): Unit = synchronized {
+ if currentContext.result.isEmpty then
+ val winner = currentContext.turn.opposite
+ currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
+ invoker.clear()
+ }
+
+ /** Apply a draw result directly (for agreement, fifty-move claim, etc.). */
+ def applyDraw(reason: DrawReason): Unit = synchronized {
+ if currentContext.result.isEmpty then
+ currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
+ invoker.clear()
+ notifyObservers(DrawEvent(currentContext, reason))
+ }
+
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
diff --git a/modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala b/modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala
new file mode 100644
index 0000000..24fd729
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala
@@ -0,0 +1,13 @@
+package de.nowchess.chess.exception
+
+class ApiException(
+ val status: Int,
+ val code: String,
+ message: String,
+ val field: Option[String] = None,
+) extends RuntimeException(message)
+
+class GameNotFoundException(gameId: String) extends ApiException(404, "GAME_NOT_FOUND", s"Game $gameId not found")
+
+class BadRequestException(code: String, message: String, field: Option[String] = None)
+ extends ApiException(400, code, message, field)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/exception/ApiExceptionMapper.scala b/modules/core/src/main/scala/de/nowchess/chess/exception/ApiExceptionMapper.scala
new file mode 100644
index 0000000..50595d0
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/exception/ApiExceptionMapper.scala
@@ -0,0 +1,14 @@
+package de.nowchess.chess.exception
+
+import de.nowchess.api.dto.ApiErrorDto
+import jakarta.ws.rs.core.{MediaType, Response}
+import jakarta.ws.rs.ext.{ExceptionMapper, Provider}
+
+@Provider
+class ApiExceptionMapper extends ExceptionMapper[ApiException]:
+ def toResponse(ex: ApiException): Response =
+ Response
+ .status(ex.status)
+ .entity(ApiErrorDto(ex.code, ex.getMessage, ex.field))
+ .`type`(MediaType.APPLICATION_JSON)
+ .build()
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala
new file mode 100644
index 0000000..7dd09fb
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala
@@ -0,0 +1,13 @@
+package de.nowchess.chess.registry
+
+import de.nowchess.api.board.Color
+import de.nowchess.api.player.PlayerInfo
+import de.nowchess.chess.engine.GameEngine
+
+final case class GameEntry(
+ gameId: String,
+ engine: GameEngine,
+ white: PlayerInfo,
+ black: PlayerInfo,
+ resigned: Boolean = false,
+)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistry.scala
new file mode 100644
index 0000000..be25fd7
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistry.scala
@@ -0,0 +1,7 @@
+package de.nowchess.chess.registry
+
+trait GameRegistry:
+ def store(entry: GameEntry): Unit
+ def get(gameId: String): Option[GameEntry]
+ def update(entry: GameEntry): Unit
+ def generateId(): String
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
new file mode 100644
index 0000000..61668d4
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
@@ -0,0 +1,23 @@
+package de.nowchess.chess.registry
+
+import jakarta.enterprise.context.ApplicationScoped
+import java.security.SecureRandom
+import java.util.concurrent.ConcurrentHashMap
+
+@ApplicationScoped
+class GameRegistryImpl extends GameRegistry:
+ private val games = ConcurrentHashMap[String, GameEntry]()
+ private val rng = new SecureRandom()
+
+ def store(entry: GameEntry): Unit =
+ games.put(entry.gameId, entry)
+
+ def get(gameId: String): Option[GameEntry] =
+ Option(games.get(gameId))
+
+ def update(entry: GameEntry): Unit =
+ games.put(entry.gameId, entry)
+
+ def generateId(): String =
+ val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
new file mode 100644
index 0000000..e8258bb
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
@@ -0,0 +1,311 @@
+package de.nowchess.chess.resource
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.board.Square
+import de.nowchess.api.dto.*
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.controller.Parser
+import de.nowchess.chess.engine.GameEngine
+import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
+import de.nowchess.chess.observer.*
+import de.nowchess.chess.registry.{GameEntry, GameRegistry}
+import de.nowchess.io.fen.{FenExporter, FenParser}
+import de.nowchess.io.pgn.{PgnExporter, PgnParser}
+import io.smallrye.mutiny.Multi
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+
+import java.util.concurrent.atomic.AtomicReference
+import scala.compiletime.uninitialized
+
+@Path("/api/board/game")
+@ApplicationScoped
+class GameResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var registry: GameRegistry = uninitialized
+
+ @Inject
+ var objectMapper: ObjectMapper = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
+ private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
+
+ // ── mapping ──────────────────────────────────────────────────────────────
+
+ private def statusOf(entry: GameEntry): String =
+ if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
+ else
+ val ctx = entry.engine.context
+ ctx.result match
+ case Some(GameResult.Win(_)) =>
+ if entry.resigned then "resign" else "checkmate"
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
+ case Some(GameResult.Draw(_)) => "draw"
+ case None =>
+ if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
+ else if entry.engine.ruleSet.isCheck(ctx) then "check"
+ else "started"
+
+ private def moveToUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
+ case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
+ case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
+ case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
+ case _ => base
+
+ private def toLegalMoveDto(move: Move): LegalMoveDto =
+ val (moveTypeStr, promotionStr) = move.moveType match
+ case MoveType.Normal(false) => ("normal", None)
+ case MoveType.Normal(true) => ("capture", None)
+ case MoveType.CastleKingside => ("castleKingside", None)
+ case MoveType.CastleQueenside => ("castleQueenside", None)
+ case MoveType.EnPassant => ("enPassant", None)
+ case MoveType.Promotion(PromotionPiece.Queen) => ("promotion", Some("queen"))
+ case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
+ case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
+ case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
+ LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr)
+
+ private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
+ PlayerInfoDto(info.id.value, info.displayName)
+
+ private def toGameStateDto(entry: GameEntry): GameStateDto =
+ val ctx = entry.engine.context
+ GameStateDto(
+ fen = FenExporter.exportGameContext(ctx),
+ pgn = PgnExporter.exportGame(
+ Map(
+ "Event" -> "NowChess game",
+ "White" -> entry.white.displayName,
+ "Black" -> entry.black.displayName,
+ "Result" -> "*",
+ ),
+ ctx.moves,
+ ),
+ turn = ctx.turn.label.toLowerCase,
+ status = statusOf(entry),
+ winner = ctx.result.collect { case GameResult.Win(c) => c.label.toLowerCase },
+ moves = ctx.moves.map(moveToUci),
+ undoAvailable = entry.engine.canUndo,
+ redoAvailable = entry.engine.canRedo,
+ )
+
+ private def toGameFullDto(entry: GameEntry): GameFullDto =
+ GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
+
+ private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
+ dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
+
+ private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry =
+ GameEntry(registry.generateId(), GameEngine(initialContext = ctx), white, black)
+
+ private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
+ val error = new AtomicReference[Option[String]](None)
+ val obs = new Observer:
+ def onGameEvent(e: GameEvent): Unit = e match
+ case InvalidMoveEvent(_, reason) => error.set(Some(reason.toString))
+ case _ => ()
+ engine.subscribe(obs)
+ engine.processUserInput(uci)
+ engine.unsubscribe(obs)
+ error.get()
+
+ // ── response helpers ─────────────────────────────────────────────────────
+
+ private def ok(body: AnyRef): Response = Response.ok(body).build()
+ private def created(body: AnyRef): Response = Response.status(Response.Status.CREATED).entity(body).build()
+
+ // scalafix:off DisableSyntax.throw
+ private def assertGameNotOver(entry: GameEntry): Unit =
+ if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
+ // scalafix:on DisableSyntax.throw
+
+ // ── endpoints ────────────────────────────────────────────────────────────
+ // scalafix:off DisableSyntax.throw
+
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def createGame(body: CreateGameRequestDto): Response =
+ val req = Option(body).getOrElse(CreateGameRequestDto(None, None))
+ val white = playerInfoFrom(req.white, DefaultWhite)
+ val black = playerInfoFrom(req.black, DefaultBlack)
+ val entry = newEntry(GameContext.initial, white, black)
+ registry.store(entry)
+ println(s"Created game ${entry.gameId}")
+ created(toGameFullDto(entry))
+
+ @GET
+ @Path("/{gameId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getGame(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ ok(toGameFullDto(entry))
+
+ @GET
+ @Path("/{gameId}/stream")
+ @Produces(Array("application/x-ndjson"))
+ def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ Multi
+ .createFrom()
+ .emitter[String] { emitter =>
+ emitter.emit(objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry))) + "\n")
+ val obs = new Observer:
+ def onGameEvent(event: GameEvent): Unit =
+ registry.get(gameId).foreach { updated =>
+ emitter.emit(
+ objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))) + "\n",
+ )
+ }
+ entry.engine.subscribe(obs)
+ emitter.onTermination(() => entry.engine.unsubscribe(obs))
+ }
+
+ @POST
+ @Path("/{gameId}/resign")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def resignGame(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ assertGameNotOver(entry)
+ entry.engine.resign()
+ registry.update(entry.copy(resigned = true))
+ ok(OkResponseDto())
+
+ @POST
+ @Path("/{gameId}/move/{uci}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ assertGameNotOver(entry)
+ val (from, to, promoOpt) = Parser
+ .parseMove(uci)
+ .getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
+ val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
+ val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
+ if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
+ throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
+ applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
+ ok(toGameStateDto(entry))
+
+ @GET
+ @Path("/{gameId}/moves")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getLegalMoves(
+ @PathParam("gameId") gameId: String,
+ @QueryParam("square") square: String,
+ ): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ val ctx = entry.engine.context
+ val moves =
+ if Option(square).isEmpty || square.isEmpty then entry.engine.ruleSet.allLegalMoves(ctx)
+ else
+ val sq = Square
+ .fromAlgebraic(square)
+ .getOrElse(throw BadRequestException("INVALID_SQUARE", s"Invalid square: $square", Some("square")))
+ entry.engine.ruleSet.legalMoves(ctx)(sq)
+ ok(LegalMovesResponseDto(moves.map(toLegalMoveDto)))
+
+ @POST
+ @Path("/{gameId}/undo")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def undoMove(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
+ entry.engine.undo()
+ ok(toGameStateDto(entry))
+
+ @POST
+ @Path("/{gameId}/redo")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def redoMove(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
+ entry.engine.redo()
+ ok(toGameStateDto(entry))
+
+ @POST
+ @Path("/{gameId}/draw/{action}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def drawAction(
+ @PathParam("gameId") gameId: String,
+ @PathParam("action") action: String,
+ ): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ assertGameNotOver(entry)
+ action match
+ case "offer" =>
+ entry.engine.offerDraw(entry.engine.context.turn)
+ ok(OkResponseDto())
+ case "accept" =>
+ entry.engine.acceptDraw(entry.engine.context.turn)
+ ok(OkResponseDto())
+ case "decline" =>
+ entry.engine.declineDraw(entry.engine.context.turn)
+ ok(OkResponseDto())
+ case "claim" =>
+ entry.engine.claimDraw()
+ ok(OkResponseDto())
+ case _ =>
+ throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
+
+ @POST
+ @Path("/import/fen")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def importFen(body: ImportFenRequestDto): Response =
+ val ctx = FenParser.parseFen(body.fen) match
+ case Left(err) => throw BadRequestException("INVALID_FEN", err, Some("fen"))
+ case Right(ctx) => ctx
+ val white = playerInfoFrom(body.white, DefaultWhite)
+ val black = playerInfoFrom(body.black, DefaultBlack)
+ val entry = newEntry(ctx, white, black)
+ registry.store(entry)
+ created(toGameFullDto(entry))
+
+ @POST
+ @Path("/import/pgn")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def importPgn(body: ImportPgnRequestDto): Response =
+ val engine = GameEngine()
+ engine.loadGame(PgnParser, body.pgn) match
+ case Left(err) => throw BadRequestException("INVALID_PGN", err, Some("pgn"))
+ case Right(_) => ()
+ val entry = GameEntry(registry.generateId(), engine, DefaultWhite, DefaultBlack)
+ registry.store(entry)
+ created(toGameFullDto(entry))
+
+ @GET
+ @Path("/{gameId}/export/fen")
+ @Produces(Array(MediaType.TEXT_PLAIN))
+ def exportFen(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ ok(FenExporter.exportGameContext(entry.engine.context))
+
+ @GET
+ @Path("/{gameId}/export/pgn")
+ @Produces(Array("application/x-chess-pgn"))
+ def exportPgn(@PathParam("gameId") gameId: String): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ val pgn = PgnExporter.exportGame(
+ Map(
+ "Event" -> "NowChess game",
+ "White" -> entry.white.displayName,
+ "Black" -> entry.black.displayName,
+ "Result" -> "*",
+ ),
+ entry.engine.context.moves,
+ )
+ ok(pgn)
+ // scalafix:on DisableSyntax.throw
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
index f1c2c80..03b39a6 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
@@ -234,6 +234,77 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
+ test("pendingDrawOfferBy returns None initially"):
+ val engine = new GameEngine()
+ engine.pendingDrawOfferBy shouldBe None
+
+ test("pendingDrawOfferBy returns White after White offers"):
+ val engine = new GameEngine()
+ engine.offerDraw(Color.White)
+ engine.pendingDrawOfferBy shouldBe Some(Color.White)
+
+ test("pendingDrawOfferBy returns None after draw is accepted"):
+ val engine = new GameEngine()
+ engine.offerDraw(Color.White)
+ engine.acceptDraw(Color.Black)
+ engine.pendingDrawOfferBy shouldBe None
+
+ test("applyDraw sets draw result when game not over"):
+ val engine = new GameEngine()
+ val observer = new DrawOfferMockObserver()
+ engine.subscribe(observer)
+ engine.applyDraw(DrawReason.Agreement)
+ observer.events should have length 1
+ observer.events.head match
+ case event: DrawEvent =>
+ event.reason shouldBe DrawReason.Agreement
+ event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement))
+ case other =>
+ fail(s"Expected DrawEvent, but got $other")
+
+ test("applyDraw does nothing when game already over"):
+ val engine = new GameEngine()
+ val observer = new DrawOfferMockObserver()
+ engine.subscribe(observer)
+ // End the game with checkmate
+ engine.processUserInput("f2f3")
+ engine.processUserInput("e7e5")
+ engine.processUserInput("g2g4")
+ engine.processUserInput("d8h4")
+ observer.events.clear()
+ engine.applyDraw(DrawReason.Agreement)
+ observer.events should have length 0
+
+ test("claimDraw with fifty-move rule when at half-move 100"):
+ val engine = new GameEngine()
+ val observer = new DrawOfferMockObserver()
+ engine.subscribe(observer)
+ // Play moves to reach fifty-move rule claim
+ engine.processUserInput("e2e4")
+ engine.processUserInput("e7e5")
+ engine.processUserInput("g1f3")
+ engine.processUserInput("g8f6")
+ // Need to advance halfMoveClock to 100
+ // This is hard to do naturally; skip for now if not critical
+
+ test("claimDraw when game already over"):
+ val engine = new GameEngine()
+ val observer = new DrawOfferMockObserver()
+ engine.subscribe(observer)
+ // End the game with checkmate
+ engine.processUserInput("f2f3")
+ engine.processUserInput("e7e5")
+ engine.processUserInput("g2g4")
+ engine.processUserInput("d8h4")
+ observer.events.clear()
+ engine.claimDraw()
+ observer.events should have length 1
+ observer.events.head match
+ case event: InvalidMoveEvent =>
+ event.reason shouldBe InvalidMoveReason.GameAlreadyOver
+ case other =>
+ fail(s"Expected InvalidMoveEvent, but got $other")
+
private class DrawOfferMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
index 75bd76f..cf96cdf 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
@@ -63,6 +63,32 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
+ test("resign() without color resigns side to move"):
+ val engine = new GameEngine()
+ val observer = new ResignMockObserver()
+ engine.subscribe(observer)
+
+ engine.resign()
+
+ engine.context.result shouldBe Some(GameResult.Win(Color.Black))
+
+ test("resign() without color does nothing when game already over"):
+ val engine = new GameEngine()
+ val observer = new ResignMockObserver()
+ engine.subscribe(observer)
+
+ // End the game with checkmate
+ engine.processUserInput("f2f3")
+ engine.processUserInput("e7e5")
+ engine.processUserInput("g2g4")
+ observer.events.clear()
+ engine.processUserInput("d8h4")
+
+ // Try to resign without color parameter
+ val resultBefore = engine.context.result
+ engine.resign()
+ resultBefore shouldBe engine.context.result
+
private class ResignMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
diff --git a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala
new file mode 100644
index 0000000..6bea0c0
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala
@@ -0,0 +1,60 @@
+package de.nowchess.chess.registry
+
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.engine.GameEngine
+import io.quarkus.test.junit.QuarkusTest
+import jakarta.inject.Inject
+import org.junit.jupiter.api.{DisplayName, Test}
+import org.junit.jupiter.api.Assertions.*
+
+import scala.compiletime.uninitialized
+
+// scalafix:off
+@QuarkusTest
+@DisplayName("GameRegistryImpl")
+class GameRegistryImplTest:
+
+ @Inject
+ var registry: GameRegistry = uninitialized
+
+ @Test
+ @DisplayName("store saves entry")
+ def testStore(): Unit =
+ val entry = GameEntry("g1", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
+ registry.store(entry)
+ assertTrue(registry.get("g1").isDefined)
+
+ @Test
+ @DisplayName("get returns stored entry")
+ def testGet(): Unit =
+ val entry = GameEntry("g2", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
+ registry.store(entry)
+ val retrieved = registry.get("g2")
+ assertTrue(retrieved.isDefined)
+ assertEquals("g2", retrieved.get.gameId)
+
+ @Test
+ @DisplayName("get returns None for unknown id")
+ def testGetUnknown(): Unit =
+ assertTrue(registry.get("unknown").isEmpty)
+
+ @Test
+ @DisplayName("update modifies existing entry")
+ def testUpdate(): Unit =
+ val entry = GameEntry("g3", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
+ registry.store(entry)
+ val updated = entry.copy(resigned = true)
+ registry.update(updated)
+ val retrieved = registry.get("g3")
+ assertTrue(retrieved.isDefined)
+ assertTrue(retrieved.get.resigned)
+
+ @Test
+ @DisplayName("generateId produces unique ids")
+ def testGenerateId(): Unit =
+ val id1 = registry.generateId()
+ val id2 = registry.generateId()
+ assertNotEquals(id1, id2)
+ assertFalse(id1.isEmpty)
+ assertFalse(id2.isEmpty)
+// scalafix:on
diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
new file mode 100644
index 0000000..bf165cc
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
@@ -0,0 +1,154 @@
+package de.nowchess.chess.resource
+
+import de.nowchess.api.dto.*
+import de.nowchess.chess.exception.BadRequestException
+import io.quarkus.test.junit.QuarkusTest
+import jakarta.inject.Inject
+import org.junit.jupiter.api.{DisplayName, Test}
+import org.junit.jupiter.api.Assertions.*
+
+import scala.compiletime.uninitialized
+
+// scalafix:off
+@QuarkusTest
+@DisplayName("GameResource Integration")
+class GameResourceIntegrationTest:
+
+ @Inject
+ var resource: GameResource = uninitialized
+
+ @Test
+ @DisplayName("createGame returns 201")
+ def testCreateGame(): Unit =
+ val req = CreateGameRequestDto(None, None)
+ val resp = resource.createGame(req)
+ assertEquals(201, resp.getStatus)
+ val dto = resp.getEntity.asInstanceOf[GameFullDto]
+ assertNotNull(dto.gameId)
+
+ @Test
+ @DisplayName("getGame returns 200")
+ def testGetGame(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val getResp = resource.getGame(gameId)
+ assertEquals(200, getResp.getStatus)
+ val dto = getResp.getEntity.asInstanceOf[GameFullDto]
+ assertEquals(gameId, dto.gameId)
+
+ @Test
+ @DisplayName("makeMove advances game")
+ def testMakeMove(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val moveResp = resource.makeMove(gameId, "e2e4")
+ assertEquals(200, moveResp.getStatus)
+ val state = moveResp.getEntity.asInstanceOf[GameStateDto]
+ assertEquals("black", state.turn)
+
+ @Test
+ @DisplayName("makeMove with invalid UCI throws")
+ def testMakeMoveInvalid(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
+
+ @Test
+ @DisplayName("getLegalMoves returns moves")
+ def testGetLegalMoves(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val movesResp = resource.getLegalMoves(gameId, "")
+ assertEquals(200, movesResp.getStatus)
+ val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
+ assertFalse(dto.moves.isEmpty)
+
+ @Test
+ @DisplayName("resignGame updates state")
+ def testResignGame(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val resignResp = resource.resignGame(gameId)
+ assertEquals(200, resignResp.getStatus)
+ val getResp = resource.getGame(gameId)
+ val state = getResp.getEntity.asInstanceOf[GameFullDto].state
+ assertEquals("resign", state.status)
+
+ @Test
+ @DisplayName("undoMove reverts")
+ def testUndoMove(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ resource.makeMove(gameId, "e2e4")
+ val undoResp = resource.undoMove(gameId)
+ assertEquals(200, undoResp.getStatus)
+ val state = undoResp.getEntity.asInstanceOf[GameStateDto]
+ assertEquals("white", state.turn)
+
+ @Test
+ @DisplayName("redoMove restores")
+ def testRedoMove(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ resource.makeMove(gameId, "e2e4")
+ resource.undoMove(gameId)
+ val redoResp = resource.redoMove(gameId)
+ assertEquals(200, redoResp.getStatus)
+ val state = redoResp.getEntity.asInstanceOf[GameStateDto]
+ assertEquals("black", state.turn)
+
+ @Test
+ @DisplayName("drawAction offer")
+ def testDrawActionOffer(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val resp = resource.drawAction(gameId, "offer")
+ assertEquals(200, resp.getStatus)
+
+ @Test
+ @DisplayName("drawAction accept")
+ def testDrawActionAccept(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ resource.drawAction(gameId, "offer")
+ val resp = resource.drawAction(gameId, "accept")
+ assertEquals(200, resp.getStatus)
+
+ @Test
+ @DisplayName("importFen creates game")
+ def testImportFen(): Unit =
+ val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
+ val req = ImportFenRequestDto(fen, None, None)
+ val resp = resource.importFen(req)
+ assertEquals(201, resp.getStatus)
+ val dto = resp.getEntity.asInstanceOf[GameFullDto]
+ assertEquals(fen, dto.state.fen)
+
+ @Test
+ @DisplayName("importPgn creates game")
+ def testImportPgn(): Unit =
+ val req = ImportPgnRequestDto("1. e4 c5")
+ val resp = resource.importPgn(req)
+ assertEquals(201, resp.getStatus)
+ val dto = resp.getEntity.asInstanceOf[GameFullDto]
+ assertTrue(dto.state.moves.length > 0)
+
+ @Test
+ @DisplayName("exportFen returns FEN")
+ def testExportFen(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ val resp = resource.exportFen(gameId)
+ assertEquals(200, resp.getStatus)
+ assertTrue(resp.getEntity.asInstanceOf[String].contains("rnbqkbnr"))
+
+ @Test
+ @DisplayName("exportPgn returns PGN")
+ def testExportPgn(): Unit =
+ val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
+ resource.makeMove(gameId, "e2e4")
+ val resp = resource.exportPgn(gameId)
+ assertEquals(200, resp.getStatus)
+ assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
+// scalafix:on
diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts
index d0027de..84475e4 100644
--- a/modules/io/build.gradle.kts
+++ b/modules/io/build.gradle.kts
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
repositories {
mavenCentral()
@@ -19,7 +21,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
- excludedFiles.set(listOf(".*FenParserFastParse.*"))
+ excludedFiles.set(scoverageExcluded)
}
tasks.withType {
@@ -28,7 +30,7 @@ tasks.withType {
dependencies {
- implementation("org.scala-lang:scala3-compiler_3") {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts
index 07a8017..093fe12 100644
--- a/modules/rule/build.gradle.kts
+++ b/modules/rule/build.gradle.kts
@@ -27,7 +27,7 @@ tasks.withType {
dependencies {
- implementation("org.scala-lang:scala3-compiler_3") {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
diff --git a/modules/ui/CHANGELOG.md b/modules/ui/CHANGELOG.md
deleted file mode 100644
index c80ea5f..0000000
--- a/modules/ui/CHANGELOG.md
+++ /dev/null
@@ -1,119 +0,0 @@
-## (2026-04-01)
-
-### Features
-
-* 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))
-## (2026-04-01)
-
-### Features
-
-* 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))
-## (2026-04-01)
-
-### Features
-
-* 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-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))
-## (2026-04-02)
-
-### Features
-
-* 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-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))
-## (2026-04-03)
-
-### Features
-
-* 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-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))
-## (2026-04-07)
-
-### Features
-
-* 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-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))
-## (2026-04-07)
-
-### Features
-
-* 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-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))
-## (2026-04-12)
-
-### Features
-
-* 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-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-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-## (2026-04-12)
-
-### Features
-
-* 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-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-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
-* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-## (2026-04-14)
-
-### Features
-
-* 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-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
-* 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-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
-* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-## (2026-04-16)
-
-### Features
-
-* 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-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
-* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
-* 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-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
-* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-## (2026-04-19)
-
-### Features
-
-* 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-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
-* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
-* 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-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
-* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
-## (2026-04-19)
-
-### Features
-
-* 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-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
-* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
-* 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-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
-* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
-* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts
deleted file mode 100644
index 2a930c2..0000000
--- a/modules/ui/build.gradle.kts
+++ /dev/null
@@ -1,101 +0,0 @@
-import org.gradle.api.file.DuplicatesStrategy
-import org.gradle.jvm.tasks.Jar
-
-plugins {
- id("scala")
- id("org.scoverage")
- application
-}
-
-group = "de.nowchess"
-version = "1.0-SNAPSHOT"
-
-@Suppress("UNCHECKED_CAST")
-val versions = rootProject.extra["VERSIONS"] as Map
-
-repositories {
- mavenCentral()
-}
-
-scala {
- scalaVersion = versions["SCALA3"]!!
-}
-
-scoverage {
- scoverageVersion.set(versions["SCOVERAGE"]!!)
- excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*"))
-}
-
-application {
- mainClass.set("de.nowchess.ui.Main")
-}
-
-tasks.withType {
- scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
-}
-
-tasks.named("run") {
- jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
- standardInput = System.`in`
-}
-
-tasks.named("jar") {
- duplicatesStrategy = DuplicatesStrategy.EXCLUDE
-}
-
-dependencies {
-
- implementation("org.scala-lang:scala3-compiler_3") {
- version {
- strictly(versions["SCALA3"]!!)
- }
- }
- implementation("org.scala-lang:scala3-library_3") {
- version {
- strictly(versions["SCALA3"]!!)
- }
- }
-
- implementation(project(":modules:core"))
- implementation(project(":modules:rule"))
- implementation(project(":modules:api"))
- implementation(project(":modules:io"))
- implementation(project(":modules:bot"))
-
- // ScalaFX dependencies
- implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
-
- // JavaFX dependencies for the current platform
- val javaFXVersion = versions["JAVAFX"]!!
- val osName = System.getProperty("os.name").lowercase()
- val platform = when {
- osName.contains("win") -> "win"
- osName.contains("mac") -> "mac"
- osName.contains("linux") -> "linux"
- else -> "linux"
- }
-
- listOf("base", "controls", "graphics", "media").forEach { module ->
- implementation("org.openjfx:javafx-$module:$javaFXVersion:$platform")
- }
-
- testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
- testImplementation("org.junit.jupiter:junit-jupiter")
- 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("skipped", "failed")
- }
- }
- finalizedBy(tasks.reportScoverage)
-}
-tasks.reportScoverage {
- dependsOn(tasks.test)
-}
diff --git a/modules/ui/src/main/resources/sprites/board/board_bottom.png b/modules/ui/src/main/resources/sprites/board/board_bottom.png
deleted file mode 100644
index 884fb3c..0000000
Binary files a/modules/ui/src/main/resources/sprites/board/board_bottom.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/board/board_square_black.png b/modules/ui/src/main/resources/sprites/board/board_square_black.png
deleted file mode 100644
index 42c4b9a..0000000
Binary files a/modules/ui/src/main/resources/sprites/board/board_square_black.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/board/board_square_white.png b/modules/ui/src/main/resources/sprites/board/board_square_white.png
deleted file mode 100644
index ea97b12..0000000
Binary files a/modules/ui/src/main/resources/sprites/board/board_square_white.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_bishop.png b/modules/ui/src/main/resources/sprites/pieces/black_bishop.png
deleted file mode 100644
index fe2c260..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_bishop.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_king.png b/modules/ui/src/main/resources/sprites/pieces/black_king.png
deleted file mode 100644
index f1c96bb..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_king.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_knight.png b/modules/ui/src/main/resources/sprites/pieces/black_knight.png
deleted file mode 100644
index 579db13..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_knight.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_pawn.png b/modules/ui/src/main/resources/sprites/pieces/black_pawn.png
deleted file mode 100644
index 92597c9..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_pawn.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_queen.png b/modules/ui/src/main/resources/sprites/pieces/black_queen.png
deleted file mode 100644
index 6d94c24..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_queen.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/black_rook.png b/modules/ui/src/main/resources/sprites/pieces/black_rook.png
deleted file mode 100644
index 7ab7e04..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/black_rook.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_bishop.png b/modules/ui/src/main/resources/sprites/pieces/white_bishop.png
deleted file mode 100644
index ab456ed..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_bishop.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_king.png b/modules/ui/src/main/resources/sprites/pieces/white_king.png
deleted file mode 100644
index 435d27a..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_king.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_knight.png b/modules/ui/src/main/resources/sprites/pieces/white_knight.png
deleted file mode 100644
index 7cf6ed6..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_knight.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_pawn.png b/modules/ui/src/main/resources/sprites/pieces/white_pawn.png
deleted file mode 100644
index 47cb262..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_pawn.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_queen.png b/modules/ui/src/main/resources/sprites/pieces/white_queen.png
deleted file mode 100644
index cb53ef1..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_queen.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/sprites/pieces/white_rook.png b/modules/ui/src/main/resources/sprites/pieces/white_rook.png
deleted file mode 100644
index 10ba443..0000000
Binary files a/modules/ui/src/main/resources/sprites/pieces/white_rook.png and /dev/null differ
diff --git a/modules/ui/src/main/resources/styles.css b/modules/ui/src/main/resources/styles.css
deleted file mode 100644
index aae36d1..0000000
--- a/modules/ui/src/main/resources/styles.css
+++ /dev/null
@@ -1,30 +0,0 @@
-/* Arabian Chess GUI Styles */
-
-.root {
- -fx-font-family: "Comic Sans MS", "Comic Sans", cursive;
- -fx-background-color: #F3C8A0;
-}
-
-.button {
- -fx-background-radius: 8;
- -fx-padding: 8 16 8 16;
- -fx-font-family: "Comic Sans MS", cursive;
- -fx-font-size: 12px;
- -fx-cursor: hand;
-}
-
-.button:hover {
- -fx-opacity: 0.8;
-}
-
-.label {
- -fx-font-family: "Comic Sans MS", cursive;
-}
-
-.dialog-pane {
- -fx-background-color: #F3C8A0;
-}
-
-.dialog-pane .content {
- -fx-font-family: "Comic Sans MS", cursive;
-}
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala
deleted file mode 100644
index f5a8efd..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package de.nowchess.ui
-
-import de.nowchess.api.game.{BotParticipant, Human}
-import de.nowchess.api.player.{PlayerId, PlayerInfo}
-import de.nowchess.bot.util.PolyglotBook
-import de.nowchess.bot.BotDifficulty
-import de.nowchess.ui.terminal.TerminalUI
-import de.nowchess.ui.gui.ChessGUILauncher
-
-/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
- * GameEngine via Observer pattern.
- */
-object Main:
- def main(args: Array[String]): Unit =
- val book = PolyglotBook("../../modules/bot/codekiddy.bin")
-
- // Create the core game engine (single source of truth)
- val engine = new de.nowchess.chess.engine.GameEngine(
- participants = Map(
- de.nowchess.api.board.Color.White -> BotParticipant(
- de.nowchess.bot.bots.HybridBot(BotDifficulty.Easy, book = Some(book)),
- ),
- de.nowchess.api.board.Color.Black -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
- ),
- )
-
- engine.startGame()
-
- // Launch ScalaFX GUI in separate thread
- ChessGUILauncher.launch(engine)
-
- // Create and start the terminal UI (blocks on main thread)
- val tui = new TerminalUI(engine)
- tui.start()
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala
deleted file mode 100644
index 649b15d..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala
+++ /dev/null
@@ -1,392 +0,0 @@
-package de.nowchess.ui.gui
-
-import java.util.concurrent.atomic.AtomicReference
-import scalafx.Includes.*
-import scalafx.application.Platform
-import scalafx.geometry.{Insets, Pos}
-import scalafx.scene.control.{Button, ButtonType, ChoiceDialog, Label}
-import scalafx.scene.layout.{BorderPane, GridPane, HBox, StackPane, VBox}
-import scalafx.scene.paint.Color as FXColor
-import scalafx.scene.shape.Rectangle
-import scalafx.scene.text.{Font, Text}
-import scalafx.stage.Stage
-import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
-import de.nowchess.api.move.MoveType
-import de.nowchess.chess.command.{MoveCommand, MoveResult}
-import de.nowchess.chess.engine.GameEngine
-import de.nowchess.io.fen.{FenExporter, FenParser}
-import de.nowchess.io.pgn.{PgnExporter, PgnParser}
-import de.nowchess.io.json.{JsonExporter, JsonParser}
-import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
-import java.nio.file.Paths
-import scalafx.stage.FileChooser
-import scalafx.stage.FileChooser.ExtensionFilter
-
-/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
- * interactions (clicks) and sends moves to GameEngine.
- */
-class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
-
- private val squareSize = 70.0
- private val comicSansFontFamily = "Comic Sans MS"
- private val boardGrid = new GridPane()
- private val messageLabel = new Label {
- text = "Welcome!"
- font = Font.font(comicSansFontFamily, 16)
- padding = Insets(10)
- }
-
- private val currentBoard = new AtomicReference[Board](engine.board)
- private val currentTurn = new AtomicReference[Color](engine.turn)
- private val selectedSquare = new AtomicReference[Option[Square]](None)
- private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
-
- private val undoButton: Button = new Button("Undo") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => if engine.canUndo then engine.undo()
- style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
- disable = !engine.canUndo
- }
- private val redoButton: Button = new Button("Redo") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => if engine.canRedo then engine.redo()
- style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
- disable = !engine.canRedo
- }
-
- // Initialize UI
- initializeBoard()
-
- top = new VBox {
- padding = Insets(10)
- spacing = 5
- alignment = Pos.Center
- children = Seq(
- new Label {
- text = "Chess"
- font = Font.font(comicSansFontFamily, 24)
- style = "-fx-font-weight: bold;"
- },
- messageLabel,
- )
- }
-
- center = new VBox {
- padding = Insets(20)
- alignment = Pos.Center
- style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
- children = boardGrid
- }
-
- bottom = new VBox {
- padding = Insets(10)
- spacing = 8
- alignment = Pos.Center
- children = Seq(
- new HBox {
- spacing = 10
- alignment = Pos.Center
- children = Seq(
- undoButton,
- redoButton,
- new Button("Reset") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => engine.reset()
- style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
- },
- )
- },
- new HBox {
- spacing = 10
- alignment = Pos.Center
- children = Seq(
- new Button("FEN Export") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doFenExport()
- style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;"
- },
- new Button("FEN Import") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doFenImport()
- style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;"
- },
- new Button("PGN Export") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doPgnExport()
- style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;"
- },
- new Button("PGN Import") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doPgnImport()
- style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
- },
- )
- },
- new HBox {
- spacing = 10
- alignment = Pos.Center
- children = Seq(
- new Button("JSON Export") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doJsonExport()
- style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;"
- },
- new Button("JSON Import") {
- font = Font.font(comicSansFontFamily, 12)
- onAction = _ => doJsonImport()
- style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
- },
- )
- },
- )
- }
-
- private def initializeBoard(): Unit =
- boardGrid.padding = Insets(5)
- boardGrid.hgap = 0
- boardGrid.vgap = 0
-
- // Create 8x8 board with rank/file labels
- for
- rank <- 0 until 8
- file <- 0 until 8
- do
- val square = createSquare(rank, file)
- squareViews((rank, file)) = square
- boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
-
- updateBoard(currentBoard.get(), currentTurn.get())
-
- private def createSquare(rank: Int, file: Int): StackPane =
- val isWhite = (rank + file) % 2 == 0
- val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
-
- val bgRect = new Rectangle {
- width = squareSize
- height = squareSize
- fill = FXColor.web(baseColor)
- arcWidth = 8
- arcHeight = 8
- }
-
- val square = new StackPane {
- children = Seq(bgRect)
- onMouseClicked = _ => handleSquareClick(rank, file)
- style = "-fx-cursor: hand;"
- }
-
- square
-
- private def handleSquareClick(rank: Int, file: Int): Unit =
- val clickedSquare = Square(File.values(file), Rank.values(rank))
-
- selectedSquare.get() match
- case None =>
- // First click - select piece if it belongs to current player
- currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
- if piece.color == currentTurn.get() then
- selectedSquare.set(Some(clickedSquare))
- highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
-
- val legalDests = engine.ruleSet
- .legalMoves(engine.context)(clickedSquare)
- .collect { case move if move.from == clickedSquare => move.to }
- legalDests.foreach { sq =>
- highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
- }
- }
-
- case Some(fromSquare) =>
- // Second click - attempt move
- if clickedSquare == fromSquare then
- // Deselect
- selectedSquare.set(None)
- updateBoard(currentBoard.get(), currentTurn.get())
- else
- val isPromo = engine.ruleSet
- .legalMoves(engine.context)(fromSquare)
- .exists(m =>
- m.to == clickedSquare && (m.moveType match
- case MoveType.Promotion(_) => true
- case _ => false
- ),
- )
- if isPromo then showPromotionDialog(fromSquare, clickedSquare)
- else engine.processUserInput(s"${fromSquare}$clickedSquare")
- selectedSquare.set(None)
-
- def updateBoard(board: Board, turn: Color): Unit =
- currentBoard.set(board)
- currentTurn.set(turn)
- selectedSquare.set(None)
-
- // Update all squares
- for
- rank <- 0 until 8
- file <- 0 until 8
- do
- squareViews.get((rank, file)).foreach { stackPane =>
- val isWhite = (rank + file) % 2 == 0
- val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
-
- val bgRect = new Rectangle {
- width = squareSize
- height = squareSize
- fill = FXColor.web(baseColor)
- arcWidth = 8
- arcHeight = 8
- }
-
- val square = Square(File.values(file), Rank.values(rank))
- val pieceOption = board.pieceAt(square)
-
- val children: Seq[scalafx.scene.Node] = pieceOption match
- case Some(piece) =>
- Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
- case None =>
- Seq(bgRect)
-
- stackPane.children = children
- }
-
- updateUndoRedoButtons()
-
- def updateUndoRedoButtons(): Unit =
- undoButton.disable = !engine.canUndo
- redoButton.disable = !engine.canRedo
-
- private def highlightSquare(rank: Int, file: Int, color: String): Unit =
- squareViews.get((rank, file)).foreach { stackPane =>
- val bgRect = new Rectangle {
- width = squareSize
- height = squareSize
- fill = FXColor.web(color)
- arcWidth = 8
- arcHeight = 8
- }
-
- val square = Square(File.values(file), Rank.values(rank))
- val pieceOption = currentBoard.get().pieceAt(square)
-
- stackPane.children = (pieceOption match
- case Some(piece) =>
- Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
- case None =>
- Seq(bgRect)
- ): Seq[scalafx.scene.Node]
- }
-
- def showMessage(msg: String): Unit =
- messageLabel.text = msg
-
- def showPromotionDialog(from: Square, to: Square): Unit =
- val choices = Seq("Queen", "Rook", "Bishop", "Knight")
- val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
- initOwner(stage)
- title = "Pawn Promotion"
- headerText = "Choose promotion piece"
- contentText = "Promote to:"
- }
- val uciSuffix = dialog.showAndWait() match
- case Some("Rook") => "r"
- case Some("Bishop") => "b"
- case Some("Knight") => "n"
- case _ => "q"
- engine.processUserInput(s"${from}${to}$uciSuffix")
-
- private def doFenExport(): Unit =
- doExport(FenExporter, "FEN")
-
- private def doFenImport(): Unit =
- doImport(FenParser, "FEN")
-
- private def doPgnExport(): Unit =
- doExport(PgnExporter, "PGN")
-
- private def doPgnImport(): Unit =
- doImport(PgnParser, "PGN")
-
- private def doJsonExport(): Unit =
- val fileChooser = new FileChooser {
- title = "Export Game as JSON"
- initialFileName = "chess_game.json"
- extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
- extensionFilters.add(new ExtensionFilter("All files", "*.*"))
- }
-
- Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile =>
- val result = FileSystemGameService.saveGameToFile(
- engine.context,
- selectedFile.toPath,
- JsonExporter,
- )
- result match
- case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
- case Left(err) => showMessage(s"⚠️ Error saving file: $err")
- }
-
- private def doJsonImport(): Unit =
- val fileChooser = new FileChooser {
- title = "Import Game from JSON"
- extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
- extensionFilters.add(new ExtensionFilter("All files", "*.*"))
- }
-
- Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile =>
- val result = FileSystemGameService.loadGameFromFile(
- selectedFile.toPath,
- JsonParser,
- )
- result match
- case Right(gameContext) =>
- engine.loadPosition(gameContext)
- showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
- case Left(err) =>
- showMessage(s"⚠️ Error: $err")
- }
-
- private def doExport(exporter: GameContextExport, formatName: String): Unit = {
- val exported = exporter.exportGameContext(engine.context)
- showCopyDialog(s"$formatName Export", exported)
- }
-
- private def doImport(importer: GameContextImport, formatName: String): Unit =
- showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
- importer.importGameContext(input) match
- case Right(gameContext) =>
- engine.loadPosition(gameContext)
- showMessage(s"✓ $formatName loaded successfully!")
- case Left(err) =>
- showMessage(s"⚠️ $formatName Error: $err")
- }
-
- private def showCopyDialog(title: String, content: String): Unit =
- val area = new javafx.scene.control.TextArea(content)
- area.setEditable(false)
- area.setWrapText(true)
- area.setPrefRowCount(4)
- val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
- alert.setTitle(title)
- alert.setHeaderText("")
- alert.getDialogPane.setContent(area)
- alert.getDialogPane.setPrefWidth(500)
- alert.initOwner(stage.delegate)
- alert.showAndWait()
-
- private def showInputDialog(title: String, rows: Int = 2): Option[String] =
- val area = new javafx.scene.control.TextArea()
- area.setWrapText(true)
- area.setPrefRowCount(rows)
- val dialog = new javafx.scene.control.Dialog[String]()
- dialog.setTitle(title)
- dialog.getDialogPane.setContent(area)
- dialog.getDialogPane.getButtonTypes.addAll(
- javafx.scene.control.ButtonType.OK,
- javafx.scene.control.ButtonType.CANCEL,
- )
- dialog.setResultConverter { bt =>
- if bt == javafx.scene.control.ButtonType.OK then area.getText else ""
- }
- dialog.initOwner(stage.delegate)
- val result = dialog.showAndWait()
- if result.isPresent && result.get.nonEmpty then Some(result.get) else None
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala
deleted file mode 100644
index 04829df..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala
+++ /dev/null
@@ -1,57 +0,0 @@
-package de.nowchess.ui.gui
-
-import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
-import javafx.stage.Stage as JFXStage
-import scalafx.application.Platform
-import scalafx.scene.Scene
-import scalafx.stage.Stage
-import de.nowchess.chess.engine.GameEngine
-
-/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
- * GameEngine via Observer pattern.
- */
-class ChessGUIApp extends JFXApplication:
-
- override def start(primaryStage: JFXStage): Unit =
- val engine = ChessGUILauncher.getEngine
- val stage = new Stage(primaryStage)
-
- stage.title = "Chess"
- stage.width = 700
- stage.height = 1000
- stage.resizable = false
-
- val boardView = new ChessBoardView(stage, engine)
- val guiObserver = new GUIObserver(boardView)
-
- // Subscribe GUI observer to engine
- engine.subscribe(guiObserver)
-
- stage.scene = new Scene {
- root = boardView
- // Load CSS if available
- try
- Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
- catch {
- case _: Exception => // CSS is optional
- }
- }
-
- stage.onCloseRequest = _ =>
- // Unsubscribe when window closes
- engine.unsubscribe(guiObserver)
-
- stage.show()
-
-/** Launcher object that holds the engine reference and launches GUI in separate thread. */
-object ChessGUILauncher:
- private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]()
-
- def getEngine: GameEngine = engineRef.get()
-
- def launch(eng: GameEngine): Unit =
- engineRef.set(eng)
- val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
- guiThread.setDaemon(false)
- guiThread.setName("ScalaFX-GUI-Thread")
- guiThread.start()
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala
deleted file mode 100644
index 836263d..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala
+++ /dev/null
@@ -1,80 +0,0 @@
-package de.nowchess.ui.gui
-
-import scalafx.application.Platform
-import scalafx.scene.control.Alert
-import scalafx.scene.control.Alert.AlertType
-import de.nowchess.chess.observer.{GameEvent, Observer, *}
-import de.nowchess.api.board.Board
-import de.nowchess.api.game.DrawReason
-
-/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
- * All UI updates must be done on the JavaFX Application Thread.
- */
-class GUIObserver(private val boardView: ChessBoardView) extends Observer:
-
- override def onGameEvent(event: GameEvent): Unit =
- // Ensure UI updates happen on JavaFX thread
- Platform.runLater {
- event match
- case e: MoveExecutedEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- e.capturedPiece.foreach { piece =>
- boardView.showMessage(s"Captured: $piece on ${e.toSquare}")
- }
-
- case e: CheckDetectedEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- boardView.showMessage(s"${e.context.turn.label} is in check!")
-
- case e: CheckmateEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
-
- case e: DrawEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- val msg = e.reason match
- case DrawReason.Stalemate => "Stalemate! The game is a draw."
- case DrawReason.InsufficientMaterial => "Draw by insufficient material."
- case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
- case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
- case DrawReason.Agreement => "Draw by agreement."
- showAlert(AlertType.Information, "Game Over", msg)
-
- case e: InvalidMoveEvent =>
- boardView.showMessage(s"⚠️ ${e.reason}")
-
- case e: BoardResetEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- boardView.showMessage("Board has been reset to initial position.")
-
- case e: FiftyMoveRuleAvailableEvent =>
- boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
-
- case e: ThreefoldRepetitionAvailableEvent =>
- boardView.showMessage("Threefold repetition is now available — type 'draw' to claim.")
-
- case e: MoveUndoneEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
- boardView.updateUndoRedoButtons()
-
- case e: MoveRedoneEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- if e.capturedPiece.isDefined then
- boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
- else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
- boardView.updateUndoRedoButtons()
-
- case e: PgnLoadedEvent =>
- boardView.updateBoard(e.context.board, e.context.turn)
- boardView.showMessage("✓ PGN loaded successfully!")
- boardView.updateUndoRedoButtons()
- }
-
- private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
- new Alert(alertType) {
- initOwner(boardView.stage)
- title = titleText
- headerText = None
- contentText = content
- }.showAndWait()
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala
deleted file mode 100644
index f059250..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package de.nowchess.ui.gui
-
-import scalafx.scene.image.{Image, ImageView}
-import de.nowchess.api.board.{Color, Piece, PieceType}
-
-/** Utility object for loading chess piece sprites. */
-object PieceSprites:
-
- private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
-
- /** Load a piece sprite image from resources. Sprites are cached for performance.
- */
- def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
- val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
- spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
- new ImageView(image) {
- fitWidth = size
- fitHeight = size
- preserveRatio = true
- smooth = true
- }
- }
-
- private def loadImage(key: String): Option[Image] =
- val path = s"/sprites/pieces/$key.png"
- Option(getClass.getResourceAsStream(path)).map(new Image(_))
-
- /** Get square colors for the board using theme. */
- object SquareColors:
- val White = "#F3C8A0" // Warm light beige
- val Black = "#BA6D4B" // Warm terracotta
- val Selected = "#C19EF5" // Purple highlight
- val ValidMove = "#E1EAA9" // Light yellow-green
- val Border = "#5A2C28" // Dark brown border
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala
deleted file mode 100644
index e8dc192..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala
+++ /dev/null
@@ -1,107 +0,0 @@
-package de.nowchess.ui.terminal
-
-import java.util.concurrent.atomic.AtomicBoolean
-import scala.io.StdIn
-import de.nowchess.api.game.DrawReason
-import de.nowchess.chess.engine.GameEngine
-import de.nowchess.chess.observer.*
-import de.nowchess.ui.utils.Renderer
-
-/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
- * I/O and user interaction in the terminal.
- */
-class TerminalUI(engine: GameEngine) extends Observer:
- private val running = new AtomicBoolean(true)
-
- /** Called by GameEngine whenever a game event occurs. */
- override def onGameEvent(event: GameEvent): Unit =
- event match
- case e: MoveExecutedEvent =>
- println()
- print(Renderer.render(e.context.board))
- e.capturedPiece.foreach: cap =>
- println(s"Captured: $cap on ${e.toSquare}")
- printPrompt(e.context.turn)
-
- case e: MoveUndoneEvent =>
- println(s"Undo: ${e.pgnNotation}")
- println()
- print(Renderer.render(e.context.board))
- printPrompt(e.context.turn)
-
- case e: MoveRedoneEvent =>
- println(s"Redo: ${e.pgnNotation}")
- println()
- print(Renderer.render(e.context.board))
- printPrompt(e.context.turn)
-
- case e: CheckDetectedEvent =>
- println(s"${e.context.turn.label} is in check!")
-
- case e: CheckmateEvent =>
- println(s"Checkmate! ${e.winner.label} wins.")
- println()
- print(Renderer.render(e.context.board))
-
- case e: DrawEvent =>
- val msg = e.reason match
- case DrawReason.Stalemate => "Stalemate! The game is a draw."
- case DrawReason.InsufficientMaterial => "Draw by insufficient material."
- case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
- case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
- case DrawReason.Agreement => "Draw by agreement."
- println(msg)
- println()
- print(Renderer.render(e.context.board))
-
- case e: InvalidMoveEvent =>
- println(s"⚠️ ${e.reason}")
-
- case e: BoardResetEvent =>
- println("Board has been reset to initial position.")
- println()
- print(Renderer.render(e.context.board))
- printPrompt(e.context.turn)
-
- case _: FiftyMoveRuleAvailableEvent =>
- println("50-move rule is now available — type 'draw' to claim.")
-
- case _: ThreefoldRepetitionAvailableEvent =>
- println("Threefold repetition is now available — type 'draw' to claim.")
-
- case e: PgnLoadedEvent =>
- println("PGN loaded successfully.")
- println()
- print(Renderer.render(e.context.board))
- printPrompt(e.context.turn)
-
- /** Start the terminal UI game loop. */
- def start(): Unit =
- // Register as observer
- engine.subscribe(this)
-
- // Show initial board
- println()
- print(Renderer.render(engine.board))
- printPrompt(engine.turn)
-
- while running.get() do
- val input = Option(StdIn.readLine()).getOrElse("quit").trim
- synchronized {
- input.toLowerCase match
- case "quit" | "q" =>
- running.set(false)
- println("Game over. Goodbye!")
- case "" =>
- printPrompt(engine.turn)
- case _ =>
- engine.processUserInput(input)
- }
-
- // Unsubscribe when done
- engine.unsubscribe(this)
-
- private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
- val undoHint = if engine.canUndo then " [undo]" else ""
- val redoHint = if engine.canRedo then " [redo]" else ""
- print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/utils/PieceUnicode.scala b/modules/ui/src/main/scala/de/nowchess/ui/utils/PieceUnicode.scala
deleted file mode 100644
index 96c9548..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/utils/PieceUnicode.scala
+++ /dev/null
@@ -1,18 +0,0 @@
-package de.nowchess.ui.utils
-
-import de.nowchess.api.board.{Color, Piece, PieceType}
-
-extension (p: Piece)
- def unicode: String = (p.color, p.pieceType) match
- case (Color.White, PieceType.King) => "\u2654"
- case (Color.White, PieceType.Queen) => "\u2655"
- case (Color.White, PieceType.Rook) => "\u2656"
- case (Color.White, PieceType.Bishop) => "\u2657"
- case (Color.White, PieceType.Knight) => "\u2658"
- case (Color.White, PieceType.Pawn) => "\u2659"
- case (Color.Black, PieceType.King) => "\u265A"
- case (Color.Black, PieceType.Queen) => "\u265B"
- case (Color.Black, PieceType.Rook) => "\u265C"
- case (Color.Black, PieceType.Bishop) => "\u265D"
- case (Color.Black, PieceType.Knight) => "\u265E"
- case (Color.Black, PieceType.Pawn) => "\u265F"
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala b/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala
deleted file mode 100644
index 27cc533..0000000
--- a/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala
+++ /dev/null
@@ -1,30 +0,0 @@
-package de.nowchess.ui.utils
-
-import de.nowchess.api.board.*
-
-object Renderer:
-
- private val AnsiReset = "\u001b[0m"
- private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
- private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
- private val AnsiWhitePiece = "\u001b[97m" // bright white text
- private val AnsiBlackPiece = "\u001b[30m" // black text
-
- def render(board: Board): String =
- val rows = (0 until 8).reverse
- .map { rank =>
- val cells = (0 until 8).map { file =>
- val sq = Square(File.values(file), Rank.values(rank))
- val isLightSq = (file + rank) % 2 != 0
- val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
- board.pieceAt(sq) match
- case Some(piece) =>
- val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
- s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
- case None =>
- s"$bgColor $AnsiReset"
- }.mkString
- s"${rank + 1} $cells ${rank + 1}"
- }
- .mkString("\n")
- s" a b c d e f g h\n$rows\n a b c d e f g h\n"
diff --git a/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala b/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala
deleted file mode 100644
index 4a82afe..0000000
--- a/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala
+++ /dev/null
@@ -1,44 +0,0 @@
-package de.nowchess.ui.utils
-
-import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
-
- test("unicode returns correct unicode character for all piece types"):
- val pieces = Seq(
- (Piece(Color.White, PieceType.King), "\u2654"),
- (Piece(Color.White, PieceType.Queen), "\u2655"),
- (Piece(Color.White, PieceType.Rook), "\u2656"),
- (Piece(Color.White, PieceType.Bishop), "\u2657"),
- (Piece(Color.White, PieceType.Knight), "\u2658"),
- (Piece(Color.White, PieceType.Pawn), "\u2659"),
- (Piece(Color.Black, PieceType.King), "\u265A"),
- (Piece(Color.Black, PieceType.Queen), "\u265B"),
- (Piece(Color.Black, PieceType.Rook), "\u265C"),
- (Piece(Color.Black, PieceType.Bishop), "\u265D"),
- (Piece(Color.Black, PieceType.Knight), "\u265E"),
- (Piece(Color.Black, PieceType.Pawn), "\u265F"),
- )
- pieces.foreach { (piece, expected) =>
- piece.unicode shouldBe expected
- }
-
- test("render outputs coordinates ranks ansi escapes and piece glyphs"):
- val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
- val rendered = Renderer.render(Board(Map.empty))
- val lines = rendered.trim.split("\\n").toList.map(_.trim)
-
- lines.head shouldBe "a b c d e f g h"
- lines.last shouldBe "a b c d e f g h"
- rendered should include("8")
- rendered should include("1")
- Renderer.render(board) should include("\u2655")
- Renderer.render(board) should include("\u001b[")
-
- test("render applies black piece color for black pieces"):
- val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
- val rendered = Renderer.render(board)
- rendered should include("\u265A") // Black king unicode
- rendered should include("\u001b[30m") // ANSI black text color
diff --git a/modules/ui/versions.env b/modules/ui/versions.env
deleted file mode 100644
index 05bdbf6..0000000
--- a/modules/ui/versions.env
+++ /dev/null
@@ -1,3 +0,0 @@
-MAJOR=0
-MINOR=13
-PATCH=0
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c47f490..9b36b4b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,9 +1,22 @@
rootProject.name = "NowChessSystems"
+
+pluginManagement {
+ val quarkusPluginVersion: String by settings
+ val quarkusPluginId: String by settings
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ mavenLocal()
+ }
+ plugins {
+ id(quarkusPluginId) version quarkusPluginVersion
+ }
+}
+
include(
"modules:core",
"modules:api",
"modules:io",
"modules:rule",
- "modules:ui",
"modules:bot",
)
\ No newline at end of file