Compare commits
35 Commits
api-0.0.8
...
db955c08a5
| Author | SHA1 | Date | |
|---|---|---|---|
| db955c08a5 | |||
| 2c4d96e373 | |||
| acddd58ad3 | |||
| 33dd63a9b6 | |||
| 6fc3b3c3df | |||
| 41fa674bf7 | |||
| 21eee717f1 | |||
| d59d692381 | |||
| c830b143dc | |||
| b2e62dc60c | |||
| b0399a4e48 | |||
| ec2ab2f365 | |||
| fd4e67d4f7 | |||
| 3cb3160731 | |||
| dbcafd2869 | |||
| 3ecb2c9d66 | |||
| 9ad11fb97a | |||
| e158b0a7f0 | |||
| f1c9df16b6 | |||
| 9d11d25b99 | |||
| 7a045d31d7 | |||
| b518c704fa | |||
| fe8e3c0539 | |||
| 1b16adcc72 | |||
| b4bc72f7e4 | |||
| 8959c3a849 | |||
| 47032378e2 | |||
| 217f14f899 | |||
| 638139602c | |||
| 8f56a82104 | |||
| 51ffd7aac9 | |||
| 1b9eb471de | |||
| 45013c87a9 | |||
| 80518719d5 | |||
| 2d6ead7e47 |
@@ -1,9 +0,0 @@
|
|||||||
# Memory Index
|
|
||||||
|
|
||||||
## Feedback
|
|
||||||
- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md) — Update structure memory files whenever source files are added, removed, or changed
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
- [project_structure_root.md](project_structure_root.md) — Top-level layout, modules list, VERSIONS map, navigation rules (skip `build/`, `.gradle/`, `.idea/`)
|
|
||||||
- [project_structure_api.md](project_structure_api.md) — `modules/api`: all files and types (Board, Piece, Square, GameState, Move, ApiResponse, PlayerInfo)
|
|
||||||
- [project_structure_core.md](project_structure_core.md) — `modules/core`: all files and types (GameContext, GameRules, MoveValidator, GameController, Parser, Renderer)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
name: keep-structure-memory-updated
|
|
||||||
description: Always update the project structure memory files when adding, removing, or changing source files
|
|
||||||
type: feedback
|
|
||||||
---
|
|
||||||
|
|
||||||
After any change that adds, removes, renames, or significantly alters a source file, update the relevant structure memory file:
|
|
||||||
|
|
||||||
- New/renamed/deleted file in `modules/api` → update `project_structure_api.md`
|
|
||||||
- New/renamed/deleted file in `modules/core` → update `project_structure_core.md`
|
|
||||||
- New module, dependency version change, or new top-level directory → update `project_structure_root.md`
|
|
||||||
- New module added → create a new `project_structure_<module>.md` and add it to `MEMORY.md`
|
|
||||||
|
|
||||||
**Why:** Structure memories are the primary navigation aid. Stale entries cause wasted exploration.
|
|
||||||
|
|
||||||
**How to apply:** Treat the structure memory update as part of completing any implementation task — do it in the same session, not as a follow-up.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
name: module-api-structure
|
|
||||||
description: File and type overview for the modules/api module (shared domain types)
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module: `modules/api`
|
|
||||||
|
|
||||||
**Purpose:** Shared domain model — pure data types with no game logic. Depended on by `modules/core`.
|
|
||||||
|
|
||||||
**Gradle:** `id("scala")`, no `application` plugin. No Quarkus. Uses scoverage plugin.
|
|
||||||
|
|
||||||
**Package root:** `de.nowchess.api`
|
|
||||||
|
|
||||||
## Source files (`src/main/scala/de/nowchess/api/`)
|
|
||||||
|
|
||||||
### `board/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Board.scala` | `opaque type Board = Map[Square, Piece]` — extensions: `pieceAt`, `withMove`, `pieces`; `Board.initial` sets up start position |
|
|
||||||
| `Color.scala` | `enum Color { White, Black }` — `.opposite`, `.label` |
|
|
||||||
| `Piece.scala` | `case class Piece(color, pieceType)` — convenience vals `WhitePawn`…`BlackKing` |
|
|
||||||
| `PieceType.scala` | `enum PieceType { Pawn, Knight, Bishop, Rook, Queen, King }` — `.label` |
|
|
||||||
| `Square.scala` | `enum File { A–H }`, `enum Rank { R1–R8 }`, `case class Square(file, rank)` — `.toString` algebraic, `Square.fromAlgebraic(s)` |
|
|
||||||
|
|
||||||
### `game/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameState.scala` | `case class CastlingRights(kingSide, queenSide)` + `.None`/`.Both`; `enum GameResult { WhiteWins, BlackWins, Draw }`; `enum GameStatus { NotStarted, InProgress, Finished(result) }`; `case class GameState(piecePlacement, activeColor, castlingWhite, castlingBlack, enPassantTarget, halfMoveClock, fullMoveNumber, status)` — FEN-compatible snapshot |
|
|
||||||
|
|
||||||
### `move/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Move.scala` | `enum PromotionPiece { Knight, Bishop, Rook, Queen }`; `enum MoveType { Normal, CastleKingside, CastleQueenside, EnPassant, Promotion(piece) }`; `case class Move(from, to, moveType = Normal)` |
|
|
||||||
|
|
||||||
### `player/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `PlayerInfo.scala` | `opaque type PlayerId = String`; `case class PlayerInfo(id: PlayerId, displayName: String)` |
|
|
||||||
|
|
||||||
### `response/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `ApiResponse.scala` | `sealed trait ApiResponse[+A]` → `Success[A](data)` / `Failure(errors)`; `case class ApiError(code, message, field?)`; `case class Pagination(page, pageSize, totalItems)` + `.totalPages`; `case class PagedResponse[A](items, pagination)` |
|
|
||||||
|
|
||||||
## Test files (`src/test/scala/de/nowchess/api/`)
|
|
||||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- `GameState` is FEN-style but `Board` (in `core`) is a `Map[Square,Piece]` — the two are separate representations
|
|
||||||
- `CastlingRights` is defined here in `api`; the castling logic lives in `core`
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
name: module-core-structure
|
|
||||||
description: File and type overview for the modules/core module (TUI chess engine)
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module: `modules/core`
|
|
||||||
|
|
||||||
**Purpose:** Standalone TUI chess application. All game logic, move validation, rendering. Depends on `modules/api`.
|
|
||||||
|
|
||||||
**Gradle:** `id("scala")` + `application` plugin. Main class: `de.nowchess.chess.Main`. Uses scoverage plugin.
|
|
||||||
|
|
||||||
**Package root:** `de.nowchess.chess`
|
|
||||||
|
|
||||||
## Source files (`src/main/scala/de/nowchess/chess/`)
|
|
||||||
|
|
||||||
### Root
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Main.scala` | Entry point — prints welcome, starts `GameController.gameLoop(GameContext.initial, Color.White)` |
|
|
||||||
|
|
||||||
### `controller/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameController.scala` | `sealed trait MoveResult` ADT: `Quit`, `InvalidFormat`, `NoPiece`, `WrongColor`, `IllegalMove`, `Moved`, `MovedInCheck`, `Checkmate`, `Stalemate`; `object GameController` — `processMove(ctx, turn, raw): MoveResult` (pure), `gameLoop(ctx, turn)` (I/O loop), `applyRightsRevocation(...)` (castling rights bookkeeping) |
|
|
||||||
| `Parser.scala` | `object Parser` — `parseMove(input): Option[(Square, Square)]` parses coordinate notation e.g. `"e2e4"` |
|
|
||||||
|
|
||||||
### `logic/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `GameContext.scala` | `enum CastleSide { Kingside, Queenside }`; `case class GameContext(board, whiteCastling, blackCastling)` — `.castlingFor(color)`, `.withUpdatedRights(color, rights)`; `GameContext.initial`; `extension (Board).withCastle(color, side)` moves king+rook atomically |
|
|
||||||
| `GameRules.scala` | `enum PositionStatus { Normal, InCheck, Mated, Drawn }`; `object GameRules` — `isInCheck(board, color)`, `legalMoves(ctx, color): Set[(Square,Square)]`, `gameStatus(ctx, color): PositionStatus` |
|
|
||||||
| `MoveValidator.scala` | `object MoveValidator` — `isLegal(board, from, to)`, `legalTargets(board, from): Set[Square]` (board-only, no castling), `legalTargets(ctx, from)` (context-aware, includes castling), `isCastle`, `castleSide`, `castlingTargets(ctx, color)` — full castling legality (empty squares, no check through transit) |
|
|
||||||
|
|
||||||
### `view/`
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `Renderer.scala` | `object Renderer` — `render(board): String` outputs ANSI-colored board with file/rank labels |
|
|
||||||
| `PieceUnicode.scala` | `extension (Piece).unicode: String` maps each piece to its Unicode chess symbol |
|
|
||||||
|
|
||||||
## Test files (`src/test/scala/de/nowchess/chess/`)
|
|
||||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
|
||||||
|
|
||||||
## Key design notes
|
|
||||||
- `MoveValidator` has two overloaded `legalTargets`: one takes `Board` (geometry only), one takes `GameContext` (adds castling)
|
|
||||||
- `GameRules.legalMoves` filters by check — it calls `MoveValidator.legalTargets(ctx, from)` then simulates each move
|
|
||||||
- Castling rights revocation is in `GameController.applyRightsRevocation`, triggered after every move
|
|
||||||
- No `@QuarkusTest` — this module is a plain Scala application, not a Quarkus service
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: project-root-structure
|
|
||||||
description: Top-level project structure, modules list, and navigation notes for NowChessSystems
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# NowChessSystems — Root Structure
|
|
||||||
|
|
||||||
## Directory layout (skip `build/`, `.gradle/`, `.idea/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
NowChessSystems/
|
|
||||||
├── build.gradle.kts # Root: sonarqube plugin, VERSIONS map
|
|
||||||
├── settings.gradle.kts # include(":modules:core", ":modules:api")
|
|
||||||
├── gradlew / gradlew.bat
|
|
||||||
├── CLAUDE.md # Project instructions for Claude Code
|
|
||||||
├── .claude/
|
|
||||||
│ ├── CLAUDE.MD # Working agreement (plan/verify/unresolved)
|
|
||||||
│ ├── settings.json
|
|
||||||
│ └── agents/ # architect, code-reviewer, gradle-builder, scala-implementer, test-writer
|
|
||||||
├── docs/
|
|
||||||
│ ├── Claude-Skills.md
|
|
||||||
│ ├── Security.md
|
|
||||||
│ └── unresolved.md
|
|
||||||
├── jacoco-reporter/ # Python scripts for coverage gap reporting
|
|
||||||
└── modules/
|
|
||||||
├── api/ # Shared domain types (no logic)
|
|
||||||
└── core/ # TUI chess engine + game logic
|
|
||||||
```
|
|
||||||
|
|
||||||
## Modules
|
|
||||||
|
|
||||||
| Module | Gradle path | Purpose |
|
|
||||||
|--------|-------------|---------|
|
|
||||||
| `api` | `:modules:api` | Shared domain model: Board, Piece, Move, GameState, ApiResponse |
|
|
||||||
| `core` | `:modules:core` | TUI chess app: game logic, move validation, rendering |
|
|
||||||
|
|
||||||
`core` depends on `api` via `implementation(project(":modules:api"))`.
|
|
||||||
|
|
||||||
## VERSIONS (root `build.gradle.kts`)
|
|
||||||
|
|
||||||
| Key | Value |
|
|
||||||
|-----|-------|
|
|
||||||
| `QUARKUS_SCALA3` | 1.0.0 |
|
|
||||||
| `SCALA3` | 3.5.1 |
|
|
||||||
| `SCALA_LIBRARY` | 2.13.18 |
|
|
||||||
| `SCALATEST` | 3.2.19 |
|
|
||||||
| `SCALATEST_JUNIT` | 0.1.11 |
|
|
||||||
| `SCOVERAGE` | 2.1.1 |
|
|
||||||
|
|
||||||
## Navigation rules
|
|
||||||
- **Always skip** `build/`, `.gradle/`, `.idea/` when exploring — they are generated artifacts
|
|
||||||
- Tests use `AnyFunSuite with Matchers` (ScalaTest), not JUnit `@Test`
|
|
||||||
- No Quarkus in current modules — Quarkus is planned for future services
|
|
||||||
- Agent workflow: architect → scala-implementer → test-writer → gradle-builder → code-reviewer
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"superpowers@claude-plugins-official": true,
|
"superpowers@claude-plugins-official": false,
|
||||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
"ui-ux-pro-max@ui-ux-pro-max-skill": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# NowChessSystems — AI Context Map
|
||||||
|
|
||||||
|
> **Stack:** raw-http | none | unknown | scala
|
||||||
|
|
||||||
|
> 0 routes | 0 models | 0 components | 35 lib files | 0 env vars | 0 middleware
|
||||||
|
> **Token savings:** this file is ~3.700 tokens. Without it, AI exploration would cost ~18.200 tokens. **Saves ~14.500 tokens per conversation.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Libraries
|
||||||
|
|
||||||
|
- `jacoco-reporter/scoverage_coverage_gaps.py`
|
||||||
|
- function parse_scoverage_xml: (xml_path) -> tuple[dict, list[ClassGap]]
|
||||||
|
- function format_agent: (project_stats, classes) -> str
|
||||||
|
- function format_json: (project_stats, classes) -> str
|
||||||
|
- function format_markdown: (project_stats, classes) -> str
|
||||||
|
- function format_module_gaps: (module_name, classes, stmt_pct) -> str
|
||||||
|
- function run_scan_modules: (modules_dir, package_filter, min_coverage) -> None
|
||||||
|
- _...4 more_
|
||||||
|
- `jacoco-reporter/test_gaps.py`
|
||||||
|
- function parse_suite_xml: (xml_path) -> SuiteResult
|
||||||
|
- function load_module: (module_dir, results_subdir) -> Optional[ModuleResult]
|
||||||
|
- function format_module: (mod) -> str
|
||||||
|
- function run: (modules_dir, results_subdir, module_filter) -> None
|
||||||
|
- function main: () -> None
|
||||||
|
- class TestCase
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala`
|
||||||
|
- class Board
|
||||||
|
- function apply
|
||||||
|
- function pieceAt
|
||||||
|
- function updated
|
||||||
|
- function removed
|
||||||
|
- function withMove
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala`
|
||||||
|
- function hasAnyRights
|
||||||
|
- function hasRights
|
||||||
|
- function revokeColor
|
||||||
|
- function revokeKingSide
|
||||||
|
- function revokeQueenSide
|
||||||
|
- class CastlingRights
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — function opposite, function label
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — class Piece
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — function label
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala`
|
||||||
|
- class Square
|
||||||
|
- function fromAlgebraic
|
||||||
|
- function offset
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`
|
||||||
|
- function withBoard
|
||||||
|
- function withTurn
|
||||||
|
- function withCastlingRights
|
||||||
|
- function withEnPassantSquare
|
||||||
|
- function withHalfMoveClock
|
||||||
|
- function withMove
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — class PlayerId, function apply
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala`
|
||||||
|
- class ApiResponse
|
||||||
|
- function error
|
||||||
|
- function totalPages
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`
|
||||||
|
- class Command
|
||||||
|
- function execute
|
||||||
|
- function undo
|
||||||
|
- function description
|
||||||
|
- class MoveResult
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala`
|
||||||
|
- class CommandInvoker
|
||||||
|
- function execute
|
||||||
|
- function undo
|
||||||
|
- function redo
|
||||||
|
- function history
|
||||||
|
- function getCurrentIndex
|
||||||
|
- _...3 more_
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — class Parser, function parseMove
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
|
||||||
|
- class GameEngine
|
||||||
|
- function isPendingPromotion
|
||||||
|
- function board
|
||||||
|
- function turn
|
||||||
|
- function context
|
||||||
|
- function canUndo
|
||||||
|
- _...10 more_
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
|
||||||
|
- function context
|
||||||
|
- class Observer
|
||||||
|
- function onGameEvent
|
||||||
|
- class Observable
|
||||||
|
- function subscribe
|
||||||
|
- function unsubscribe
|
||||||
|
- _...1 more_
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — class GameContextImport, function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala`
|
||||||
|
- class FenExporter
|
||||||
|
- function boardToFen
|
||||||
|
- function gameContextToFen
|
||||||
|
- function exportGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala`
|
||||||
|
- class FenParser
|
||||||
|
- function parseFen
|
||||||
|
- function importGameContext
|
||||||
|
- function parseBoard
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala`
|
||||||
|
- class FenParserCombinators
|
||||||
|
- function parseFen
|
||||||
|
- function parseBoard
|
||||||
|
- function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala`
|
||||||
|
- class FenParserFastParse
|
||||||
|
- function parseFen
|
||||||
|
- function parseBoard
|
||||||
|
- function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — function buildSquares
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala`
|
||||||
|
- class PgnExporter
|
||||||
|
- function exportGameContext
|
||||||
|
- function exportGame
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala`
|
||||||
|
- class PgnParser
|
||||||
|
- function validatePgn
|
||||||
|
- function importGameContext
|
||||||
|
- function parsePgn
|
||||||
|
- function parseAlgebraicMove
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala`
|
||||||
|
- class RuleSet
|
||||||
|
- function candidateMoves
|
||||||
|
- function legalMoves
|
||||||
|
- function allLegalMoves
|
||||||
|
- function isCheck
|
||||||
|
- function isCheckmate
|
||||||
|
- _...4 more_
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala`
|
||||||
|
- class DefaultRules
|
||||||
|
- function loop
|
||||||
|
- function toMoves
|
||||||
|
- function loop
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/Main.scala` — class Main, function main
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala`
|
||||||
|
- class ChessBoardView
|
||||||
|
- function updateBoard
|
||||||
|
- function updateUndoRedoButtons
|
||||||
|
- function showMessage
|
||||||
|
- function showPromotionDialog
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala`
|
||||||
|
- class ChessGUIApp
|
||||||
|
- class ChessGUILauncher
|
||||||
|
- function getEngine
|
||||||
|
- function launch
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala` — class GUIObserver
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala`
|
||||||
|
- class PieceSprites
|
||||||
|
- function loadPieceImage
|
||||||
|
- class SquareColors
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala` — class TerminalUI, function start
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/utils/PieceUnicode.scala` — function unicode
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala` — class Renderer, function render
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dependency Graph
|
||||||
|
|
||||||
|
## Most Imported Files (change these carefully)
|
||||||
|
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **28** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **21** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **19** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **14** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **13** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **10** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **9** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **9** files
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **8** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — imported by **7** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **4** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — imported by **4** files
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **4** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **4** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` — imported by **4** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — imported by **2** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — imported by **1** files
|
||||||
|
|
||||||
|
## Import Map (who imports what)
|
||||||
|
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, `modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala` +23 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala` +16 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala` +14 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala` +9 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala` +8 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala` +5 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala` +4 more
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala` +4 more
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` +3 more
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala` +2 more
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated by [codesight](https://github.com/Houseofmvps/codesight) — see your codebase clearly_
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependency Graph
|
||||||
|
|
||||||
|
## Most Imported Files (change these carefully)
|
||||||
|
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **28** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **21** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **19** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **14** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **13** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **10** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **9** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **9** files
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **8** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — imported by **7** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **4** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — imported by **4** files
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **4** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **4** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` — imported by **4** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala` — imported by **2** files
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — imported by **2** files
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — imported by **1** files
|
||||||
|
|
||||||
|
## Import Map (who imports what)
|
||||||
|
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, `modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala` +23 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala` +16 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala` +14 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala` +9 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala` +8 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala` +5 more
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala`, `modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala` +4 more
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala` +4 more
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`, `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` +3 more
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` ← `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala`, `modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala` +2 more
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Libraries
|
||||||
|
|
||||||
|
- `jacoco-reporter/scoverage_coverage_gaps.py`
|
||||||
|
- function parse_scoverage_xml: (xml_path) -> tuple[dict, list[ClassGap]]
|
||||||
|
- function format_agent: (project_stats, classes) -> str
|
||||||
|
- function format_json: (project_stats, classes) -> str
|
||||||
|
- function format_markdown: (project_stats, classes) -> str
|
||||||
|
- function format_module_gaps: (module_name, classes, stmt_pct) -> str
|
||||||
|
- function run_scan_modules: (modules_dir, package_filter, min_coverage) -> None
|
||||||
|
- _...4 more_
|
||||||
|
- `jacoco-reporter/test_gaps.py`
|
||||||
|
- function parse_suite_xml: (xml_path) -> SuiteResult
|
||||||
|
- function load_module: (module_dir, results_subdir) -> Optional[ModuleResult]
|
||||||
|
- function format_module: (mod) -> str
|
||||||
|
- function run: (modules_dir, results_subdir, module_filter) -> None
|
||||||
|
- function main: () -> None
|
||||||
|
- class TestCase
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala`
|
||||||
|
- class Board
|
||||||
|
- function apply
|
||||||
|
- function pieceAt
|
||||||
|
- function updated
|
||||||
|
- function removed
|
||||||
|
- function withMove
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala`
|
||||||
|
- function hasAnyRights
|
||||||
|
- function hasRights
|
||||||
|
- function revokeColor
|
||||||
|
- function revokeKingSide
|
||||||
|
- function revokeQueenSide
|
||||||
|
- class CastlingRights
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — function opposite, function label
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — class Piece
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — function label
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala`
|
||||||
|
- class Square
|
||||||
|
- function fromAlgebraic
|
||||||
|
- function offset
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`
|
||||||
|
- function withBoard
|
||||||
|
- function withTurn
|
||||||
|
- function withCastlingRights
|
||||||
|
- function withEnPassantSquare
|
||||||
|
- function withHalfMoveClock
|
||||||
|
- function withMove
|
||||||
|
- _...2 more_
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — class PlayerId, function apply
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala`
|
||||||
|
- class ApiResponse
|
||||||
|
- function error
|
||||||
|
- function totalPages
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`
|
||||||
|
- class Command
|
||||||
|
- function execute
|
||||||
|
- function undo
|
||||||
|
- function description
|
||||||
|
- class MoveResult
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala`
|
||||||
|
- class CommandInvoker
|
||||||
|
- function execute
|
||||||
|
- function undo
|
||||||
|
- function redo
|
||||||
|
- function history
|
||||||
|
- function getCurrentIndex
|
||||||
|
- _...3 more_
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — class Parser, function parseMove
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
|
||||||
|
- class GameEngine
|
||||||
|
- function isPendingPromotion
|
||||||
|
- function board
|
||||||
|
- function turn
|
||||||
|
- function context
|
||||||
|
- function canUndo
|
||||||
|
- _...10 more_
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
|
||||||
|
- function context
|
||||||
|
- class Observer
|
||||||
|
- function onGameEvent
|
||||||
|
- class Observable
|
||||||
|
- function subscribe
|
||||||
|
- function unsubscribe
|
||||||
|
- _...1 more_
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — class GameContextImport, function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala`
|
||||||
|
- class FenExporter
|
||||||
|
- function boardToFen
|
||||||
|
- function gameContextToFen
|
||||||
|
- function exportGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala`
|
||||||
|
- class FenParser
|
||||||
|
- function parseFen
|
||||||
|
- function importGameContext
|
||||||
|
- function parseBoard
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala`
|
||||||
|
- class FenParserCombinators
|
||||||
|
- function parseFen
|
||||||
|
- function parseBoard
|
||||||
|
- function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala`
|
||||||
|
- class FenParserFastParse
|
||||||
|
- function parseFen
|
||||||
|
- function parseBoard
|
||||||
|
- function importGameContext
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — function buildSquares
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala`
|
||||||
|
- class PgnExporter
|
||||||
|
- function exportGameContext
|
||||||
|
- function exportGame
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala`
|
||||||
|
- class PgnParser
|
||||||
|
- function validatePgn
|
||||||
|
- function importGameContext
|
||||||
|
- function parsePgn
|
||||||
|
- function parseAlgebraicMove
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala`
|
||||||
|
- class RuleSet
|
||||||
|
- function candidateMoves
|
||||||
|
- function legalMoves
|
||||||
|
- function allLegalMoves
|
||||||
|
- function isCheck
|
||||||
|
- function isCheckmate
|
||||||
|
- _...4 more_
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala`
|
||||||
|
- class DefaultRules
|
||||||
|
- function loop
|
||||||
|
- function toMoves
|
||||||
|
- function loop
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/Main.scala` — class Main, function main
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala`
|
||||||
|
- class ChessBoardView
|
||||||
|
- function updateBoard
|
||||||
|
- function updateUndoRedoButtons
|
||||||
|
- function showMessage
|
||||||
|
- function showPromotionDialog
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala`
|
||||||
|
- class ChessGUIApp
|
||||||
|
- class ChessGUILauncher
|
||||||
|
- function getEngine
|
||||||
|
- function launch
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala` — class GUIObserver
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala`
|
||||||
|
- class PieceSprites
|
||||||
|
- function loadPieceImage
|
||||||
|
- class SquareColors
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala` — class TerminalUI, function start
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/utils/PieceUnicode.scala` — function unicode
|
||||||
|
- `modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala` — class Renderer, function render
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# NowChessSystems — Wiki
|
||||||
|
|
||||||
|
_Generated 2026-04-12 — re-run `npx codesight --wiki` if the codebase has changed._
|
||||||
|
|
||||||
|
Structural map compiled from source code via AST. No LLM — deterministic, 200ms.
|
||||||
|
|
||||||
|
> **How to use safely:** These articles tell you WHERE things live and WHAT exists. They do not show full implementation logic. Always read the actual source files before implementing new features or making changes. Never infer how a function works from the wiki alone.
|
||||||
|
|
||||||
|
## Articles
|
||||||
|
|
||||||
|
- [Overview](./overview.md)
|
||||||
|
|
||||||
|
## Quick Stats
|
||||||
|
|
||||||
|
- Routes: **0**
|
||||||
|
- Models: **0**
|
||||||
|
- Components: **0**
|
||||||
|
- Env vars: **0** required, **0** with defaults
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
- **New session:** read `index.md` (this file) for orientation — WHERE things are
|
||||||
|
- **Architecture question:** read `overview.md` (~500 tokens)
|
||||||
|
- **Domain question:** read the relevant article, then **read those source files**
|
||||||
|
- **Database question:** read `database.md`, then read the actual schema files
|
||||||
|
- **Before implementing anything:** read the source files listed in the article
|
||||||
|
- **Full source context:** read `.codesight/CODESIGHT.md`
|
||||||
|
|
||||||
|
## What the Wiki Does Not Cover
|
||||||
|
|
||||||
|
These exist in your codebase but are **not** reflected in wiki articles:
|
||||||
|
- Routes registered dynamically at runtime (loops, plugin factories, `app.use(dynamicRouter)`)
|
||||||
|
- Internal routes from npm packages (e.g. Better Auth's built-in `/api/auth/*` endpoints)
|
||||||
|
- WebSocket and SSE handlers
|
||||||
|
- Raw SQL tables not declared through an ORM
|
||||||
|
- Computed or virtual fields absent from schema declarations
|
||||||
|
- TypeScript types that are not actual database columns
|
||||||
|
- Routes marked `[inferred]` were detected via regex and may have lower precision
|
||||||
|
- gRPC, tRPC, and GraphQL resolvers may be partially captured
|
||||||
|
|
||||||
|
When in doubt, search the source. The wiki is a starting point, not a complete inventory.
|
||||||
|
|
||||||
|
---
|
||||||
|
_Last compiled: 2026-04-12 · 2 articles · [codesight](https://github.com/Houseofmvps/codesight)_
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Wiki Log
|
||||||
|
|
||||||
|
History of `npx codesight --wiki` runs. Capped at 20 entries.
|
||||||
|
|
||||||
|
## [2026-04-12 14:34:19] scan | 0 routes, 0 models, 0 components → 2 articles
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# NowChessSystems — Overview
|
||||||
|
|
||||||
|
> **Navigation aid.** This article shows WHERE things live (routes, models, files). Read actual source files before implementing new features or making changes.
|
||||||
|
|
||||||
|
**NowChessSystems** is a scala project built with raw-http.
|
||||||
|
|
||||||
|
## High-Impact Files
|
||||||
|
|
||||||
|
Changes to these files have the widest blast radius across the codebase:
|
||||||
|
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **28** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **21** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **19** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **14** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **13** files
|
||||||
|
- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **10** files
|
||||||
|
|
||||||
|
---
|
||||||
|
_Back to [index.md](./index.md) · Generated 2026-04-12_
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Normalize text files in the repo
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Keep Windows command scripts in CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Keep Unix shell scripts in LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Binary assets (no EOL normalization / textual diff)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.bmp binary
|
||||||
|
*.ico binary
|
||||||
|
|
||||||
|
# ML / model / numeric artifacts
|
||||||
|
*.bin binary
|
||||||
|
*.pt binary
|
||||||
|
*.pth binary
|
||||||
|
*.onnx binary
|
||||||
|
*.h5 binary
|
||||||
|
*.hdf5 binary
|
||||||
|
*.pb binary
|
||||||
|
*.tflite binary
|
||||||
|
*.npy binary
|
||||||
|
*.npz binary
|
||||||
|
*.safetensors binary
|
||||||
|
|
||||||
|
# Firmware / hex-like artifacts
|
||||||
|
*.hex binary
|
||||||
|
|
||||||
|
# Packaged binaries
|
||||||
|
*.jar binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.gz binary
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ bin/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
graphify-out/
|
||||||
|
.graphify_*.json
|
||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+3
@@ -11,7 +11,10 @@
|
|||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
<option value="$PROJECT_DIR$/modules/api" />
|
<option value="$PROJECT_DIR$/modules/api" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/backcore" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/io" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
<option value="$PROJECT_DIR$/modules/ui" />
|
<option value="$PROJECT_DIR$/modules/ui" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</profile>
|
||||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
<profile name="Gradle 2" modules="NowChessSystems.modules.backcore.integrationTest,NowChessSystems.modules.backcore.main,NowChessSystems.modules.backcore.native-test,NowChessSystems.modules.backcore.quarkus-generated-sources,NowChessSystems.modules.backcore.quarkus-test-generated-sources,NowChessSystems.modules.backcore.scoverage,NowChessSystems.modules.backcore.test,NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<parameters>
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ScalaProjectSettings">
|
||||||
|
<option name="scala3DisclaimerShown" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
rules = [
|
||||||
|
DisableSyntax,
|
||||||
|
LeakingImplicitClassVal,
|
||||||
|
NoValInForComprehension,
|
||||||
|
ProcedureSyntax,
|
||||||
|
]
|
||||||
|
|
||||||
|
DisableSyntax.noVars = true
|
||||||
|
DisableSyntax.noThrows = true
|
||||||
|
DisableSyntax.noNulls = true
|
||||||
|
DisableSyntax.noReturns = true
|
||||||
|
DisableSyntax.noAsInstanceOf = true
|
||||||
|
DisableSyntax.noIsInstanceOf = true
|
||||||
|
DisableSyntax.noXml = true
|
||||||
|
DisableSyntax.noFinalize = true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
version = 3.8.1
|
||||||
|
runner.dialect = scala3
|
||||||
|
maxColumn = 120
|
||||||
|
indent.main = 2
|
||||||
|
align.preset = more
|
||||||
|
trailingCommas = always
|
||||||
|
rewrite.rules = [SortImports, RedundantBraces]
|
||||||
|
rewrite.scala3.convertToNewSyntax = true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Project Context
|
||||||
|
|
||||||
|
This is a scala project using raw-http.
|
||||||
|
|
||||||
|
Middleware includes: custom.
|
||||||
|
|
||||||
|
High-impact files (most imported, changes here affect many other files):
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala (imported by 50 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/board/Square.scala (imported by 33 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/board/Color.scala (imported by 30 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/move/Move.scala (imported by 29 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/board/Board.scala (imported by 19 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala (imported by 18 files)
|
||||||
|
- modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala (imported by 17 files)
|
||||||
|
- modules/api/src/main/scala/de/nowchess/api/board/Piece.scala (imported by 15 files)
|
||||||
|
|
||||||
|
Required environment variables (no defaults):
|
||||||
|
- STOCKFISH_PATH (modules/bot/python/nnue.py)
|
||||||
|
|
||||||
|
Read .codesight/wiki/index.md for orientation (WHERE things live). Then read actual source files before implementing. Wiki articles are navigation aids, not implementation guides.
|
||||||
|
Read .codesight/CODESIGHT.md for the complete AI context map including all routes, schema, components, libraries, config, middleware, and dependency graph.
|
||||||
@@ -1,58 +1,95 @@
|
|||||||
# CLAUDE.md — NowChessSystems
|
# Now-Chess
|
||||||
|
|
||||||
## Stack
|
Scala 3.5.1 · Gradle 9
|
||||||
Scala 3.5.x · Quarkus + quarkus-scala3 · Hibernate/Jakarta · Lanterna TUI · K8s + ArgoCD + Kargo · Frontend TBD (Vite/React/Angular/Vue)
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
|
|
||||||
Your memory is saved under .claude/memory/MEMORY.md.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
```
|
|
||||||
build.gradle.kts / settings.gradle.kts # root; include(":modules:<svc>") per service
|
|
||||||
modules/<svc>/build.gradle.kts + src/
|
|
||||||
docs/adr/ docs/api/ docs/unresolved.md
|
|
||||||
```
|
|
||||||
Versions in root `extra["VERSIONS"]`; modules read via `rootProject.extra["VERSIONS"] as Map<String,String>`.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
```bash
|
|
||||||
./gradlew build
|
|
||||||
./gradlew :modules:<svc>:build|test
|
|
||||||
./gradlew :modules:<svc>:test --tests "de.nowchess.<svc>.<Class>"
|
|
||||||
```
|
```
|
||||||
|
./clean # Clear build dirs — only when necessary
|
||||||
## Workflow
|
./compile # Compile all modules — always run
|
||||||
1. **Plan** — restate requirement, list files, flag risks. Proceed unless genuine ambiguity.
|
./test # Run all tests
|
||||||
2. **Tests first** — cover only new behaviour.
|
./coverage # Check coverage
|
||||||
3. **Implement** — no scope creep.
|
|
||||||
4. **Verify** — check each requirement; confirm green build.
|
|
||||||
|
|
||||||
## Scala/Quarkus Rules
|
|
||||||
- `given`/`using` only (no `implicit`); `Option`/`Either`/`Try` (no `null`/`.get`)
|
|
||||||
- `jakarta.*` only; reactive I/O (`Uni`/`Multi`), no blocking on event loop
|
|
||||||
- Always exclude `org.scala-lang:scala-library` from Quarkus BOM
|
|
||||||
- Unit tests: `extends AnyFunSuite with Matchers` — no `@Test`, no `: Unit`
|
|
||||||
- Integration tests: `@QuarkusTest` + JUnit 5 — `@Test` methods need explicit `: Unit`
|
|
||||||
|
|
||||||
## Coverage
|
|
||||||
Line = 100% · Branch = 100% · Method = 100% · Regression tests · document exceptions
|
|
||||||
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
|
|
||||||
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
|
|
||||||
|
|
||||||
## Bug Fixing
|
|
||||||
Fix failures immediately without asking. After 3 failed attempts → log in `docs/unresolved.md` + surface summary.
|
|
||||||
|
|
||||||
## Agents (new service)
|
|
||||||
Sequential: architect → scala-implementer → test-writer → gradle-builder → code-reviewer (review only, no self-fix)
|
|
||||||
Parallel: only when services are fully independent (no shared contracts/state).
|
|
||||||
|
|
||||||
## Unresolved (`docs/unresolved.md`)
|
|
||||||
Append only, never delete:
|
|
||||||
```
|
|
||||||
## [YYYY-MM-DD] <title>
|
|
||||||
**Requirement/Bug:** **Root Cause:** **Attempted Fixes:** **Next Step:**
|
|
||||||
```
|
```
|
||||||
|
Try to stick to these commands for consistency.
|
||||||
|
|
||||||
## Done Checklist
|
## Modules
|
||||||
- [ ] Plan written · files created/modified · tests green · requirements verified · unresolved logged
|
|
||||||
|
| Module | Role | Depends on |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| `api` | Model / shared types | (none) |
|
||||||
|
| `core` | Primary business logic | api, rule |
|
||||||
|
| `rule` | Game rules | api |
|
||||||
|
| `io` | Export formats | api, core |
|
||||||
|
| `ui` | Entrypoint & UI | core, io |
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Use immutable data and pure functions.
|
||||||
|
- Keep functions under 30 lines. If you need "and" to describe it, split it.
|
||||||
|
- Keep cyclomatic complexity under 15.
|
||||||
|
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
|
||||||
|
- Scan for duplicated logic before finishing. Extract it.
|
||||||
|
- Follow default Sonar style for Scala.
|
||||||
|
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
|
||||||
|
- Naming: types are PascalCase, functions/values are camelCase.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
+39
-1
@@ -1,6 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.sonarqube") version "7.2.3.7755"
|
id("org.sonarqube") version "7.2.3.7755"
|
||||||
id("org.scoverage") version "8.1" apply false
|
id("org.scoverage") version "8.1" apply false
|
||||||
|
id("com.diffplug.spotless") version "8.4.0" apply false
|
||||||
|
id("io.github.cosmicsilence.scalafix") version "0.2.6" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -19,7 +21,15 @@ sonar {
|
|||||||
if (report.exists()) report.absolutePath else null
|
if (report.exists()) report.absolutePath else null
|
||||||
}.joinToString(",")
|
}.joinToString(",")
|
||||||
|
|
||||||
|
val jacocoReports = subprojects.mapNotNull { subproject ->
|
||||||
|
val report = subproject.file("build/reports/jacoco/test/jacocoTestReport.xml")
|
||||||
|
if (report.exists()) report.absolutePath else null
|
||||||
|
}.joinToString(",")
|
||||||
|
|
||||||
property("sonar.scala.coverage.reportPaths", scoverageReports)
|
property("sonar.scala.coverage.reportPaths", scoverageReports)
|
||||||
|
if (jacocoReports.isNotEmpty()) {
|
||||||
|
property("sonar.coverage.jacoco.xmlReportPaths", jacocoReports)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +42,35 @@ val versions = mapOf(
|
|||||||
"SCOVERAGE" to "2.1.1",
|
"SCOVERAGE" to "2.1.1",
|
||||||
"SCALAFX" to "21.0.0-R32",
|
"SCALAFX" to "21.0.0-R32",
|
||||||
"JAVAFX" to "21.0.1",
|
"JAVAFX" to "21.0.1",
|
||||||
"JUNIT_BOM" to "5.13.4"
|
"JUNIT_BOM" to "5.13.4",
|
||||||
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
|
"FASTPARSE" to "3.0.2",
|
||||||
|
"JACKSON" to "2.17.2",
|
||||||
|
"JACKSON_SCALA" to "2.17.2"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply(plugin = "com.diffplug.spotless")
|
||||||
|
|
||||||
|
pluginManager.withPlugin("scala") {
|
||||||
|
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||||
|
scala {
|
||||||
|
scalafmt().configFile(rootProject.file(".scalafmt.conf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(plugin = "io.github.cosmicsilence.scalafix")
|
||||||
|
configure<io.github.cosmicsilence.scalafix.ScalafixExtension> {
|
||||||
|
configFile.set(rootProject.file(".scalafix.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable SemanticDB config for the scoverage source set — it sets -sourceroot to
|
||||||
|
// the root project dir, which conflicts with scoverage's own -sourceroot and causes
|
||||||
|
// reportTestScoverage to fail with "No source root found".
|
||||||
|
tasks.matching { it.name in setOf("configSemanticDBScoverage", "checkScalafixScoverage", "checkScalafixTest") }.configureEach {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#! /usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
./gradlew classes
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#! /usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||||
|
else
|
||||||
|
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: NowChess API
|
||||||
|
description: |
|
||||||
|
REST API for the NowChess application. Designed to feel familiar to users
|
||||||
|
of the [lichess API](https://lichess.org/api).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
Most endpoints require a Bearer token:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
Authentication is reserved for future implementation — endpoints are currently
|
||||||
|
open unless noted otherwise.
|
||||||
|
|
||||||
|
## Move notation
|
||||||
|
Moves are expressed in **UCI notation**: `{from}{to}[promotion]`
|
||||||
|
- Normal move: `e2e4`
|
||||||
|
- Capture: `d5e6`
|
||||||
|
- Promotion: `e7e8q` (q=queen, r=rook, b=bishop, n=knight)
|
||||||
|
- Castling: `e1g1` (kingside white), `e1c1` (queenside white)
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
Endpoints that support streaming return **NDJSON** (newline-delimited JSON).
|
||||||
|
Request them with:
|
||||||
|
```
|
||||||
|
Accept: application/x-ndjson
|
||||||
|
```
|
||||||
|
Each line of the response is a complete JSON object. Empty lines are
|
||||||
|
keep-alive heartbeats.
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
Requests that exceed the rate limit receive `429 Too Many Requests`.
|
||||||
|
Honour the `Retry-After` response header and wait before retrying.
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: NowChess
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Local development server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: game
|
||||||
|
description: Create and manage chess games
|
||||||
|
- name: move
|
||||||
|
description: Make moves and navigate game history
|
||||||
|
- name: draw
|
||||||
|
description: Draw offers and claims
|
||||||
|
- name: import
|
||||||
|
description: Load a game from FEN or PGN
|
||||||
|
- name: export
|
||||||
|
description: Export a game as FEN or PGN
|
||||||
|
|
||||||
|
paths:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Game lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game:
|
||||||
|
post:
|
||||||
|
operationId: createGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Create a new game
|
||||||
|
description: |
|
||||||
|
Creates a new chess game starting from the initial position.
|
||||||
|
Returns the full game state including the generated `gameId`.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateGameRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}:
|
||||||
|
get:
|
||||||
|
operationId: getGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Get game state
|
||||||
|
description: Returns the full current state of a game.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current game state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/stream:
|
||||||
|
get:
|
||||||
|
operationId: streamGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Stream game events
|
||||||
|
description: |
|
||||||
|
Opens a persistent NDJSON stream for a game. The first object sent is
|
||||||
|
a `gameFull` event containing the complete game state. Subsequent
|
||||||
|
objects are `gameState` events sent whenever the game changes (move
|
||||||
|
made, draw offered, game over, etc.).
|
||||||
|
|
||||||
|
Empty lines are heartbeats to keep the connection alive.
|
||||||
|
|
||||||
|
Connect with:
|
||||||
|
```
|
||||||
|
Accept: application/x-ndjson
|
||||||
|
```
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: NDJSON event stream
|
||||||
|
content:
|
||||||
|
application/x-ndjson:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/GameFullEvent'
|
||||||
|
- $ref: '#/components/schemas/GameStateEvent'
|
||||||
|
- $ref: '#/components/schemas/ErrorEvent'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/resign:
|
||||||
|
post:
|
||||||
|
operationId: resignGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Resign the game
|
||||||
|
description: The active player resigns. The game ends immediately.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Resignation accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Move-making
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/move/{uci}:
|
||||||
|
post:
|
||||||
|
operationId: makeMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Make a move
|
||||||
|
description: |
|
||||||
|
Submit a move in UCI notation. The move must be legal for the side
|
||||||
|
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.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: uci
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Move in UCI notation (e.g. `e2e4`, `e7e8q`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-h][1-8][a-h][1-8][qrbn]?$'
|
||||||
|
example: e2e4
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move applied — returns updated game state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/moves:
|
||||||
|
get:
|
||||||
|
operationId: getLegalMoves
|
||||||
|
tags: [move]
|
||||||
|
summary: Get legal moves
|
||||||
|
description: |
|
||||||
|
Returns all legal moves for the side currently to move.
|
||||||
|
Optionally filter to moves originating from a single square.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: square
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Filter to moves from this square (e.g. `e2`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-h][1-8]$'
|
||||||
|
example: e2
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of legal moves
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LegalMovesResponse'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/undo:
|
||||||
|
post:
|
||||||
|
operationId: undoMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Undo the last move
|
||||||
|
description: Reverts the most recent move. Returns the updated game state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move undone
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
description: No moves to undo
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/redo:
|
||||||
|
post:
|
||||||
|
operationId: redoMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Redo a previously undone move
|
||||||
|
description: Re-applies the next move in the undo stack. Returns the updated game state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move redone
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
description: No moves to redo
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Draw handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/draw/{action}:
|
||||||
|
post:
|
||||||
|
operationId: drawAction
|
||||||
|
tags: [draw]
|
||||||
|
summary: Offer, accept, decline, or claim a draw
|
||||||
|
description: |
|
||||||
|
Perform a draw-related action:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `offer` | Offer a draw to the opponent |
|
||||||
|
| `accept` | Accept the opponent's draw offer |
|
||||||
|
| `decline` | Decline the opponent's draw offer |
|
||||||
|
| `claim` | Claim a draw under the fifty-move rule (only valid when `status` is `fiftyMoveAvailable`) |
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: action
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [offer, accept, decline, claim]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Action accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/import/fen:
|
||||||
|
post:
|
||||||
|
operationId: importFen
|
||||||
|
tags: [import]
|
||||||
|
summary: Load a position from FEN
|
||||||
|
description: |
|
||||||
|
Creates a new game from a FEN string. The game starts at the position
|
||||||
|
described by the FEN; move history prior to that position is not
|
||||||
|
available.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ImportFenRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created from FEN
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/import/pgn:
|
||||||
|
post:
|
||||||
|
operationId: importPgn
|
||||||
|
tags: [import]
|
||||||
|
summary: Load a game from PGN
|
||||||
|
description: |
|
||||||
|
Creates a new game by replaying all moves in a PGN string. The game
|
||||||
|
starts at the position after the final move in the PGN; undo is
|
||||||
|
available for every replayed move.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ImportPgnRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created from PGN
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/export/fen:
|
||||||
|
get:
|
||||||
|
operationId: exportFen
|
||||||
|
tags: [export]
|
||||||
|
summary: Export current position as FEN
|
||||||
|
description: Returns the FEN string representing the current board position.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: FEN string
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/export/pgn:
|
||||||
|
get:
|
||||||
|
operationId: exportPgn
|
||||||
|
tags: [export]
|
||||||
|
summary: Export game as PGN
|
||||||
|
description: Returns the full PGN for the game including headers and move text.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: PGN text
|
||||||
|
content:
|
||||||
|
application/x-chess-pgn:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: |
|
||||||
|
[Event "NowChess game"]
|
||||||
|
[White "Player1"]
|
||||||
|
[Black "Player2"]
|
||||||
|
[Result "*"]
|
||||||
|
|
||||||
|
1. e4 e5 2. Nf3 *
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Components
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
components:
|
||||||
|
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: 'Personal access token — `Authorization: Bearer <token>`'
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
gameId:
|
||||||
|
name: gameId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 8-character alphanumeric game ID (e.g. `Qa7FJNk2`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Za-z0-9]{8}$'
|
||||||
|
example: Qa7FJNk2
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid input
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
Unauthorized:
|
||||||
|
description: Missing or invalid authentication token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
NotFound:
|
||||||
|
description: Game not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
TooManyRequests:
|
||||||
|
description: Rate limit exceeded — see `Retry-After` header
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
description: Seconds to wait before retrying
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Requests
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CreateGameRequest:
|
||||||
|
type: object
|
||||||
|
description: Parameters for creating a new game. All fields are optional.
|
||||||
|
properties:
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
|
||||||
|
ImportFenRequest:
|
||||||
|
type: object
|
||||||
|
required: [fen]
|
||||||
|
properties:
|
||||||
|
fen:
|
||||||
|
type: string
|
||||||
|
description: Complete FEN string (6 fields)
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
|
||||||
|
ImportPgnRequest:
|
||||||
|
type: object
|
||||||
|
required: [pgn]
|
||||||
|
properties:
|
||||||
|
pgn:
|
||||||
|
type: string
|
||||||
|
description: PGN text (headers and move list)
|
||||||
|
example: "1. e4 e5 2. Nf3 Nc6 *"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Game state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GameFull:
|
||||||
|
type: object
|
||||||
|
description: Complete game information including players and current state.
|
||||||
|
required: [gameId, white, black, state]
|
||||||
|
properties:
|
||||||
|
gameId:
|
||||||
|
type: string
|
||||||
|
description: Unique 8-character game identifier
|
||||||
|
example: Qa7FJNk2
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
state:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
|
||||||
|
GameState:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
The current game state. Included in `GameFull` and returned by move
|
||||||
|
endpoints and stream events.
|
||||||
|
required: [fen, pgn, turn, status, moves, undoAvailable, redoAvailable]
|
||||||
|
properties:
|
||||||
|
fen:
|
||||||
|
type: string
|
||||||
|
description: FEN string for the current position
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
|
||||||
|
pgn:
|
||||||
|
type: string
|
||||||
|
description: PGN move text for the full game so far
|
||||||
|
example: "1. e4"
|
||||||
|
turn:
|
||||||
|
type: string
|
||||||
|
enum: [white, black]
|
||||||
|
description: The side to move
|
||||||
|
status:
|
||||||
|
$ref: '#/components/schemas/GameStatus'
|
||||||
|
winner:
|
||||||
|
type: string
|
||||||
|
enum: [white, black]
|
||||||
|
description: Set when `status` is `checkmate` or `resign`
|
||||||
|
nullable: true
|
||||||
|
moves:
|
||||||
|
type: array
|
||||||
|
description: All moves played so far, in UCI notation
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: [e2e4, e7e5, g1f3]
|
||||||
|
undoAvailable:
|
||||||
|
type: boolean
|
||||||
|
description: Whether `POST /undo` is currently valid
|
||||||
|
redoAvailable:
|
||||||
|
type: boolean
|
||||||
|
description: Whether `POST /redo` is currently valid
|
||||||
|
|
||||||
|
GameStatus:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Current game status:
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `started` | Game in progress, no special condition |
|
||||||
|
| `check` | Side to move is in check |
|
||||||
|
| `checkmate` | Side to move is checkmated — game over |
|
||||||
|
| `stalemate` | Side to move has no legal moves, not in check — game over (draw) |
|
||||||
|
| `resign` | A player resigned — game over |
|
||||||
|
| `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
|
||||||
|
- check
|
||||||
|
- checkmate
|
||||||
|
- stalemate
|
||||||
|
- resign
|
||||||
|
- draw
|
||||||
|
- drawOffered
|
||||||
|
- fiftyMoveAvailable
|
||||||
|
- promotionPending
|
||||||
|
- insufficientMaterial
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Moves
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LegalMovesResponse:
|
||||||
|
type: object
|
||||||
|
required: [moves]
|
||||||
|
properties:
|
||||||
|
moves:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LegalMove'
|
||||||
|
|
||||||
|
LegalMove:
|
||||||
|
type: object
|
||||||
|
required: [from, to, uci, moveType]
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
type: string
|
||||||
|
description: Origin square in algebraic notation
|
||||||
|
example: e2
|
||||||
|
to:
|
||||||
|
type: string
|
||||||
|
description: Destination square in algebraic notation
|
||||||
|
example: e4
|
||||||
|
uci:
|
||||||
|
type: string
|
||||||
|
description: Full move in UCI notation
|
||||||
|
example: e2e4
|
||||||
|
moveType:
|
||||||
|
$ref: '#/components/schemas/MoveType'
|
||||||
|
promotion:
|
||||||
|
type: string
|
||||||
|
enum: [queen, rook, bishop, knight]
|
||||||
|
description: Target piece for promotion moves
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
MoveType:
|
||||||
|
type: string
|
||||||
|
description: Classification of the move
|
||||||
|
enum:
|
||||||
|
- normal
|
||||||
|
- capture
|
||||||
|
- castleKingside
|
||||||
|
- castleQueenside
|
||||||
|
- enPassant
|
||||||
|
- promotion
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Streaming events
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GameFullEvent:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
First event on a game stream. Contains the complete game snapshot.
|
||||||
|
required: [type, game]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [gameFull]
|
||||||
|
game:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
|
||||||
|
GameStateEvent:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Emitted on a game stream whenever the game state changes (move played,
|
||||||
|
draw offered, game over, etc.).
|
||||||
|
required: [type, state]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [gameState]
|
||||||
|
state:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
|
||||||
|
ErrorEvent:
|
||||||
|
type: object
|
||||||
|
description: Emitted on a game stream when an error occurs.
|
||||||
|
required: [type, error]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [error]
|
||||||
|
error:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Shared types
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PlayerInfo:
|
||||||
|
type: object
|
||||||
|
required: [id, displayName]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Unique player identifier
|
||||||
|
example: player1
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
description: Human-readable display name
|
||||||
|
example: Alice
|
||||||
|
|
||||||
|
OkResponse:
|
||||||
|
type: object
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
enum: [true]
|
||||||
|
|
||||||
|
ApiError:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code
|
||||||
|
example: INVALID_MOVE
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Human-readable error description
|
||||||
|
example: e2e5 is not a legal move
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Request field that caused the error, if applicable
|
||||||
|
example: uci
|
||||||
|
nullable: true
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
## [2026-03-31] Unreachable code blocking 100% statement coverage
|
|
||||||
|
|
||||||
**Requirement/Bug:** Reach 100% statement coverage in core module.
|
|
||||||
|
|
||||||
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
|
|
||||||
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
|
|
||||||
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
|
|
||||||
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
|
|
||||||
|
|
||||||
**Attempted Fixes:**
|
|
||||||
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
|
|
||||||
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
|
|
||||||
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
|
|
||||||
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
|
|
||||||
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
|
|
||||||
|
|
||||||
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
|
|
||||||
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
|
|
||||||
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
|
|
||||||
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
quarkusPluginId=io.quarkus
|
||||||
|
quarkusPluginVersion=3.32.4
|
||||||
|
quarkusPlatformGroupId=io.quarkus.platform
|
||||||
|
quarkusPlatformArtifactId=quarkus-bom
|
||||||
|
quarkusPlatformVersion=3.32.4
|
||||||
@@ -19,6 +19,9 @@ Usage:
|
|||||||
python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default)
|
python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default)
|
||||||
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller
|
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller
|
||||||
python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80
|
python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80
|
||||||
|
python scoverage_coverage_gaps.py (default: scans ./modules)
|
||||||
|
python scoverage_coverage_gaps.py --modules-dir ./services
|
||||||
|
python scoverage_coverage_gaps.py <scoverage.xml>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
@@ -26,7 +29,8 @@ import sys
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path, PureWindowsPath
|
import glob
|
||||||
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -112,7 +116,6 @@ class ClassGap:
|
|||||||
@property
|
@property
|
||||||
def uncovered_branch_lines(self) -> list[int]:
|
def uncovered_branch_lines(self) -> list[int]:
|
||||||
"""Lines that are branch points and have at least one uncovered branch statement."""
|
"""Lines that are branch points and have at least one uncovered branch statement."""
|
||||||
# Group branch statements by line; a line is "partial" if some covered, some not
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
by_line: dict[int, list[Statement]] = defaultdict(list)
|
by_line: dict[int, list[Statement]] = defaultdict(list)
|
||||||
for s in self.statements:
|
for s in self.statements:
|
||||||
@@ -120,10 +123,7 @@ class ClassGap:
|
|||||||
by_line[s.line].append(s)
|
by_line[s.line].append(s)
|
||||||
partial = []
|
partial = []
|
||||||
for line, stmts in by_line.items():
|
for line, stmts in by_line.items():
|
||||||
has_covered = any(s.is_covered for s in stmts)
|
if any(s.is_uncovered for s in stmts):
|
||||||
has_uncovered = any(s.is_uncovered for s in stmts)
|
|
||||||
# Report line if any branch arm is uncovered
|
|
||||||
if has_uncovered:
|
|
||||||
partial.append(line)
|
partial.append(line)
|
||||||
return sorted(partial)
|
return sorted(partial)
|
||||||
|
|
||||||
@@ -169,20 +169,10 @@ class ClassGap:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _normalise_source(raw: str) -> str:
|
def _normalise_source(raw: str) -> str:
|
||||||
"""
|
|
||||||
Convert an absolute Windows or Unix source path from the XML into a
|
|
||||||
relative src/main/scala/… path for agent consumption.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Replace Windows backslashes.
|
|
||||||
2. Find the 'src/' anchor and take everything from there.
|
|
||||||
3. Fall back to the package-derived path if no anchor found.
|
|
||||||
"""
|
|
||||||
normalised = raw.replace("\\", "/")
|
normalised = raw.replace("\\", "/")
|
||||||
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
|
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
# Fallback: just the filename portion
|
|
||||||
return normalised.split("/")[-1]
|
return normalised.split("/")[-1]
|
||||||
|
|
||||||
|
|
||||||
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
|
|||||||
# Parser
|
# Parser
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
def parse_scoverage_xml(xml_path: str) -> tuple[dict, list[ClassGap]]:
|
||||||
tree = ET.parse(xml_path)
|
tree = ET.parse(xml_path)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
|
|
||||||
# ── Authoritative project-level totals from <scoverage> root element ──────
|
|
||||||
project_stats = {
|
project_stats = {
|
||||||
"total_statements": int(root.get("statement-count", 0)),
|
"total_statements": int(root.get("statement-count", 0)),
|
||||||
"covered_statements": int(root.get("statements-invoked", 0)),
|
"covered_statements": int(root.get("statements-invoked", 0)),
|
||||||
@@ -205,14 +194,13 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
project_stats["total_statements"] - project_stats["covered_statements"]
|
project_stats["total_statements"] - project_stats["covered_statements"]
|
||||||
)
|
)
|
||||||
|
|
||||||
class_map: dict[str, ClassGap] = {} # full-class-name → ClassGap
|
class_map: dict[str, ClassGap] = {}
|
||||||
|
|
||||||
for package in root.findall("packages/package"):
|
for package in root.findall("packages/package"):
|
||||||
for cls_elem in package.findall("classes/class"):
|
for cls_elem in package.findall("classes/class"):
|
||||||
class_name = cls_elem.get("name", "")
|
class_name = cls_elem.get("name", "")
|
||||||
filename = cls_elem.get("filename", "")
|
filename = cls_elem.get("filename", "")
|
||||||
|
|
||||||
# Authoritative per-class totals from <class> attributes
|
|
||||||
cls_total = int(cls_elem.get("statement-count", 0))
|
cls_total = int(cls_elem.get("statement-count", 0))
|
||||||
cls_invoked = int(cls_elem.get("statements-invoked", 0))
|
cls_invoked = int(cls_elem.get("statements-invoked", 0))
|
||||||
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0))
|
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0))
|
||||||
@@ -221,11 +209,8 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
for method_elem in cls_elem.findall("methods/method"):
|
for method_elem in cls_elem.findall("methods/method"):
|
||||||
method_name = method_elem.get("name", "")
|
method_name = method_elem.get("name", "")
|
||||||
|
|
||||||
# Authoritative per-method totals from <method> attributes
|
|
||||||
m_total = int(method_elem.get("statement-count", 0))
|
m_total = int(method_elem.get("statement-count", 0))
|
||||||
m_invoked = int(method_elem.get("statements-invoked", 0))
|
m_invoked = int(method_elem.get("statements-invoked", 0))
|
||||||
m_stmt_rate = float(method_elem.get("statement-rate", 0.0))
|
|
||||||
m_br_rate = float(method_elem.get("branch-rate", 0.0))
|
|
||||||
|
|
||||||
for stmt_elem in method_elem.findall("statements/statement"):
|
for stmt_elem in method_elem.findall("statements/statement"):
|
||||||
raw_source = stmt_elem.get("source", filename)
|
raw_source = stmt_elem.get("source", filename)
|
||||||
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
method=method_name,
|
method=method_name,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Register method-level gap using authoritative XML stats
|
|
||||||
cg = next(
|
cg = next(
|
||||||
(v for v in class_map.values() if v.class_name == class_name),
|
(v for v in class_map.values() if v.class_name == class_name),
|
||||||
None,
|
None,
|
||||||
@@ -268,7 +252,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
uncov_lines = sorted({s.line for s in active if s.is_uncovered})
|
uncov_lines = sorted({s.line for s in active if s.is_uncovered})
|
||||||
uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
|
uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
|
||||||
if uncov_lines or uncov_branch_lines:
|
if uncov_lines or uncov_branch_lines:
|
||||||
# Count branches from statement-level data (not in method XML attrs)
|
|
||||||
total_b = sum(1 for s in active if s.is_branch)
|
total_b = sum(1 for s in active if s.is_branch)
|
||||||
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
|
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
|
||||||
mg = MethodGap(
|
mg = MethodGap(
|
||||||
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
|||||||
)
|
)
|
||||||
cg.method_gaps.append(mg)
|
cg.method_gaps.append(mg)
|
||||||
|
|
||||||
# ── Project stats injected so formatters never recount from statements ────
|
|
||||||
return project_stats, [cg for cg in class_map.values() if cg.has_gaps]
|
return project_stats, [cg for cg in class_map.values() if cg.has_gaps]
|
||||||
|
|
||||||
|
|
||||||
@@ -310,103 +292,60 @@ def _compact_ranges(numbers: list[int]) -> str:
|
|||||||
# Formatters
|
# Formatters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _pct_bar(pct: float, width: int = 20) -> str:
|
|
||||||
"""Render a compact ASCII progress bar, e.g. [████░░░░░░░░░░░░░░░░] 23.5%"""
|
|
||||||
filled = round(pct / 100 * width)
|
|
||||||
bar = "█" * filled + "░" * (width - filled)
|
|
||||||
return f"[{bar}] {pct:.1f}%"
|
|
||||||
|
|
||||||
|
|
||||||
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
||||||
|
"""
|
||||||
|
Compact agent format — optimised for low token count.
|
||||||
|
Emits only actionable gaps: file path, uncovered lines, branch-gap lines,
|
||||||
|
and a per-method breakdown. No ASCII bars, no redundant tables.
|
||||||
|
"""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
lines.append("# scoverage Coverage Gaps — Agent Action Report")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# ---- Project-level totals (authoritative from <scoverage> root element) ----
|
|
||||||
total_stmts = project_stats["total_statements"]
|
total_stmts = project_stats["total_statements"]
|
||||||
covered_stmts = project_stats["covered_statements"]
|
covered_stmts = project_stats["covered_statements"]
|
||||||
missed_stmts = project_stats["missed_statements"]
|
missed_stmts = project_stats["missed_statements"]
|
||||||
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
||||||
overall_branch_pct = project_stats["branch_coverage_pct"]
|
overall_branch_pct = project_stats["branch_coverage_pct"]
|
||||||
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes)
|
|
||||||
# Branch totals: count from statement data (scoverage root has no branch count attr)
|
|
||||||
total_branches = sum(c.total_branches for c in classes)
|
total_branches = sum(c.total_branches for c in classes)
|
||||||
covered_branches = sum(c.covered_branches for c in classes)
|
covered_branches = sum(c.covered_branches for c in classes)
|
||||||
missed_branches = sum(c.missed_branches for c in classes)
|
missed_branches = total_branches - covered_branches
|
||||||
|
|
||||||
lines.append("## Project Coverage Summary")
|
lines.append("# scoverage Coverage Gaps")
|
||||||
lines.append("")
|
lines.append(
|
||||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
|
||||||
lines.append(f"|-------------------|---------|-------|--------|----------|")
|
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
|
||||||
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |")
|
f"files with gaps: {len(classes)}"
|
||||||
lines.append(f"| Branch paths | {covered_branches:>7} | {total_branches:>5} | {missed_branches:>6} | {_pct_bar(overall_branch_pct)} |")
|
)
|
||||||
lines.append(f"| Files with gaps | {'—':>7} | {len(classes):>5} | {'—':>6} | {'—'} |")
|
|
||||||
lines.append(f"| Lines w/ br. gaps | {'—':>7} | {total_branch_lines:>5} | {'—':>6} | {'—'} |")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Files Requiring Tests")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("> Each entry lists the SOURCE FILE PATH, uncovered LINE NUMBERS,")
|
|
||||||
lines.append("> and the METHODS that contain those gaps.")
|
|
||||||
lines.append("> Write or extend unit/integration tests to exercise these paths.")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
|
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
|
||||||
|
|
||||||
for cls in sorted_classes:
|
for cls in sorted_classes:
|
||||||
lines.append(f"### `{cls.source_path}`")
|
|
||||||
lines.append(f"**Class**: `{cls.class_name}`")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
|
||||||
lines.append(f"|--------------|---------|-------|--------|----------|")
|
|
||||||
lines.append(f"| Statements | {cls.covered_statements:>7} | {cls.total_statements:>5} | {cls.missed_statements:>6} | {_pct_bar(cls.stmt_coverage_pct)} |")
|
|
||||||
if cls.total_branches:
|
|
||||||
lines.append(f"| Branch paths | {cls.covered_branches:>7} | {cls.total_branches:>5} | {cls.missed_branches:>6} | {_pct_bar(cls.branch_coverage_pct)} |")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
uncov = cls.all_uncovered_lines
|
uncov = cls.all_uncovered_lines
|
||||||
if uncov:
|
|
||||||
lines.append("#### ❌ Uncovered Statements")
|
|
||||||
lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
branch_lines = cls.uncovered_branch_lines
|
branch_lines = cls.uncovered_branch_lines
|
||||||
if branch_lines:
|
|
||||||
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)")
|
lines.append(f"## {cls.source_path}")
|
||||||
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`")
|
lines.append(
|
||||||
lines.append("")
|
f"stmt: {cls.stmt_coverage_pct:.1f}% ({cls.missed_statements} missed)"
|
||||||
|
+ (f" | branches: {cls.branch_coverage_pct:.1f}% ({cls.missed_branches} missed)"
|
||||||
|
if cls.total_branches else "")
|
||||||
|
)
|
||||||
|
if uncov:
|
||||||
|
lines.append(f"uncovered lines: {_compact_ranges(uncov)}")
|
||||||
|
only_branch = [l for l in branch_lines if l not in cls.all_uncovered_lines]
|
||||||
|
if only_branch:
|
||||||
|
lines.append(f"partial branches: {_compact_ranges(only_branch)}")
|
||||||
|
|
||||||
if cls.method_gaps:
|
if cls.method_gaps:
|
||||||
lines.append("#### Methods with Gaps")
|
lines.append("methods:")
|
||||||
lines.append("")
|
|
||||||
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
|
|
||||||
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
|
|
||||||
for mg in cls.method_gaps:
|
for mg in cls.method_gaps:
|
||||||
stmt_cell = f"{_pct_bar(mg.stmt_coverage_pct, 10)} ({mg.total_statements - mg.covered_statements}/{mg.total_statements} missed)"
|
parts = [f" {mg.short_name}"]
|
||||||
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a"
|
if mg.uncovered_lines:
|
||||||
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "—"
|
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
|
||||||
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "—"
|
if mg.uncovered_branch_lines:
|
||||||
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |")
|
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
|
||||||
lines.append("")
|
lines.append(" ".join(parts))
|
||||||
|
|
||||||
lines.append("**Action**: Add tests that exercise the lines/branches listed above.")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("## Quick Reference: All Uncovered Locations")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Copy-paste friendly list for IDE navigation or grep:")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("```")
|
|
||||||
for cls in sorted_classes:
|
|
||||||
for ln in cls.all_uncovered_lines:
|
|
||||||
lines.append(f"{cls.source_path}:{ln} # uncovered statement")
|
|
||||||
for ln in cls.uncovered_branch_lines:
|
|
||||||
if ln not in cls.all_uncovered_lines:
|
|
||||||
lines.append(f"{cls.source_path}:{ln} # partial branch")
|
|
||||||
lines.append("```")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scan-modules mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Candidate sub-paths within a module directory where scoverage.xml may live.
|
||||||
|
_SCOVERAGE_SUBPATHS = [
|
||||||
|
# Gradle / default layout
|
||||||
|
"build/reports/scoverageTest/scoverage.xml",
|
||||||
|
# sbt default (scala version wildcard resolved via glob)
|
||||||
|
"target/scala-*/scoverage-report/scoverage.xml",
|
||||||
|
# Maven / flat layout
|
||||||
|
"target/scoverage-report/scoverage.xml",
|
||||||
|
# Already at root of module
|
||||||
|
"scoverage.xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_scoverage_xml(module_dir: Path) -> Optional[Path]:
|
||||||
|
"""Return the first scoverage.xml found inside *module_dir*, or None."""
|
||||||
|
for pattern in _SCOVERAGE_SUBPATHS:
|
||||||
|
hits = sorted(module_dir.glob(pattern))
|
||||||
|
if hits:
|
||||||
|
return hits[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_module_gaps(module_name: str, classes: list[ClassGap], stmt_pct: float) -> str:
|
||||||
|
"""
|
||||||
|
One summary line per module. If coverage is not 100%, append an agent hint.
|
||||||
|
"""
|
||||||
|
if not classes:
|
||||||
|
return f"[{module_name}] stmt: {stmt_pct:.1f}% ✅"
|
||||||
|
|
||||||
|
line = f"[{module_name}] stmt: {stmt_pct:.1f}% files_with_gaps: {len(classes)}"
|
||||||
|
if stmt_pct < 100.0:
|
||||||
|
line += f" # hint: run ./coverage {module_name} for details"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan_modules(modules_dir: str, package_filter: Optional[str], min_coverage: float) -> None:
|
||||||
|
base = Path(modules_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
module_dirs = sorted(p for p in base.iterdir() if p.is_dir())
|
||||||
|
if not module_dirs:
|
||||||
|
print(f"No sub-directories found in {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for mod_dir in module_dirs:
|
||||||
|
if mod_dir.name.startswith("build"):
|
||||||
|
continue
|
||||||
|
xml_path = _find_scoverage_xml(mod_dir)
|
||||||
|
if xml_path is None:
|
||||||
|
missing.append(mod_dir.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
project_stats, classes = parse_scoverage_xml(str(xml_path))
|
||||||
|
|
||||||
|
if package_filter:
|
||||||
|
classes = [c for c in classes if c.class_name.startswith(package_filter)]
|
||||||
|
if min_coverage > 0:
|
||||||
|
classes = [c for c in classes if c.stmt_coverage_pct < min_coverage]
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
format_module_gaps(mod_dir.name, classes, project_stats["stmt_coverage_pct"])
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n".join(results))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"\n# Modules without scoverage.xml: {', '.join(missing)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Entry point
|
# Entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -519,7 +539,13 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Report missing statement & branch coverage from a scoverage XML report."
|
description="Report missing statement & branch coverage from a scoverage XML report."
|
||||||
)
|
)
|
||||||
parser.add_argument("xml_file", help="Path to scoverage.xml report file")
|
|
||||||
|
# Positional xml_file is optional when --scan-modules is used
|
||||||
|
parser.add_argument(
|
||||||
|
"xml_file",
|
||||||
|
nargs="?",
|
||||||
|
help="Path to scoverage.xml report file (not required with --scan-modules)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", "-o",
|
"--output", "-o",
|
||||||
choices=["agent", "json", "markdown"],
|
choices=["agent", "json", "markdown"],
|
||||||
@@ -537,8 +563,30 @@ def main() -> None:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
|
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
|
||||||
)
|
)
|
||||||
|
# ── Scan-modules mode ──────────────────────────────────────────────────
|
||||||
|
parser.add_argument(
|
||||||
|
"--scan-modules",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Scan every sub-directory of --modules-dir for a scoverage.xml "
|
||||||
|
"and print a compact coverage-gaps summary per module."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modules-dir",
|
||||||
|
default="./modules",
|
||||||
|
help="Root directory that contains one sub-directory per module (default: ./modules)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# ── Scan-modules path (explicit flag, or default when no xml_file given) ──
|
||||||
|
if args.scan_modules or not args.xml_file:
|
||||||
|
run_scan_modules(args.modules_dir, args.package_filter, args.min_coverage)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Single-file path ──────────────────────────────────────────────────
|
||||||
|
|
||||||
xml_path = Path(args.xml_file)
|
xml_path = Path(args.xml_file)
|
||||||
if not xml_path.exists():
|
if not xml_path.exists():
|
||||||
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import glob,re
|
||||||
|
mods=['api','core','io','rule','ui']
|
||||||
|
tot=0
|
||||||
|
for m in mods:
|
||||||
|
s=0
|
||||||
|
for f in glob.glob(f'modules/{m}/build/test-results/test/TEST-*.xml'):
|
||||||
|
txt=open(f,encoding='utf-8').read(300)
|
||||||
|
m2=re.search(r'tests="(\d+)"',txt)
|
||||||
|
if m2:s+=int(m2.group(1))
|
||||||
|
print(f'{m}: {s}')
|
||||||
|
tot+=s
|
||||||
|
print('overall:',tot)
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Gap Reporter
|
||||||
|
Scans JUnit XML test results under modules/*/build/test-results/*.xml and
|
||||||
|
outputs a minimal summary optimised for agent consumption.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python test_gaps.py # scan all modules (default)
|
||||||
|
python test_gaps.py --module chess # single module
|
||||||
|
python test_gaps.py --module all # explicit all
|
||||||
|
python test_gaps.py --modules-dir ./modules
|
||||||
|
python test_gaps.py --results-subdir build/test-results
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestCase:
|
||||||
|
classname: str
|
||||||
|
name: str
|
||||||
|
time: float
|
||||||
|
failure: Optional[str] = None # message if failed
|
||||||
|
error: Optional[str] = None # message if errored
|
||||||
|
skipped: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_class(self) -> str:
|
||||||
|
return self.classname.split(".")[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.failure is not None:
|
||||||
|
return "FAIL"
|
||||||
|
if self.error is not None:
|
||||||
|
return "ERROR"
|
||||||
|
if self.skipped:
|
||||||
|
return "SKIP"
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SuiteResult:
|
||||||
|
name: str
|
||||||
|
total: int
|
||||||
|
failures: int
|
||||||
|
errors: int
|
||||||
|
skipped: int
|
||||||
|
time: float
|
||||||
|
cases: list[TestCase] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> int:
|
||||||
|
return self.total - self.failures - self.errors - self.skipped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_clean(self) -> bool:
|
||||||
|
return self.failures == 0 and self.errors == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_cases(self) -> list[TestCase]:
|
||||||
|
return [c for c in self.cases if c.status in ("FAIL", "ERROR")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped_cases(self) -> list[TestCase]:
|
||||||
|
return [c for c in self.cases if c.skipped]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleResult:
|
||||||
|
name: str
|
||||||
|
suites: list[SuiteResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int: return sum(s.total for s in self.suites)
|
||||||
|
@property
|
||||||
|
def failures(self) -> int: return sum(s.failures for s in self.suites)
|
||||||
|
@property
|
||||||
|
def errors(self) -> int: return sum(s.errors for s in self.suites)
|
||||||
|
@property
|
||||||
|
def skipped(self) -> int: return sum(s.skipped for s in self.suites)
|
||||||
|
@property
|
||||||
|
def passed(self) -> int: return sum(s.passed for s in self.suites)
|
||||||
|
@property
|
||||||
|
def is_clean(self) -> bool: return self.failures == 0 and self.errors == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_cases(self) -> list[TestCase]:
|
||||||
|
return [c for s in self.suites for c in s.bad_cases]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped_cases(self) -> list[TestCase]:
|
||||||
|
return [c for s in self.suites for c in s.skipped_cases]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_suite_xml(xml_path: Path) -> SuiteResult:
|
||||||
|
tree = ET.parse(xml_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Handle both <testsuite> root and <testsuites> wrapper
|
||||||
|
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||||
|
|
||||||
|
# Merge multiple suites from one file into a single SuiteResult
|
||||||
|
total = failures = errors = skipped = 0
|
||||||
|
elapsed = 0.0
|
||||||
|
name = xml_path.stem
|
||||||
|
cases: list[TestCase] = []
|
||||||
|
|
||||||
|
for suite in suites:
|
||||||
|
total += int(suite.get("tests", 0))
|
||||||
|
failures += int(suite.get("failures", 0))
|
||||||
|
errors += int(suite.get("errors", 0))
|
||||||
|
skipped += int(suite.get("skipped", 0))
|
||||||
|
elapsed += float(suite.get("time", 0.0))
|
||||||
|
if suite.get("name"):
|
||||||
|
name = suite.get("name")
|
||||||
|
|
||||||
|
for tc in suite.findall("testcase"):
|
||||||
|
fail_el = tc.find("failure")
|
||||||
|
err_el = tc.find("error")
|
||||||
|
skip_el = tc.find("skipped")
|
||||||
|
cases.append(TestCase(
|
||||||
|
classname=tc.get("classname", ""),
|
||||||
|
name=tc.get("name", ""),
|
||||||
|
time=float(tc.get("time", 0.0)),
|
||||||
|
failure=fail_el.get("message", fail_el.text or "") if fail_el is not None else None,
|
||||||
|
error=err_el.get("message", err_el.text or "") if err_el is not None else None,
|
||||||
|
skipped=skip_el is not None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return SuiteResult(
|
||||||
|
name=name, total=total, failures=failures,
|
||||||
|
errors=errors, skipped=skipped, time=elapsed, cases=cases,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(module_dir: Path, results_subdir: str) -> Optional[ModuleResult]:
|
||||||
|
results_dir = module_dir / results_subdir
|
||||||
|
if not results_dir.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
xml_files = sorted(results_dir.glob("*.xml"))
|
||||||
|
if not xml_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mod = ModuleResult(name=module_dir.name)
|
||||||
|
for xml_path in xml_files:
|
||||||
|
try:
|
||||||
|
mod.suites.append(parse_suite_xml(xml_path))
|
||||||
|
except ET.ParseError:
|
||||||
|
pass # skip malformed files silently
|
||||||
|
return mod if mod.suites else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Formatter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _truncate(text: str, max_len: int = 120) -> str:
|
||||||
|
text = " ".join(text.split()) # collapse whitespace
|
||||||
|
return text[:max_len] + "…" if len(text) > max_len else text
|
||||||
|
|
||||||
|
|
||||||
|
def format_module(mod: ModuleResult) -> str:
|
||||||
|
parts = [f"[{mod.name}]"]
|
||||||
|
|
||||||
|
if mod.is_clean and mod.skipped == 0:
|
||||||
|
parts.append(f"tests: {mod.total} ✅")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
parts.append(f"tests: {mod.total}")
|
||||||
|
if mod.failures: parts.append(f"failed: {mod.failures}")
|
||||||
|
if mod.errors: parts.append(f"errors: {mod.errors}")
|
||||||
|
if mod.skipped: parts.append(f"skipped: {mod.skipped}")
|
||||||
|
|
||||||
|
# Agent hint only when there are actual failures/errors
|
||||||
|
if not mod.is_clean:
|
||||||
|
parts.append(f" # hint: run ./test {mod.name} for details")
|
||||||
|
|
||||||
|
lines = [" ".join(parts)]
|
||||||
|
|
||||||
|
# List each failed/errored test — this IS the actionable info
|
||||||
|
for tc in mod.bad_cases:
|
||||||
|
msg = tc.failure if tc.failure is not None else tc.error
|
||||||
|
label = f" {tc.status}: {tc.short_class} > {tc.name}"
|
||||||
|
if msg:
|
||||||
|
label += f" [{_truncate(msg, 80)}]"
|
||||||
|
lines.append(label)
|
||||||
|
|
||||||
|
# Skipped: compact, one line total
|
||||||
|
if mod.skipped_cases:
|
||||||
|
skipped_names = ", ".join(
|
||||||
|
f"{c.short_class}.{c.name}" for c in mod.skipped_cases[:5]
|
||||||
|
)
|
||||||
|
if len(mod.skipped_cases) > 5:
|
||||||
|
skipped_names += f" (+{len(mod.skipped_cases) - 5} more)"
|
||||||
|
lines.append(f" SKIP: {skipped_names}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(modules_dir: str, results_subdir: str, module_filter: Optional[str]) -> None:
|
||||||
|
base = Path(modules_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve which module dirs to scan
|
||||||
|
if module_filter and module_filter != "all":
|
||||||
|
mod_dir = base / module_filter
|
||||||
|
if not mod_dir.is_dir():
|
||||||
|
print(f"ERROR: module not found: {mod_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
candidates = [mod_dir]
|
||||||
|
else:
|
||||||
|
candidates = sorted(p for p in base.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for mod_dir in candidates:
|
||||||
|
if mod_dir.name.startswith("build"):
|
||||||
|
continue
|
||||||
|
mod = load_module(mod_dir, results_subdir)
|
||||||
|
if mod is None:
|
||||||
|
missing.append(mod_dir.name)
|
||||||
|
continue
|
||||||
|
results.append(format_module(mod))
|
||||||
|
|
||||||
|
print("\n".join(results))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"\n# Modules without test results: {', '.join(missing)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Minimal test-gap reporter for JUnit XML results across modules."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--module", "-m",
|
||||||
|
nargs="?",
|
||||||
|
const="all",
|
||||||
|
default="all",
|
||||||
|
help="Module name to scan, or 'all' (default: all)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modules-dir",
|
||||||
|
default="./modules",
|
||||||
|
help="Root directory containing one sub-directory per module (default: ./modules)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--results-subdir",
|
||||||
|
default="build/test-results/test",
|
||||||
|
help="Sub-path inside each module dir where *.xml files live (default: build/test-results/test)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
filter_ = None if args.module == "all" else args.module
|
||||||
|
run(args.modules_dir, args.results_subdir, filter_)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,3 +5,32 @@
|
|||||||
## (2026-03-31)
|
## (2026-03-31)
|
||||||
## (2026-04-01)
|
## (2026-04-01)
|
||||||
## (2026-04-01)
|
## (2026-04-01)
|
||||||
|
## (2026-04-01)
|
||||||
|
## (2026-04-02)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
## (2026-04-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
## (2026-04-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
## (2026-04-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* 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))
|
||||||
|
## (2026-04-14)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* 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-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))
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("passed", "skipped", "failed")
|
events("skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -14,12 +14,21 @@ object Board:
|
|||||||
val captured = b.get(to)
|
val captured = b.get(to)
|
||||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||||
(updatedBoard, captured)
|
(updatedBoard, captured)
|
||||||
|
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||||
|
val (updatedBoard, _) = b.withMove(move.from, move.to)
|
||||||
|
updatedBoard
|
||||||
def pieces: Map[Square, Piece] = b
|
def pieces: Map[Square, Piece] = b
|
||||||
|
|
||||||
val initial: Board =
|
val initial: Board =
|
||||||
val backRank: Vector[PieceType] = Vector(
|
val backRank: Vector[PieceType] = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
val entries = for
|
val entries = for
|
||||||
fileIdx <- 0 until 8
|
fileIdx <- 0 until 8
|
||||||
@@ -27,7 +36,7 @@ object Board:
|
|||||||
(Color.White, Rank.R1, backRank(fileIdx)),
|
(Color.White, Rank.R1, backRank(fileIdx)),
|
||||||
(Color.White, Rank.R2, PieceType.Pawn),
|
(Color.White, Rank.R2, PieceType.Pawn),
|
||||||
(Color.Black, Rank.R8, backRank(fileIdx)),
|
(Color.Black, Rank.R8, backRank(fileIdx)),
|
||||||
(Color.Black, Rank.R7, PieceType.Pawn)
|
(Color.Black, Rank.R7, PieceType.Pawn),
|
||||||
)
|
)
|
||||||
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
||||||
Board(entries.toMap)
|
Board(entries.toMap)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and
|
||||||
|
* direction.
|
||||||
|
*
|
||||||
|
* @param whiteKingSide
|
||||||
|
* White's king-side castling (0-0) still legally available
|
||||||
|
* @param whiteQueenSide
|
||||||
|
* White's queen-side castling (0-0-0) still legally available
|
||||||
|
* @param blackKingSide
|
||||||
|
* Black's king-side castling (0-0) still legally available
|
||||||
|
* @param blackQueenSide
|
||||||
|
* Black's queen-side castling (0-0-0) still legally available
|
||||||
|
*/
|
||||||
|
final case class CastlingRights(
|
||||||
|
whiteKingSide: Boolean,
|
||||||
|
whiteQueenSide: Boolean,
|
||||||
|
blackKingSide: Boolean,
|
||||||
|
blackQueenSide: Boolean,
|
||||||
|
):
|
||||||
|
/** Check if either side has any castling rights remaining.
|
||||||
|
*/
|
||||||
|
def hasAnyRights: Boolean =
|
||||||
|
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||||
|
|
||||||
|
/** Check if a specific color has any castling rights remaining.
|
||||||
|
*/
|
||||||
|
def hasRights(color: Color): Boolean = color match
|
||||||
|
case Color.White => whiteKingSide || whiteQueenSide
|
||||||
|
case Color.Black => blackKingSide || blackQueenSide
|
||||||
|
|
||||||
|
/** Revoke all castling rights for a specific color.
|
||||||
|
*/
|
||||||
|
def revokeColor(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||||
|
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||||
|
|
||||||
|
/** Revoke a specific castling right.
|
||||||
|
*/
|
||||||
|
def revokeKingSide(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteKingSide = false)
|
||||||
|
case Color.Black => copy(blackKingSide = false)
|
||||||
|
|
||||||
|
/** Revoke a specific castling right.
|
||||||
|
*/
|
||||||
|
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||||
|
case Color.White => copy(whiteQueenSide = false)
|
||||||
|
case Color.Black => copy(blackQueenSide = false)
|
||||||
|
|
||||||
|
object CastlingRights:
|
||||||
|
/** No castling rights for any side. */
|
||||||
|
val None: CastlingRights = CastlingRights(
|
||||||
|
whiteKingSide = false,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All castling rights available. */
|
||||||
|
val All: CastlingRights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = true,
|
||||||
|
blackKingSide = true,
|
||||||
|
blackQueenSide = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||||
|
val Initial: CastlingRights = All
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
/**
|
/** A file (column) on the chess board, a–h. Ordinal values 0–7 correspond to a–h.
|
||||||
* A file (column) on the chess board, a–h.
|
|
||||||
* Ordinal values 0–7 correspond to a–h.
|
|
||||||
*/
|
*/
|
||||||
enum File:
|
enum File:
|
||||||
case A, B, C, D, E, F, G, H
|
case A, B, C, D, E, F, G, H
|
||||||
|
|
||||||
/**
|
/** A rank (row) on the chess board, 1–8. Ordinal values 0–7 correspond to ranks 1–8.
|
||||||
* A rank (row) on the chess board, 1–8.
|
|
||||||
* Ordinal values 0–7 correspond to ranks 1–8.
|
|
||||||
*/
|
*/
|
||||||
enum Rank:
|
enum Rank:
|
||||||
case R1, R2, R3, R4, R5, R6, R7, R8
|
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||||
|
|
||||||
/**
|
/** A unique square on the board, identified by its file and rank.
|
||||||
* A unique square on the board, identified by its file and rank.
|
|
||||||
*
|
*
|
||||||
* @param file the column, a–h
|
* @param file
|
||||||
* @param rank the row, 1–8
|
* the column, a–h
|
||||||
|
* @param rank
|
||||||
|
* the row, 1–8
|
||||||
*/
|
*/
|
||||||
final case class Square(file: File, rank: Rank):
|
final case class Square(file: File, rank: Rank):
|
||||||
/** Algebraic notation string, e.g. "e4". */
|
/** Algebraic notation string, e.g. "e4". */
|
||||||
@@ -26,8 +23,8 @@ final case class Square(file: File, rank: Rank):
|
|||||||
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||||
|
|
||||||
object Square:
|
object Square:
|
||||||
/** Parse a square from algebraic notation (e.g. "e4").
|
/** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
|
||||||
* Returns None if the input is not a valid square name. */
|
*/
|
||||||
def fromAlgebraic(s: String): Option[Square] =
|
def fromAlgebraic(s: String): Option[Square] =
|
||||||
if s.length != 2 then None
|
if s.length != 2 then None
|
||||||
else
|
else
|
||||||
@@ -35,7 +32,22 @@ object Square:
|
|||||||
val rankChar = s.charAt(1)
|
val rankChar = s.charAt(1)
|
||||||
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
||||||
val rankOpt =
|
val rankOpt =
|
||||||
rankChar.toString.toIntOption.flatMap(n =>
|
rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
|
||||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
|
||||||
)
|
|
||||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||||
|
|
||||||
|
val all: IndexedSeq[Square] =
|
||||||
|
for
|
||||||
|
r <- Rank.values.toIndexedSeq
|
||||||
|
f <- File.values.toIndexedSeq
|
||||||
|
yield Square(f, r)
|
||||||
|
|
||||||
|
/** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board
|
||||||
|
* (0-7 range).
|
||||||
|
*/
|
||||||
|
extension (sq: Square)
|
||||||
|
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||||
|
val newFileOrd = sq.file.ordinal + fileDelta
|
||||||
|
val newRankOrd = sq.rank.ordinal + rankDelta
|
||||||
|
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
|
||||||
|
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
|
||||||
|
else None
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
/** Reason why a game ended in a draw. */
|
||||||
|
enum DrawReason:
|
||||||
|
case Stalemate
|
||||||
|
case InsufficientMaterial
|
||||||
|
case FiftyMoveRule
|
||||||
|
case Agreement
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, CastlingRights, Color, Square}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
|
/** Immutable bundle of complete game state. All state changes produce new GameContext instances.
|
||||||
|
*/
|
||||||
|
case class GameContext(
|
||||||
|
board: Board,
|
||||||
|
turn: Color,
|
||||||
|
castlingRights: CastlingRights,
|
||||||
|
enPassantSquare: Option[Square],
|
||||||
|
halfMoveClock: Int,
|
||||||
|
moves: List[Move],
|
||||||
|
result: Option[GameResult] = None,
|
||||||
|
):
|
||||||
|
/** Create new context with updated board. */
|
||||||
|
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||||
|
|
||||||
|
/** Create new context with updated turn. */
|
||||||
|
def withTurn(newTurn: Color): GameContext = copy(turn = newTurn)
|
||||||
|
|
||||||
|
/** Create new context with updated castling rights. */
|
||||||
|
def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights)
|
||||||
|
|
||||||
|
/** Create new context with updated en passant square. */
|
||||||
|
def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq)
|
||||||
|
|
||||||
|
/** Create new context with updated half-move clock. */
|
||||||
|
def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock)
|
||||||
|
|
||||||
|
/** Create new context with move appended to history. */
|
||||||
|
def withMove(move: Move): GameContext = copy(moves = moves :+ move)
|
||||||
|
|
||||||
|
/** Create new context with updated result. */
|
||||||
|
def withResult(newResult: Option[GameResult]): GameContext = copy(result = newResult)
|
||||||
|
|
||||||
|
object GameContext:
|
||||||
|
/** Initial position: white to move, all castling rights, no en passant. */
|
||||||
|
def initial: GameContext = GameContext(
|
||||||
|
board = Board.initial,
|
||||||
|
turn = Color.White,
|
||||||
|
castlingRights = CastlingRights.Initial,
|
||||||
|
enPassantSquare = None,
|
||||||
|
halfMoveClock = 0,
|
||||||
|
moves = List.empty,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
|
||||||
|
/** Outcome of a finished game. */
|
||||||
|
enum GameResult:
|
||||||
|
case Win(color: Color)
|
||||||
|
case Draw(reason: DrawReason)
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package de.nowchess.api.game
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, Square}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Castling availability flags for one side.
|
|
||||||
*
|
|
||||||
* @param kingSide king-side castling still legally available
|
|
||||||
* @param queenSide queen-side castling still legally available
|
|
||||||
*/
|
|
||||||
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
|
|
||||||
|
|
||||||
object CastlingRights:
|
|
||||||
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
|
|
||||||
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
|
|
||||||
|
|
||||||
/** Outcome of a finished game. */
|
|
||||||
enum GameResult:
|
|
||||||
case WhiteWins
|
|
||||||
case BlackWins
|
|
||||||
case Draw
|
|
||||||
|
|
||||||
/** Lifecycle state of a game. */
|
|
||||||
enum GameStatus:
|
|
||||||
case NotStarted
|
|
||||||
case InProgress
|
|
||||||
case Finished(result: GameResult)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A FEN-compatible snapshot of board and game state.
|
|
||||||
*
|
|
||||||
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
|
|
||||||
* separated by '/'). All other fields mirror standard FEN fields.
|
|
||||||
*
|
|
||||||
* @param piecePlacement FEN piece-placement field, e.g.
|
|
||||||
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
||||||
* @param activeColor side to move
|
|
||||||
* @param castlingWhite castling rights for White
|
|
||||||
* @param castlingBlack castling rights for Black
|
|
||||||
* @param enPassantTarget square behind the double-pushed pawn, if any
|
|
||||||
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
|
|
||||||
* @param fullMoveNumber increments after Black's move, starts at 1
|
|
||||||
* @param status current lifecycle status of the game
|
|
||||||
*/
|
|
||||||
final case class GameState(
|
|
||||||
piecePlacement: String,
|
|
||||||
activeColor: Color,
|
|
||||||
castlingWhite: CastlingRights,
|
|
||||||
castlingBlack: CastlingRights,
|
|
||||||
enPassantTarget: Option[Square],
|
|
||||||
halfMoveClock: Int,
|
|
||||||
fullMoveNumber: Int,
|
|
||||||
status: GameStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
object GameState:
|
|
||||||
/** Standard starting position. */
|
|
||||||
val initial: GameState = GameState(
|
|
||||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
|
||||||
activeColor = Color.White,
|
|
||||||
castlingWhite = CastlingRights.Both,
|
|
||||||
castlingBlack = CastlingRights.Both,
|
|
||||||
enPassantTarget = None,
|
|
||||||
halfMoveClock = 0,
|
|
||||||
fullMoveNumber = 1,
|
|
||||||
status = GameStatus.InProgress
|
|
||||||
)
|
|
||||||
@@ -9,25 +9,31 @@ enum PromotionPiece:
|
|||||||
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
||||||
enum MoveType:
|
enum MoveType:
|
||||||
/** A normal move or capture with no special rule. */
|
/** A normal move or capture with no special rule. */
|
||||||
case Normal
|
case Normal(isCapture: Boolean = false)
|
||||||
|
|
||||||
/** Kingside castling (O-O). */
|
/** Kingside castling (O-O). */
|
||||||
case CastleKingside
|
case CastleKingside
|
||||||
|
|
||||||
/** Queenside castling (O-O-O). */
|
/** Queenside castling (O-O-O). */
|
||||||
case CastleQueenside
|
case CastleQueenside
|
||||||
|
|
||||||
/** En-passant pawn capture. */
|
/** En-passant pawn capture. */
|
||||||
case EnPassant
|
case EnPassant
|
||||||
|
|
||||||
/** Pawn promotion; carries the chosen promotion piece. */
|
/** Pawn promotion; carries the chosen promotion piece. */
|
||||||
case Promotion(piece: PromotionPiece)
|
case Promotion(piece: PromotionPiece)
|
||||||
|
|
||||||
/**
|
/** A half-move (ply) in a chess game.
|
||||||
* A half-move (ply) in a chess game.
|
|
||||||
*
|
*
|
||||||
* @param from origin square
|
* @param from
|
||||||
* @param to destination square
|
* origin square
|
||||||
* @param moveType special semantics; defaults to Normal
|
* @param to
|
||||||
|
* destination square
|
||||||
|
* @param moveType
|
||||||
|
* special semantics; defaults to Normal
|
||||||
*/
|
*/
|
||||||
final case class Move(
|
final case class Move(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveType: MoveType = MoveType.Normal
|
moveType: MoveType = MoveType.Normal(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package de.nowchess.api.player
|
package de.nowchess.api.player
|
||||||
|
|
||||||
/**
|
/** An opaque player identifier.
|
||||||
* An opaque player identifier.
|
|
||||||
*
|
*
|
||||||
* Wraps a plain String so that IDs are not accidentally interchanged with
|
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
|
||||||
* other String values at compile time.
|
|
||||||
*/
|
*/
|
||||||
opaque type PlayerId = String
|
opaque type PlayerId = String
|
||||||
|
|
||||||
@@ -12,16 +10,17 @@ object PlayerId:
|
|||||||
def apply(value: String): PlayerId = value
|
def apply(value: String): PlayerId = value
|
||||||
extension (id: PlayerId) def value: String = id
|
extension (id: PlayerId) def value: String = id
|
||||||
|
|
||||||
/**
|
/** The minimal cross-service identity stub for a player.
|
||||||
* The minimal cross-service identity stub for a player.
|
|
||||||
*
|
*
|
||||||
* Full profile data (email, rating history, etc.) lives in the user-management
|
* Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs
|
||||||
* service. Only what every service needs is held here.
|
* is held here.
|
||||||
*
|
*
|
||||||
* @param id unique identifier
|
* @param id
|
||||||
* @param displayName human-readable name shown in the UI
|
* unique identifier
|
||||||
|
* @param displayName
|
||||||
|
* human-readable name shown in the UI
|
||||||
*/
|
*/
|
||||||
final case class PlayerInfo(
|
final case class PlayerInfo(
|
||||||
id: PlayerId,
|
id: PlayerId,
|
||||||
displayName: String
|
displayName: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package de.nowchess.api.response
|
package de.nowchess.api.response
|
||||||
|
|
||||||
/**
|
/** A standardised envelope for every API response.
|
||||||
* A standardised envelope for every API response.
|
|
||||||
*
|
*
|
||||||
* Success and failure are modelled as subtypes so that callers
|
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
|
||||||
* can pattern-match exhaustively.
|
|
||||||
*
|
*
|
||||||
* @tparam A the payload type for a successful response
|
* @tparam A
|
||||||
|
* the payload type for a successful response
|
||||||
*/
|
*/
|
||||||
sealed trait ApiResponse[+A]
|
sealed trait ApiResponse[+A]
|
||||||
|
|
||||||
@@ -20,43 +19,49 @@ object ApiResponse:
|
|||||||
/** Convenience constructor for a single-error failure. */
|
/** Convenience constructor for a single-error failure. */
|
||||||
def error(err: ApiError): Failure = Failure(List(err))
|
def error(err: ApiError): Failure = Failure(List(err))
|
||||||
|
|
||||||
/**
|
/** A structured error descriptor.
|
||||||
* A structured error descriptor.
|
|
||||||
*
|
*
|
||||||
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
* @param code
|
||||||
* @param message human-readable explanation
|
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||||
* @param field optional field name when the error relates to a specific input
|
* @param message
|
||||||
|
* human-readable explanation
|
||||||
|
* @param field
|
||||||
|
* optional field name when the error relates to a specific input
|
||||||
*/
|
*/
|
||||||
final case class ApiError(
|
final case class ApiError(
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
field: Option[String] = None
|
field: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Pagination metadata for list responses.
|
||||||
* Pagination metadata for list responses.
|
|
||||||
*
|
*
|
||||||
* @param page current 0-based page index
|
* @param page
|
||||||
* @param pageSize number of items per page
|
* current 0-based page index
|
||||||
* @param totalItems total number of items across all pages
|
* @param pageSize
|
||||||
|
* number of items per page
|
||||||
|
* @param totalItems
|
||||||
|
* total number of items across all pages
|
||||||
*/
|
*/
|
||||||
final case class Pagination(
|
final case class Pagination(
|
||||||
page: Int,
|
page: Int,
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
totalItems: Long
|
totalItems: Long,
|
||||||
):
|
):
|
||||||
def totalPages: Int =
|
def totalPages: Int =
|
||||||
if pageSize <= 0 then 0
|
if pageSize <= 0 then 0
|
||||||
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||||
|
|
||||||
/**
|
/** A paginated list response envelope.
|
||||||
* A paginated list response envelope.
|
|
||||||
*
|
*
|
||||||
* @param items the items on the current page
|
* @param items
|
||||||
* @param pagination pagination metadata
|
* the items on the current page
|
||||||
* @tparam A the item type
|
* @param pagination
|
||||||
|
* pagination metadata
|
||||||
|
* @tparam A
|
||||||
|
* the item type
|
||||||
*/
|
*/
|
||||||
final case class PagedResponse[A](
|
final case class PagedResponse[A](
|
||||||
items: List[A],
|
items: List[A],
|
||||||
pagination: Pagination
|
pagination: Pagination,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -7,13 +8,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
private val e2 = Square(File.E, Rank.R2)
|
private val e2 = Square(File.E, Rank.R2)
|
||||||
private val e4 = Square(File.E, Rank.R4)
|
private val e4 = Square(File.E, Rank.R4)
|
||||||
private val d7 = Square(File.D, Rank.R7)
|
|
||||||
|
|
||||||
test("pieceAt returns Some for occupied square") {
|
test("pieceAt resolves occupied and empty squares") {
|
||||||
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
}
|
|
||||||
|
|
||||||
test("pieceAt returns None for empty square") {
|
|
||||||
Board.initial.pieceAt(e4) shouldBe None
|
Board.initial.pieceAt(e4) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,46 +31,34 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
board.pieceAt(from) shouldBe None
|
board.pieceAt(from) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
test("pieces returns the underlying map") {
|
test("Board.apply and pieces expose the wrapped map") {
|
||||||
val map = Map(e2 -> Piece.WhitePawn)
|
|
||||||
val b = Board(map)
|
|
||||||
b.pieces shouldBe map
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Board.apply constructs board from map") {
|
|
||||||
val map = Map(e2 -> Piece.WhitePawn)
|
val map = Map(e2 -> Piece.WhitePawn)
|
||||||
val b = Board(map)
|
val b = Board(map)
|
||||||
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
|
b.pieces shouldBe map
|
||||||
}
|
}
|
||||||
|
|
||||||
test("initial board has 32 pieces") {
|
test("initial board has expected material and pawn placement") {
|
||||||
Board.initial.pieces should have size 32
|
Board.initial.pieces should have size 32
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board has 16 white pieces") {
|
|
||||||
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board has 16 black pieces") {
|
|
||||||
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board white pawns on rank 2") {
|
|
||||||
File.values.foreach { file =>
|
File.values.foreach { file =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("initial board black pawns on rank 7") {
|
|
||||||
File.values.foreach { file =>
|
|
||||||
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("initial board white back rank") {
|
test("initial board white back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||||
@@ -83,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("initial board black back rank") {
|
test("initial board black back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||||
@@ -97,21 +88,17 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
for
|
for
|
||||||
rank <- emptyRanks
|
rank <- emptyRanks
|
||||||
file <- File.values
|
file <- File.values
|
||||||
do
|
do Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("updated adds or replaces piece at square") {
|
test("updated adds and replaces piece at squares") {
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
val updated = b.updated(e4, Piece.WhiteKnight)
|
val added = b.updated(e4, Piece.WhiteKnight)
|
||||||
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
}
|
|
||||||
|
|
||||||
test("updated replaces existing piece") {
|
val replaced = b.updated(e2, Piece.WhiteKnight)
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
||||||
val updated = b.updated(e2, Piece.WhiteKnight)
|
|
||||||
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("removed deletes piece from board") {
|
test("removed deletes piece from board") {
|
||||||
@@ -120,3 +107,12 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
removed.pieceAt(e2) shouldBe None
|
removed.pieceAt(e2) shouldBe None
|
||||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("applyMove uses move.from and move.to to relocate a piece") {
|
||||||
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
|
|
||||||
|
val moved = b.applyMove(Move(e2, e4))
|
||||||
|
|
||||||
|
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||||
|
moved.pieceAt(e2) shouldBe None
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("hasAnyRights and hasRights reflect current flags"):
|
||||||
|
val rights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
rights.hasAnyRights shouldBe true
|
||||||
|
rights.hasRights(Color.White) shouldBe true
|
||||||
|
rights.hasRights(Color.Black) shouldBe true
|
||||||
|
|
||||||
|
CastlingRights.None.hasAnyRights shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.White) shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.Black) shouldBe false
|
||||||
|
|
||||||
|
test("revokeColor clears both castling sides for selected color"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteRevoked = all.revokeColor(Color.White)
|
||||||
|
whiteRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteRevoked.whiteQueenSide shouldBe false
|
||||||
|
whiteRevoked.blackKingSide shouldBe true
|
||||||
|
whiteRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackRevoked = all.revokeColor(Color.Black)
|
||||||
|
blackRevoked.whiteKingSide shouldBe true
|
||||||
|
blackRevoked.whiteQueenSide shouldBe true
|
||||||
|
blackRevoked.blackKingSide shouldBe false
|
||||||
|
blackRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("revokeKingSide and revokeQueenSide disable only requested side"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
|
||||||
|
whiteKingSideRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteKingSideRevoked.whiteQueenSide shouldBe true
|
||||||
|
|
||||||
|
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
|
||||||
|
whiteQueenSideRevoked.whiteKingSide shouldBe true
|
||||||
|
whiteQueenSideRevoked.whiteQueenSide shouldBe false
|
||||||
|
|
||||||
|
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
|
||||||
|
blackKingSideRevoked.blackKingSide shouldBe false
|
||||||
|
blackKingSideRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||||
|
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||||
|
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||||
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class ColorTest extends AnyFunSuite with Matchers:
|
class ColorTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("White.opposite returns Black") {
|
test("Color values expose opposite and label consistently"):
|
||||||
Color.White.opposite shouldBe Color.Black
|
val cases = List(
|
||||||
}
|
(Color.White, Color.Black, "White"),
|
||||||
|
(Color.Black, Color.White, "Black"),
|
||||||
|
)
|
||||||
|
|
||||||
test("Black.opposite returns White") {
|
cases.foreach { (color, opposite, label) =>
|
||||||
Color.Black.opposite shouldBe Color.White
|
color.opposite shouldBe opposite
|
||||||
}
|
color.label shouldBe label
|
||||||
|
|
||||||
test("White.label returns 'White'") {
|
|
||||||
Color.White.label shouldBe "White"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Black.label returns 'Black'") {
|
|
||||||
Color.Black.label shouldBe "Black"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
|
|||||||
p.pieceType shouldBe PieceType.Queen
|
p.pieceType shouldBe PieceType.Queen
|
||||||
}
|
}
|
||||||
|
|
||||||
test("WhitePawn convenience constant") {
|
test("all convenience constants map to expected color and piece type") {
|
||||||
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
|
val expected = List(
|
||||||
}
|
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
||||||
|
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
|
||||||
|
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
||||||
|
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||||
|
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||||
|
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||||
|
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||||
|
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
||||||
|
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||||
|
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||||
|
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||||
|
Piece.BlackKing -> Piece(Color.Black, PieceType.King),
|
||||||
|
)
|
||||||
|
|
||||||
test("WhiteKnight convenience constant") {
|
expected.foreach { case (actual, wanted) =>
|
||||||
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight)
|
actual shouldBe wanted
|
||||||
}
|
}
|
||||||
|
|
||||||
test("WhiteBishop convenience constant") {
|
|
||||||
Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteRook convenience constant") {
|
|
||||||
Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteQueen convenience constant") {
|
|
||||||
Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("WhiteKing convenience constant") {
|
|
||||||
Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackPawn convenience constant") {
|
|
||||||
Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackKnight convenience constant") {
|
|
||||||
Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackBishop convenience constant") {
|
|
||||||
Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackRook convenience constant") {
|
|
||||||
Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackQueen convenience constant") {
|
|
||||||
Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("BlackKing convenience constant") {
|
|
||||||
Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PieceTypeTest extends AnyFunSuite with Matchers:
|
class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("Pawn.label returns 'Pawn'") {
|
test("PieceType values expose the expected labels"):
|
||||||
PieceType.Pawn.label shouldBe "Pawn"
|
val expectedLabels = List(
|
||||||
}
|
PieceType.Pawn -> "Pawn",
|
||||||
|
PieceType.Knight -> "Knight",
|
||||||
|
PieceType.Bishop -> "Bishop",
|
||||||
|
PieceType.Rook -> "Rook",
|
||||||
|
PieceType.Queen -> "Queen",
|
||||||
|
PieceType.King -> "King",
|
||||||
|
)
|
||||||
|
|
||||||
test("Knight.label returns 'Knight'") {
|
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||||
PieceType.Knight.label shouldBe "Knight"
|
pieceType.label shouldBe expectedLabel
|
||||||
}
|
|
||||||
|
|
||||||
test("Bishop.label returns 'Bishop'") {
|
|
||||||
PieceType.Bishop.label shouldBe "Bishop"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Rook.label returns 'Rook'") {
|
|
||||||
PieceType.Rook.label shouldBe "Rook"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Queen.label returns 'Queen'") {
|
|
||||||
PieceType.Queen.label shouldBe "Queen"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("King.label returns 'King'") {
|
|
||||||
PieceType.King.label shouldBe "King"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,58 +5,32 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class SquareTest extends AnyFunSuite with Matchers:
|
class SquareTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("Square.toString produces lowercase file and rank number") {
|
test("toString renders algebraic notation for edge and middle squares") {
|
||||||
Square(File.E, Rank.R4).toString shouldBe "e4"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Square.toString for a1") {
|
|
||||||
Square(File.A, Rank.R1).toString shouldBe "a1"
|
Square(File.A, Rank.R1).toString shouldBe "a1"
|
||||||
}
|
Square(File.E, Rank.R4).toString shouldBe "e4"
|
||||||
|
|
||||||
test("Square.toString for h8") {
|
|
||||||
Square(File.H, Rank.R8).toString shouldBe "h8"
|
Square(File.H, Rank.R8).toString shouldBe "h8"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square e4") {
|
test("fromAlgebraic parses valid coordinates including case-insensitive files") {
|
||||||
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4))
|
val expected = List(
|
||||||
|
"a1" -> Square(File.A, Rank.R1),
|
||||||
|
"e4" -> Square(File.E, Rank.R4),
|
||||||
|
"h8" -> Square(File.H, Rank.R8),
|
||||||
|
"E4" -> Square(File.E, Rank.R4),
|
||||||
|
)
|
||||||
|
expected.foreach { case (raw, sq) =>
|
||||||
|
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square a1") {
|
test("fromAlgebraic rejects malformed coordinates") {
|
||||||
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
|
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
|
||||||
|
Square.fromAlgebraic(raw) shouldBe None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fromAlgebraic parses valid square h8") {
|
test("offset returns Some in-bounds and None out-of-bounds") {
|
||||||
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
|
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
|
||||||
}
|
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||||
|
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||||
test("fromAlgebraic is case-insensitive for file") {
|
|
||||||
Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for empty string") {
|
|
||||||
Square.fromAlgebraic("") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for string too short") {
|
|
||||||
Square.fromAlgebraic("e") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for string too long") {
|
|
||||||
Square.fromAlgebraic("e42") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for invalid file character") {
|
|
||||||
Square.fromAlgebraic("z4") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for non-digit rank") {
|
|
||||||
Square.fromAlgebraic("ex") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for rank 0") {
|
|
||||||
Square.fromAlgebraic("e0") shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("fromAlgebraic returns None for rank 9") {
|
|
||||||
Square.fromAlgebraic("e9") shouldBe None
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
import de.nowchess.api.game.{DrawReason, GameResult}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class GameContextTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("GameContext.initial exposes expected default state"):
|
||||||
|
val initial = GameContext.initial
|
||||||
|
|
||||||
|
initial.board shouldBe Board.initial
|
||||||
|
initial.turn shouldBe Color.White
|
||||||
|
initial.castlingRights shouldBe CastlingRights.Initial
|
||||||
|
initial.enPassantSquare shouldBe None
|
||||||
|
initial.halfMoveClock shouldBe 0
|
||||||
|
initial.moves shouldBe List.empty
|
||||||
|
initial.result shouldBe None
|
||||||
|
|
||||||
|
test("withBoard updates only board"):
|
||||||
|
val square = Square(File.E, Rank.R4)
|
||||||
|
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
||||||
|
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||||
|
updated.board shouldBe updatedBoard
|
||||||
|
updated.turn shouldBe GameContext.initial.turn
|
||||||
|
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||||
|
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
|
||||||
|
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
|
||||||
|
updated.moves shouldBe GameContext.initial.moves
|
||||||
|
|
||||||
|
test("withers update only targeted fields"):
|
||||||
|
val initial = GameContext.initial
|
||||||
|
val rights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = true,
|
||||||
|
)
|
||||||
|
val square = Some(Square(File.E, Rank.R3))
|
||||||
|
val updatedTurn = initial.withTurn(Color.Black)
|
||||||
|
val updatedRights = initial.withCastlingRights(rights)
|
||||||
|
val updatedEp = initial.withEnPassantSquare(square)
|
||||||
|
val updatedClock = initial.withHalfMoveClock(17)
|
||||||
|
|
||||||
|
updatedTurn.turn shouldBe Color.Black
|
||||||
|
updatedTurn.board shouldBe initial.board
|
||||||
|
|
||||||
|
updatedRights.castlingRights shouldBe rights
|
||||||
|
updatedRights.turn shouldBe initial.turn
|
||||||
|
|
||||||
|
updatedEp.enPassantSquare shouldBe square
|
||||||
|
updatedEp.castlingRights shouldBe initial.castlingRights
|
||||||
|
|
||||||
|
updatedClock.halfMoveClock shouldBe 17
|
||||||
|
updatedClock.moves shouldBe initial.moves
|
||||||
|
|
||||||
|
test("withMove appends move to history"):
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||||
|
|
||||||
|
test("withResult sets Win result"):
|
||||||
|
val win = Some(GameResult.Win(Color.White))
|
||||||
|
GameContext.initial.withResult(win).result shouldBe win
|
||||||
|
|
||||||
|
test("withResult sets Draw result"):
|
||||||
|
val draw = Some(GameResult.Draw(DrawReason.Stalemate))
|
||||||
|
GameContext.initial.withResult(draw).result shouldBe draw
|
||||||
|
|
||||||
|
test("withResult clears result"):
|
||||||
|
val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black)))
|
||||||
|
ctx.withResult(None).result shouldBe None
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package de.nowchess.api.game
|
|
||||||
|
|
||||||
import de.nowchess.api.board.Color
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameStateTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("CastlingRights.None has both flags false") {
|
|
||||||
CastlingRights.None.kingSide shouldBe false
|
|
||||||
CastlingRights.None.queenSide shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
test("CastlingRights.Both has both flags true") {
|
|
||||||
CastlingRights.Both.kingSide shouldBe true
|
|
||||||
CastlingRights.Both.queenSide shouldBe true
|
|
||||||
}
|
|
||||||
|
|
||||||
test("CastlingRights constructor sets fields") {
|
|
||||||
val cr = CastlingRights(kingSide = true, queenSide = false)
|
|
||||||
cr.kingSide shouldBe true
|
|
||||||
cr.queenSide shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameResult cases exist") {
|
|
||||||
GameResult.WhiteWins shouldBe GameResult.WhiteWins
|
|
||||||
GameResult.BlackWins shouldBe GameResult.BlackWins
|
|
||||||
GameResult.Draw shouldBe GameResult.Draw
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.NotStarted") {
|
|
||||||
GameStatus.NotStarted shouldBe GameStatus.NotStarted
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.InProgress") {
|
|
||||||
GameStatus.InProgress shouldBe GameStatus.InProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStatus.Finished carries result") {
|
|
||||||
val status = GameStatus.Finished(GameResult.Draw)
|
|
||||||
status shouldBe GameStatus.Finished(GameResult.Draw)
|
|
||||||
status match
|
|
||||||
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
|
|
||||||
case _ => fail("expected Finished")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial has standard FEN piece placement") {
|
|
||||||
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial active color is White") {
|
|
||||||
GameState.initial.activeColor shouldBe Color.White
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial white has full castling rights") {
|
|
||||||
GameState.initial.castlingWhite shouldBe CastlingRights.Both
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial black has full castling rights") {
|
|
||||||
GameState.initial.castlingBlack shouldBe CastlingRights.Both
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial en-passant target is None") {
|
|
||||||
GameState.initial.enPassantTarget shouldBe None
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial half-move clock is 0") {
|
|
||||||
GameState.initial.halfMoveClock shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial full-move number is 1") {
|
|
||||||
GameState.initial.fullMoveNumber shouldBe 1
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameState.initial status is InProgress") {
|
|
||||||
GameState.initial.status shouldBe GameStatus.InProgress
|
|
||||||
}
|
|
||||||
@@ -9,48 +9,26 @@ class MoveTest extends AnyFunSuite with Matchers:
|
|||||||
private val e2 = Square(File.E, Rank.R2)
|
private val e2 = Square(File.E, Rank.R2)
|
||||||
private val e4 = Square(File.E, Rank.R4)
|
private val e4 = Square(File.E, Rank.R4)
|
||||||
|
|
||||||
test("Move defaults moveType to Normal") {
|
test("Move defaults to Normal and keeps from/to squares") {
|
||||||
val m = Move(e2, e4)
|
|
||||||
m.moveType shouldBe MoveType.Normal
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move stores from and to squares") {
|
|
||||||
val m = Move(e2, e4)
|
val m = Move(e2, e4)
|
||||||
m.from shouldBe e2
|
m.from shouldBe e2
|
||||||
m.to shouldBe e4
|
m.to shouldBe e4
|
||||||
|
m.moveType shouldBe MoveType.Normal()
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Move with CastleKingside moveType") {
|
test("Move accepts all supported move types") {
|
||||||
val m = Move(e2, e4, MoveType.CastleKingside)
|
val moveTypes = List(
|
||||||
m.moveType shouldBe MoveType.CastleKingside
|
MoveType.Normal(isCapture = true),
|
||||||
}
|
MoveType.CastleKingside,
|
||||||
|
MoveType.CastleQueenside,
|
||||||
|
MoveType.EnPassant,
|
||||||
|
MoveType.Promotion(PromotionPiece.Queen),
|
||||||
|
MoveType.Promotion(PromotionPiece.Rook),
|
||||||
|
MoveType.Promotion(PromotionPiece.Bishop),
|
||||||
|
MoveType.Promotion(PromotionPiece.Knight),
|
||||||
|
)
|
||||||
|
|
||||||
test("Move with CastleQueenside moveType") {
|
moveTypes.foreach { moveType =>
|
||||||
val m = Move(e2, e4, MoveType.CastleQueenside)
|
Move(e2, e4, moveType).moveType shouldBe moveType
|
||||||
m.moveType shouldBe MoveType.CastleQueenside
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Move with EnPassant moveType") {
|
|
||||||
val m = Move(e2, e4, MoveType.EnPassant)
|
|
||||||
m.moveType shouldBe MoveType.EnPassant
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Queen") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Knight") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Bishop") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Rook") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("PlayerId.apply wraps a string") {
|
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||||
val id = PlayerId("player-123")
|
val raw = "player-123"
|
||||||
id.value shouldBe "player-123"
|
val id = PlayerId(raw)
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerId.value unwraps to original string") {
|
id.value shouldBe raw
|
||||||
val raw = "abc-456"
|
|
||||||
PlayerId(raw).value shouldBe raw
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerInfo holds id and displayName") {
|
val playerId = PlayerId("p1")
|
||||||
val id = PlayerId("p1")
|
val info = PlayerInfo(playerId, "Magnus")
|
||||||
val info = PlayerInfo(id, "Magnus")
|
|
||||||
info.id.value shouldBe "p1"
|
info.id.value shouldBe "p1"
|
||||||
info.displayName shouldBe "Magnus"
|
info.displayName shouldBe "Magnus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class ApiResponseTest extends AnyFunSuite with Matchers:
|
class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("ApiResponse.Success carries data") {
|
test("ApiResponse factories and payload wrappers keep values") {
|
||||||
val r = ApiResponse.Success(42)
|
val r = ApiResponse.Success(42)
|
||||||
r.data shouldBe 42
|
r.data shouldBe 42
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiResponse.Failure carries error list") {
|
|
||||||
val err = ApiError("CODE", "msg")
|
val err = ApiError("CODE", "msg")
|
||||||
val r = ApiResponse.Failure(List(err))
|
ApiResponse.Failure(List(err)).errors shouldBe List(err)
|
||||||
r.errors shouldBe List(err)
|
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiResponse.error creates single-error Failure") {
|
|
||||||
val err = ApiError("NOT_FOUND", "not found")
|
|
||||||
val f = ApiResponse.error(err)
|
|
||||||
f shouldBe ApiResponse.Failure(List(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ApiError holds code and message") {
|
|
||||||
val e = ApiError("CODE", "message")
|
val e = ApiError("CODE", "message")
|
||||||
e.code shouldBe "CODE"
|
e.code shouldBe "CODE"
|
||||||
e.message shouldBe "message"
|
e.message shouldBe "message"
|
||||||
e.field shouldBe None
|
e.field shouldBe None
|
||||||
|
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("ApiError holds optional field") {
|
test("Pagination.totalPages handles normal and guarded inputs") {
|
||||||
val e = ApiError("INVALID", "bad value", Some("email"))
|
|
||||||
e.field shouldBe Some("email")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages with exact division") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages rounds up") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when totalItems is 0") {
|
|
||||||
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when pageSize is 0") {
|
|
||||||
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
||||||
}
|
|
||||||
|
|
||||||
test("Pagination.totalPages is 0 when pageSize is negative") {
|
|
||||||
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=0
|
MINOR=5
|
||||||
PATCH=7
|
PATCH=0
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
plugins {
|
||||||
|
id("scala")
|
||||||
|
id("org.scoverage") version "8.1"
|
||||||
|
id("io.quarkus")
|
||||||
|
id("jacoco")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
mavenLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
scala {
|
||||||
|
scalaVersion = versions["SCALA3"]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
scoverage {
|
||||||
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<ScalaCompile> {
|
||||||
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
val quarkusPlatformGroupId: String by project
|
||||||
|
val quarkusPlatformArtifactId: String by project
|
||||||
|
val quarkusPlatformVersion: String by project
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":modules:api"))
|
||||||
|
implementation(project(":modules:core"))
|
||||||
|
implementation(project(":modules:io"))
|
||||||
|
implementation(project(":modules:rule"))
|
||||||
|
|
||||||
|
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
implementation("io.quarkus:quarkus-rest")
|
||||||
|
implementation("io.quarkus:quarkus-rest-jackson")
|
||||||
|
implementation("io.quarkus:quarkus-config-yaml")
|
||||||
|
implementation("io.quarkus:quarkus-arc")
|
||||||
|
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
|
||||||
|
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"]!!}")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit5")
|
||||||
|
testImplementation("io.quarkus:quarkus-jacoco")
|
||||||
|
testImplementation("io.rest-assured:rest-assured")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.nowchess"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.compilerArgs.add("-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Jar>().configureEach {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeEngines("scalatest", "junit-jupiter")
|
||||||
|
}
|
||||||
|
testLogging {
|
||||||
|
events("passed", "skipped", "failed")
|
||||||
|
}
|
||||||
|
finalizedBy(tasks.named("jacocoTestReport"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.jacocoTestReport {
|
||||||
|
dependsOn(tasks.test)
|
||||||
|
executionData.setFrom(layout.buildDirectory.file("jacoco-quarkus.exec"))
|
||||||
|
sourceDirectories.setFrom(files("src/main/scala"))
|
||||||
|
classDirectories.setFrom(files(layout.buildDirectory.dir("classes/scala/main")))
|
||||||
|
reports {
|
||||||
|
xml.required.set(true)
|
||||||
|
xml.outputLocation.set(
|
||||||
|
layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")
|
||||||
|
)
|
||||||
|
html.required.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
quarkus:
|
||||||
|
http:
|
||||||
|
port: 8080
|
||||||
|
jacoco:
|
||||||
|
data-file: ${user.dir}/build/jacoco-quarkus.exec
|
||||||
|
report: false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.nowchess.backcore.config
|
||||||
|
|
||||||
|
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(DefaultScalaModule)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.nowchess.backcore.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude.Include
|
||||||
|
|
||||||
|
case class PlayerInfoDto(id: String, displayName: String)
|
||||||
|
|
||||||
|
case class GameStateResponse(
|
||||||
|
fen: String,
|
||||||
|
pgn: String,
|
||||||
|
turn: String,
|
||||||
|
status: String,
|
||||||
|
@JsonInclude(Include.NON_ABSENT) winner: Option[String],
|
||||||
|
moves: List[String],
|
||||||
|
undoAvailable: Boolean,
|
||||||
|
redoAvailable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class GameFullResponse(
|
||||||
|
gameId: String,
|
||||||
|
white: PlayerInfoDto,
|
||||||
|
black: PlayerInfoDto,
|
||||||
|
state: GameStateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class OkResponse(ok: Boolean = true)
|
||||||
|
|
||||||
|
@JsonInclude(Include.NON_ABSENT)
|
||||||
|
case class ApiErrorResponse(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
field: Option[String] = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
case class CreateGameRequest(
|
||||||
|
white: Option[PlayerInfoDto] = None,
|
||||||
|
black: Option[PlayerInfoDto] = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class ImportFenRequest(
|
||||||
|
fen: String = "",
|
||||||
|
white: Option[PlayerInfoDto] = None,
|
||||||
|
black: Option[PlayerInfoDto] = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class ImportPgnRequest(pgn: String = "")
|
||||||
|
|
||||||
|
case class LegalMoveDto(
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
uci: String,
|
||||||
|
moveType: String,
|
||||||
|
@JsonInclude(Include.NON_ABSENT) promotion: Option[String] = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class LegalMovesResponse(moves: List[LegalMoveDto])
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.nowchess.backcore.game
|
||||||
|
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
object GameId:
|
||||||
|
private val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
def generate(): String =
|
||||||
|
(1 to 8).map(_ => chars(random.nextInt(chars.length))).mkString
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.nowchess.backcore.game
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.backcore.dto.*
|
||||||
|
import de.nowchess.io.fen.FenExporter
|
||||||
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
|
object GameMapper:
|
||||||
|
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
|
||||||
|
|
||||||
|
def toGameFullJson(session: GameSession): String =
|
||||||
|
mapper.writeValueAsString(toGameFull(session))
|
||||||
|
|
||||||
|
def toGameFull(session: GameSession): GameFullResponse =
|
||||||
|
GameFullResponse(
|
||||||
|
gameId = session.gameId,
|
||||||
|
white = toPlayerInfo(session.white),
|
||||||
|
black = toPlayerInfo(session.black),
|
||||||
|
state = toGameState(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
def toGameState(session: GameSession): GameStateResponse =
|
||||||
|
val (status, winner) = computeStatus(session)
|
||||||
|
GameStateResponse(
|
||||||
|
fen = FenExporter.exportGameContext(session.context),
|
||||||
|
pgn = buildPgn(session.context.moves),
|
||||||
|
turn = if session.context.turn == Color.White then "white" else "black",
|
||||||
|
status = status,
|
||||||
|
winner = winner,
|
||||||
|
moves = session.context.moves.map(moveToUci),
|
||||||
|
undoAvailable = session.invoker.canUndo,
|
||||||
|
redoAvailable = session.invoker.canRedo,
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toPlayerInfo(p: de.nowchess.api.player.PlayerInfo): PlayerInfoDto =
|
||||||
|
PlayerInfoDto(id = p.id.value, displayName = p.displayName)
|
||||||
|
|
||||||
|
private def computeStatus(session: GameSession): (String, Option[String]) =
|
||||||
|
session.result match
|
||||||
|
case Some(GameResult.Checkmate(winner)) =>
|
||||||
|
val w = if winner == Color.White then "white" else "black"
|
||||||
|
("checkmate", Some(w))
|
||||||
|
case Some(GameResult.Stalemate) =>
|
||||||
|
("stalemate", None)
|
||||||
|
case Some(GameResult.Resign(winner)) =>
|
||||||
|
val w = if winner == Color.White then "white" else "black"
|
||||||
|
("resign", Some(w))
|
||||||
|
case Some(GameResult.AgreedDraw) | Some(GameResult.FiftyMoveDraw) =>
|
||||||
|
("draw", None)
|
||||||
|
case Some(GameResult.InsufficientMaterial) =>
|
||||||
|
("insufficientMaterial", None)
|
||||||
|
case None =>
|
||||||
|
computeLiveStatus(session)
|
||||||
|
|
||||||
|
private def computeLiveStatus(session: GameSession): (String, Option[String]) =
|
||||||
|
val ctx = session.context
|
||||||
|
if DefaultRules.isCheck(ctx) then ("check", None)
|
||||||
|
else if session.drawOfferedBy.isDefined then ("drawOffered", None)
|
||||||
|
else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None)
|
||||||
|
else ("started", None)
|
||||||
|
|
||||||
|
def moveToUci(move: Move): String =
|
||||||
|
val base = s"${move.from}${move.to}"
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(piece) =>
|
||||||
|
val suffix = piece match
|
||||||
|
case PromotionPiece.Queen => "q"
|
||||||
|
case PromotionPiece.Rook => "r"
|
||||||
|
case PromotionPiece.Bishop => "b"
|
||||||
|
case PromotionPiece.Knight => "n"
|
||||||
|
base + suffix
|
||||||
|
case _ => base
|
||||||
|
|
||||||
|
private def buildPgn(moves: List[Move]): String =
|
||||||
|
// Use PgnExporter with no headers to get move-text only (SAN notation)
|
||||||
|
PgnExporter.exportGame(Map.empty, moves)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.nowchess.backcore.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
|
||||||
|
sealed trait GameResult
|
||||||
|
object GameResult:
|
||||||
|
case class Checkmate(winner: Color) extends GameResult
|
||||||
|
case object Stalemate extends GameResult
|
||||||
|
case class Resign(winner: Color) extends GameResult
|
||||||
|
case object AgreedDraw extends GameResult
|
||||||
|
case object FiftyMoveDraw extends GameResult
|
||||||
|
case object InsufficientMaterial extends GameResult
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.nowchess.backcore.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.player.PlayerInfo
|
||||||
|
import de.nowchess.chess.command.CommandInvoker
|
||||||
|
|
||||||
|
case class GameSession(
|
||||||
|
gameId: String,
|
||||||
|
white: PlayerInfo,
|
||||||
|
black: PlayerInfo,
|
||||||
|
context: GameContext,
|
||||||
|
invoker: CommandInvoker,
|
||||||
|
drawOfferedBy: Option[Color] = None,
|
||||||
|
result: Option[GameResult] = None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package de.nowchess.backcore.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Color, Square}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto}
|
||||||
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
|
import de.nowchess.io.fen.FenParser
|
||||||
|
import de.nowchess.io.pgn.PgnParser
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameStore:
|
||||||
|
private val games: mutable.Map[String, GameSession] = mutable.Map.empty
|
||||||
|
|
||||||
|
// ─── Create / Get ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create(req: CreateGameRequest): GameSession = synchronized:
|
||||||
|
val id = generateId()
|
||||||
|
val session = newSession(id, req.white, req.black, GameContext.initial)
|
||||||
|
games(id) = session
|
||||||
|
session
|
||||||
|
|
||||||
|
def get(id: String): Option[GameSession] = synchronized:
|
||||||
|
games.get(id)
|
||||||
|
|
||||||
|
// ─── Move-making ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def applyMove(id: String, uci: String): Either[String, GameSession] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
if session.result.isDefined then Left("Game is already over")
|
||||||
|
else
|
||||||
|
parseUci(uci) match
|
||||||
|
case None => Left(s"Invalid UCI notation: $uci")
|
||||||
|
case Some((from, to, promotion)) =>
|
||||||
|
val legalCandidates = DefaultRules.legalMoves(session.context)(from)
|
||||||
|
findMatchingMove(legalCandidates, to, promotion) match
|
||||||
|
case None => Left(s"$uci is not a legal move")
|
||||||
|
case Some(move) =>
|
||||||
|
val nextCtx = DefaultRules.applyMove(session.context)(move)
|
||||||
|
val prevCtx = session.context
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = move.from,
|
||||||
|
to = move.to,
|
||||||
|
moveResult = Some(MoveResult.Successful(nextCtx, prevCtx.board.pieceAt(move.to))),
|
||||||
|
previousContext = Some(prevCtx),
|
||||||
|
)
|
||||||
|
session.invoker.execute(cmd)
|
||||||
|
val result = detectGameOver(nextCtx)
|
||||||
|
val updated = session.copy(context = nextCtx, result = result)
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
|
||||||
|
def legalMoves(id: String, square: Option[Square]): Either[String, List[Move]] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
val moves = square match
|
||||||
|
case Some(sq) => DefaultRules.legalMoves(session.context)(sq)
|
||||||
|
case None => DefaultRules.allLegalMoves(session.context)
|
||||||
|
Right(moves)
|
||||||
|
|
||||||
|
// ─── Undo / Redo ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def undo(id: String): Either[String, GameSession] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
if !session.invoker.canUndo then Left("No moves to undo")
|
||||||
|
else
|
||||||
|
val idx = session.invoker.getCurrentIndex
|
||||||
|
session.invoker.history(idx) match
|
||||||
|
case cmd: MoveCommand =>
|
||||||
|
cmd.previousContext match
|
||||||
|
case None => Left("Cannot undo: no previous context stored")
|
||||||
|
case Some(prevCtx) =>
|
||||||
|
session.invoker.undo()
|
||||||
|
val updated = session.copy(context = prevCtx, result = None, drawOfferedBy = None)
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
case _ => Left("Cannot undo this command type")
|
||||||
|
|
||||||
|
def redo(id: String): Either[String, GameSession] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
if !session.invoker.canRedo then Left("No moves to redo")
|
||||||
|
else
|
||||||
|
val idx = session.invoker.getCurrentIndex + 1
|
||||||
|
session.invoker.history(idx) match
|
||||||
|
case cmd: MoveCommand =>
|
||||||
|
cmd.moveResult match
|
||||||
|
case Some(MoveResult.Successful(nextCtx, _)) =>
|
||||||
|
session.invoker.redo()
|
||||||
|
val result = detectGameOver(nextCtx)
|
||||||
|
val updated = session.copy(context = nextCtx, result = result)
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
case _ => Left("Cannot redo: move result not available")
|
||||||
|
case _ => Left("Cannot redo this command type")
|
||||||
|
|
||||||
|
// ─── Resign ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def resign(id: String): Either[String, GameSession] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
if session.result.isDefined then Left("Game is already over")
|
||||||
|
else
|
||||||
|
val winner = session.context.turn.opposite
|
||||||
|
val updated = session.copy(result = Some(GameResult.Resign(winner)))
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
|
||||||
|
// ─── Draw actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def drawAction(id: String, action: String): Either[String, GameSession] = synchronized:
|
||||||
|
withSession(id): session =>
|
||||||
|
if session.result.isDefined then Left("Game is already over")
|
||||||
|
else
|
||||||
|
action match
|
||||||
|
case "offer" =>
|
||||||
|
val updated = session.copy(drawOfferedBy = Some(session.context.turn))
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
case "accept" =>
|
||||||
|
session.drawOfferedBy match
|
||||||
|
case None => Left("No draw offer to accept")
|
||||||
|
case Some(offerer) if offerer == session.context.turn =>
|
||||||
|
Left("Cannot accept your own draw offer")
|
||||||
|
case Some(_) =>
|
||||||
|
val updated = session.copy(result = Some(GameResult.AgreedDraw), drawOfferedBy = None)
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
case "decline" =>
|
||||||
|
session.drawOfferedBy match
|
||||||
|
case None => Left("No draw offer to decline")
|
||||||
|
case Some(_) =>
|
||||||
|
val updated = session.copy(drawOfferedBy = None)
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
case "claim" =>
|
||||||
|
if DefaultRules.isFiftyMoveRule(session.context) then
|
||||||
|
val updated = session.copy(result = Some(GameResult.FiftyMoveDraw))
|
||||||
|
games(id) = updated
|
||||||
|
Right(updated)
|
||||||
|
else Left("Fifty-move rule has not been triggered")
|
||||||
|
case other => Left(s"Unknown draw action: $other")
|
||||||
|
|
||||||
|
// ─── Import ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def importFen(req: ImportFenRequest): Either[String, GameSession] = synchronized:
|
||||||
|
FenParser.parseFen(req.fen) match
|
||||||
|
case Left(err) => Left(err)
|
||||||
|
case Right(ctx) =>
|
||||||
|
val id = generateId()
|
||||||
|
val session = newSession(id, req.white, req.black, ctx)
|
||||||
|
games(id) = session
|
||||||
|
Right(session)
|
||||||
|
|
||||||
|
def importPgn(pgn: String, white: Option[PlayerInfoDto], black: Option[PlayerInfoDto]): Either[String, GameSession] =
|
||||||
|
synchronized:
|
||||||
|
PgnParser.validatePgn(pgn) match
|
||||||
|
case Left(err) => Left(err)
|
||||||
|
case Right(game) =>
|
||||||
|
val id = generateId()
|
||||||
|
val session = newSession(id, white, black, GameContext.initial)
|
||||||
|
replayIntoSession(session, game.moves, GameContext.initial) match
|
||||||
|
case Left(err) => Left(err)
|
||||||
|
case Right(s) =>
|
||||||
|
games(id) = s
|
||||||
|
Right(s)
|
||||||
|
|
||||||
|
// ─── Private helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def withSession[A](id: String)(f: GameSession => Either[String, A]): Either[String, A] =
|
||||||
|
games.get(id) match
|
||||||
|
case None => Left(s"Game $id not found")
|
||||||
|
case Some(session) => f(session)
|
||||||
|
|
||||||
|
private def generateId(): String =
|
||||||
|
var id = GameId.generate()
|
||||||
|
while games.contains(id) do id = GameId.generate()
|
||||||
|
id
|
||||||
|
|
||||||
|
private def newSession(
|
||||||
|
id: String,
|
||||||
|
white: Option[PlayerInfoDto],
|
||||||
|
black: Option[PlayerInfoDto],
|
||||||
|
ctx: GameContext,
|
||||||
|
): GameSession =
|
||||||
|
GameSession(
|
||||||
|
gameId = id,
|
||||||
|
white = toPlayerInfo(white, "white", "White"),
|
||||||
|
black = toPlayerInfo(black, "black", "Black"),
|
||||||
|
context = ctx,
|
||||||
|
invoker = new CommandInvoker(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo =
|
||||||
|
dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||||
|
|
||||||
|
private def parseUci(uci: String): Option[(Square, Square, Option[PromotionPiece])] =
|
||||||
|
if uci.length < 4 || uci.length > 5 then None
|
||||||
|
else
|
||||||
|
for
|
||||||
|
from <- Square.fromAlgebraic(uci.substring(0, 2))
|
||||||
|
to <- Square.fromAlgebraic(uci.substring(2, 4))
|
||||||
|
yield
|
||||||
|
val promotion = if uci.length == 5 then parsePromotionChar(uci.charAt(4)) else None
|
||||||
|
(from, to, promotion)
|
||||||
|
|
||||||
|
private def parsePromotionChar(c: Char): Option[PromotionPiece] =
|
||||||
|
c match
|
||||||
|
case 'q' => Some(PromotionPiece.Queen)
|
||||||
|
case 'r' => Some(PromotionPiece.Rook)
|
||||||
|
case 'b' => Some(PromotionPiece.Bishop)
|
||||||
|
case 'n' => Some(PromotionPiece.Knight)
|
||||||
|
case _ => None
|
||||||
|
|
||||||
|
private def findMatchingMove(
|
||||||
|
candidates: List[Move],
|
||||||
|
to: Square,
|
||||||
|
promotion: Option[PromotionPiece],
|
||||||
|
): Option[Move] =
|
||||||
|
candidates.filter(_.to == to) match
|
||||||
|
case Nil => None
|
||||||
|
case moves =>
|
||||||
|
promotion match
|
||||||
|
case Some(pp) => moves.find(_.moveType == MoveType.Promotion(pp))
|
||||||
|
case None =>
|
||||||
|
moves
|
||||||
|
.find(m => !m.moveType.isInstanceOf[MoveType.Promotion])
|
||||||
|
.orElse(moves.headOption)
|
||||||
|
|
||||||
|
private def detectGameOver(ctx: GameContext): Option[GameResult] =
|
||||||
|
if DefaultRules.isCheckmate(ctx) then Some(GameResult.Checkmate(ctx.turn.opposite))
|
||||||
|
else if DefaultRules.isStalemate(ctx) then Some(GameResult.Stalemate)
|
||||||
|
else if DefaultRules.isInsufficientMaterial(ctx) then Some(GameResult.InsufficientMaterial)
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def replayIntoSession(
|
||||||
|
session: GameSession,
|
||||||
|
moves: List[Move],
|
||||||
|
startCtx: GameContext,
|
||||||
|
): Either[String, GameSession] =
|
||||||
|
moves.foldLeft[Either[String, GameSession]](Right(session)):
|
||||||
|
case (Left(err), _) => Left(err)
|
||||||
|
case (Right(s), move) =>
|
||||||
|
val legal = DefaultRules.legalMoves(s.context)(move.from)
|
||||||
|
legal
|
||||||
|
.find(m => m.from == move.from && m.to == move.to && m.moveType == move.moveType)
|
||||||
|
.orElse(legal.find(m => m.from == move.from && m.to == move.to)) match
|
||||||
|
case None => Left(s"Illegal move in PGN: $move")
|
||||||
|
case Some(legalMove) =>
|
||||||
|
val nextCtx = DefaultRules.applyMove(s.context)(legalMove)
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = legalMove.from,
|
||||||
|
to = legalMove.to,
|
||||||
|
moveResult = Some(MoveResult.Successful(nextCtx, s.context.board.pieceAt(legalMove.to))),
|
||||||
|
previousContext = Some(s.context),
|
||||||
|
)
|
||||||
|
s.invoker.execute(cmd)
|
||||||
|
Right(s.copy(context = nextCtx))
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import de.nowchess.backcore.dto.*
|
||||||
|
import de.nowchess.backcore.game.{GameMapper, GameStore}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
|
||||||
|
@Path("/api/board/game")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameResource @Inject() (store: GameStore):
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
def createGame(req: CreateGameRequest): Response =
|
||||||
|
val session = store.create(Option(req).getOrElse(CreateGameRequest()))
|
||||||
|
Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}")
|
||||||
|
def getGame(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.get(gameId) match
|
||||||
|
case Some(session) => Response.ok(GameMapper.toGameFull(session)).build()
|
||||||
|
case None =>
|
||||||
|
Response
|
||||||
|
.status(404)
|
||||||
|
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}/stream")
|
||||||
|
@Produces(Array("application/x-ndjson"))
|
||||||
|
def streamGame(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.get(gameId) match
|
||||||
|
case None =>
|
||||||
|
Response
|
||||||
|
.status(404)
|
||||||
|
.`type`(MediaType.APPLICATION_JSON)
|
||||||
|
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||||
|
.build()
|
||||||
|
case Some(session) =>
|
||||||
|
// Simplified: return a single-line NDJSON snapshot of the current game state
|
||||||
|
val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(session)}}"""
|
||||||
|
Response.ok(event + "\n").build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/resign")
|
||||||
|
def resignGame(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.resign(gameId) match
|
||||||
|
case Right(_) => Response.ok(OkResponse()).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/draw/{action}")
|
||||||
|
def drawAction(
|
||||||
|
@PathParam("gameId") gameId: String,
|
||||||
|
@PathParam("action") action: String,
|
||||||
|
): Response =
|
||||||
|
store.drawAction(gameId, action) match
|
||||||
|
case Right(_) => Response.ok(OkResponse()).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}/export/fen")
|
||||||
|
@Produces(Array(MediaType.TEXT_PLAIN))
|
||||||
|
def exportFen(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.get(gameId) match
|
||||||
|
case None =>
|
||||||
|
Response
|
||||||
|
.status(404)
|
||||||
|
.`type`(MediaType.APPLICATION_JSON)
|
||||||
|
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||||
|
.build()
|
||||||
|
case Some(session) =>
|
||||||
|
import de.nowchess.io.fen.FenExporter
|
||||||
|
Response.ok(FenExporter.exportGameContext(session.context)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}/export/pgn")
|
||||||
|
@Produces(Array("application/x-chess-pgn"))
|
||||||
|
def exportPgn(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.get(gameId) match
|
||||||
|
case None =>
|
||||||
|
Response
|
||||||
|
.status(404)
|
||||||
|
.`type`(MediaType.APPLICATION_JSON)
|
||||||
|
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||||
|
.build()
|
||||||
|
case Some(session) =>
|
||||||
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
|
Response.ok(PgnExporter.exportGameContext(session.context)).build()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import de.nowchess.backcore.dto.{ApiErrorResponse, ImportFenRequest, ImportPgnRequest}
|
||||||
|
import de.nowchess.backcore.game.{GameMapper, GameStore}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
|
||||||
|
@Path("/api/board/game/import")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@ApplicationScoped
|
||||||
|
class ImportResource @Inject() (store: GameStore):
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/fen")
|
||||||
|
def importFen(req: ImportFenRequest): Response =
|
||||||
|
store.importFen(Option(req).getOrElse(ImportFenRequest())) match
|
||||||
|
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
||||||
|
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/pgn")
|
||||||
|
def importPgn(req: ImportPgnRequest): Response =
|
||||||
|
val body = Option(req).getOrElse(ImportPgnRequest())
|
||||||
|
store.importPgn(body.pgn, None, None) match
|
||||||
|
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
||||||
|
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Square
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.backcore.dto.*
|
||||||
|
import de.nowchess.backcore.game.{GameMapper, GameStore}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
|
||||||
|
@Path("/api/board/game")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@ApplicationScoped
|
||||||
|
class MoveResource @Inject() (store: GameStore):
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/move/{uci}")
|
||||||
|
def makeMove(
|
||||||
|
@PathParam("gameId") gameId: String,
|
||||||
|
@PathParam("uci") uci: String,
|
||||||
|
): Response =
|
||||||
|
store.applyMove(gameId, uci) match
|
||||||
|
case Right(session) => Response.ok(GameMapper.toGameState(session)).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}/moves")
|
||||||
|
def getLegalMoves(
|
||||||
|
@PathParam("gameId") gameId: String,
|
||||||
|
@QueryParam("square") squareParam: String,
|
||||||
|
): Response =
|
||||||
|
val square = Option(squareParam).flatMap(Square.fromAlgebraic)
|
||||||
|
store.legalMoves(gameId, square) match
|
||||||
|
case Right(moves) =>
|
||||||
|
val dtos = moves.map(toLegalMoveDto)
|
||||||
|
Response.ok(LegalMovesResponse(dtos)).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("ERROR", err)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/undo")
|
||||||
|
def undoMove(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.undo(gameId) match
|
||||||
|
case Right(session) => Response.ok(GameMapper.toGameState(session)).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/redo")
|
||||||
|
def redoMove(@PathParam("gameId") gameId: String): Response =
|
||||||
|
store.redo(gameId) match
|
||||||
|
case Right(session) => Response.ok(GameMapper.toGameState(session)).build()
|
||||||
|
case Left(err) if err.contains("not found") =>
|
||||||
|
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||||
|
case Left(err) =>
|
||||||
|
Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build()
|
||||||
|
|
||||||
|
private def toLegalMoveDto(move: Move): LegalMoveDto =
|
||||||
|
val uci = GameMapper.moveToUci(move)
|
||||||
|
val (moveType, promotion) = move.moveType match
|
||||||
|
case MoveType.Normal(true) => ("capture", None)
|
||||||
|
case MoveType.Normal(false) => ("normal", None)
|
||||||
|
case MoveType.CastleKingside => ("castleKingside", None)
|
||||||
|
case MoveType.CastleQueenside => ("castleQueenside", None)
|
||||||
|
case MoveType.EnPassant => ("enPassant", None)
|
||||||
|
case MoveType.Promotion(pp) =>
|
||||||
|
val pName = pp match
|
||||||
|
case PromotionPiece.Queen => "queen"
|
||||||
|
case PromotionPiece.Rook => "rook"
|
||||||
|
case PromotionPiece.Bishop => "bishop"
|
||||||
|
case PromotionPiece.Knight => "knight"
|
||||||
|
("promotion", Some(pName))
|
||||||
|
LegalMoveDto(
|
||||||
|
from = move.from.toString,
|
||||||
|
to = move.to.toString,
|
||||||
|
uci = uci,
|
||||||
|
moveType = moveType,
|
||||||
|
promotion = promotion,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.nowchess.backcore
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class BackcoreStartupTest:
|
||||||
|
@Test
|
||||||
|
def applicationStarts(): Unit =
|
||||||
|
// If we get here the Quarkus container started successfully
|
||||||
|
()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.hamcrest.Matchers.{equalTo, matchesPattern, notNullValue}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class GameResourceTest:
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createGameReturns201WithGameId(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
|
||||||
|
.body("state.fen", notNullValue())
|
||||||
|
.body("state.turn", equalTo("white"))
|
||||||
|
.body("state.status", equalTo("started"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createGameWithPlayersReturns201(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("""{"white":{"id":"p1","displayName":"Alice"},"black":{"id":"p2","displayName":"Bob"}}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("white.id", equalTo("p1"))
|
||||||
|
.body("black.displayName", equalTo("Bob"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getGameReturns200ForExistingGame(): Unit =
|
||||||
|
val gameId = RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("gameId", equalTo(gameId))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getGameReturns404ForUnknownId(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/board/game/XXXXXXXX")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.hamcrest.Matchers.{containsString, equalTo, matchesPattern, notNullValue}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class ImportExportTest:
|
||||||
|
|
||||||
|
private val startFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||||
|
|
||||||
|
// ─── Import FEN ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def importFenReturns201WithCorrectPosition(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(s"""{"fen":"$startFen"}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/import/fen")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
|
||||||
|
.body("state.fen", equalTo(startFen))
|
||||||
|
.body("state.turn", equalTo("white"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def importFenWithCustomPositionWorks(): Unit =
|
||||||
|
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(s"""{"fen":"$fen"}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/import/fen")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("state.fen", equalTo(fen))
|
||||||
|
.body("state.turn", equalTo("black"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def importFenWithInvalidFenReturns400(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("""{"fen":"not-a-fen"}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/import/fen")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
// ─── Import PGN ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def importPgnReturns201(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("""{"pgn":"1. e4 e5 2. Nf3 Nc6 *"}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/import/pgn")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
|
||||||
|
.body("state.turn", equalTo("white"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def importPgnWithInvalidPgnReturns400(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("""{"pgn":"1. z9 *"}""")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/import/pgn")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
// ─── Export FEN ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def exportFenReturnsStartingFen(): Unit =
|
||||||
|
val gameId = RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId/export/fen")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body(equalTo(startFen))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def exportFenOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/board/game/XXXXXXXX/export/fen")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
|
|
||||||
|
// ─── Export PGN ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def exportPgnReturnsText(): Unit =
|
||||||
|
val gameId = RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId/export/pgn")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body(containsString("e4"))
|
||||||
|
|
||||||
|
// ─── Stream ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def streamReturnsNdjsonSnapshot(): Unit =
|
||||||
|
val gameId = RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
val body = RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId/stream")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType("application/x-ndjson")
|
||||||
|
.extract()
|
||||||
|
.body()
|
||||||
|
.asString()
|
||||||
|
|
||||||
|
assert(body.trim.startsWith("""{"type":"gameFull""""), s"Expected gameFull event, got: $body")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def streamOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/board/game/XXXXXXXX/stream")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.hamcrest.Matchers.{containsString, empty, equalTo, hasItem, hasItems, not, notNullValue}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class MoveResourceTest:
|
||||||
|
|
||||||
|
private def createGame(): String =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def makeMoveReturns200WithUpdatedFen(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("fen", containsString("4P3")) // e4 pawn present in FEN
|
||||||
|
.body("turn", equalTo("black"))
|
||||||
|
.body("moves", hasItem("e2e4"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def makeMoveOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/XXXXXXXX/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def illegalMoveReturns400(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e5") // illegal — pawns can't jump 3 squares
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getLegalMovesReturnsNonEmptyList(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId/moves")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("moves", not(empty()))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getLegalMovesFilteredBySquareReturnsCorrectMoves(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId/moves?square=e2")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("moves.uci", hasItems("e2e3", "e2e4"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def getLegalMovesOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/board/game/XXXXXXXX/moves")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.hamcrest.Matchers.{equalTo, notNullValue}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class ResignDrawTest:
|
||||||
|
|
||||||
|
private def createGame(): String =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
// ─── Resign ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def resignReturns200(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/resign")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", equalTo(true))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def afterResignGameShowsResignStatusAndWinner(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/resign")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("state.status", equalTo("resign"))
|
||||||
|
.body("state.winner", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def resignOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/XXXXXXXX/resign")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
|
|
||||||
|
// ─── Draw ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def offerDrawSetsDrawOfferedStatus(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/offer")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", equalTo(true))
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("state.status", equalTo("drawOffered"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def acceptDrawAfterOfferSetsDrawStatus(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
// White offers
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/offer")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
// Black moves so it's black's turn... actually the API doesn't enforce turn-based draw accept.
|
||||||
|
// White offered, so black (opponent) accepts — but since there's no auth, we just call accept.
|
||||||
|
// The GameStore checks drawOfferedBy != turn to allow accept.
|
||||||
|
// White offered on white's turn, so black needs to accept — but current turn is still white.
|
||||||
|
// We need to make a move first to switch turns.
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
// Now it's black's turn and white offered the draw — black accepts
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/accept")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", equalTo(true))
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("state.status", equalTo("draw"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def declineDrawClearsOffer(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/offer")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/decline")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", equalTo(true))
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/board/game/$gameId")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("state.status", equalTo("started"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def acceptWithoutOfferReturns400(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/draw/accept")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def drawOnUnknownGameReturns404(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game/XXXXXXXX/draw/offer")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(404)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.nowchess.backcore.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import org.hamcrest.Matchers.{containsString, equalTo}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class UndoRedoTest:
|
||||||
|
|
||||||
|
private val initialFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||||
|
|
||||||
|
private def createGame(): String =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{}")
|
||||||
|
.when()
|
||||||
|
.post("/api/board/game")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("gameId")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def undoAfterMoveRestoresOriginalPosition(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/undo")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("fen", equalTo(initialFen))
|
||||||
|
.body("undoAvailable", equalTo(false))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def redoAfterUndoRestoresMovedPosition(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/undo")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/redo")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("fen", containsString("4P3"))
|
||||||
|
.body("turn", equalTo("black"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def undoWithNoHistoryReturns400(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/undo")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def redoWithNoRedoStackReturns400(): Unit =
|
||||||
|
val gameId = createGame()
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.post(s"/api/board/game/$gameId/redo")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(400)
|
||||||
@@ -120,3 +120,143 @@
|
|||||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-02)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-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-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
## (2026-04-14)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-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-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("scala")
|
id("scala")
|
||||||
id("org.scoverage") version "8.1"
|
id("org.scoverage") version "8.1"
|
||||||
application
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -22,19 +21,10 @@ scoverage {
|
|||||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
|
||||||
mainClass.set("de.nowchess.chess.Main")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<ScalaCompile> {
|
tasks.withType<ScalaCompile> {
|
||||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named<JavaExec>("run") {
|
|
||||||
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
|
||||||
standardInput = System.`in`
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation("org.scala-lang:scala3-compiler_3") {
|
implementation("org.scala-lang:scala3-compiler_3") {
|
||||||
@@ -49,6 +39,8 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
|
implementation(project(":modules:io"))
|
||||||
|
implementation(project(":modules:rule"))
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
@@ -62,7 +54,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("passed", "skipped", "failed")
|
events("skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
import de.nowchess.api.board.{Piece, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Marker trait for all commands that can be executed and undone.
|
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* transitions.
|
||||||
*/
|
*/
|
||||||
trait Command:
|
trait Command:
|
||||||
/** Execute the command and return true if successful, false otherwise. */
|
/** Execute the command and return true if successful, false otherwise. */
|
||||||
@@ -16,30 +16,28 @@ trait Command:
|
|||||||
/** A human-readable description of this command. */
|
/** A human-readable description of this command. */
|
||||||
def description: String
|
def description: String
|
||||||
|
|
||||||
/** Command to move a piece from one square to another.
|
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||||
* Stores the move result so undo can restore previous state.
|
|
||||||
*/
|
*/
|
||||||
case class MoveCommand(
|
case class MoveCommand(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
moveResult: Option[MoveResult] = None,
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
notation: String = "",
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
moveResult.isDefined
|
moveResult.isDefined
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = s"Move from $from to $to"
|
override def description: String = s"Move from $from to $to"
|
||||||
|
|
||||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||||
sealed trait MoveResult
|
sealed trait MoveResult
|
||||||
object MoveResult:
|
object MoveResult:
|
||||||
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
|
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||||
case object InvalidFormat extends MoveResult
|
case object InvalidFormat extends MoveResult
|
||||||
case object InvalidMove extends MoveResult
|
case object InvalidMove extends MoveResult
|
||||||
|
|
||||||
@@ -51,14 +49,12 @@ case class QuitCommand() extends Command:
|
|||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = "Reset board"
|
override def description: String = "Reset board"
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
|
|||||||
/** Manages command execution and history for undo/redo support. */
|
/** Manages command execution and history for undo/redo support. */
|
||||||
class CommandInvoker:
|
class CommandInvoker:
|
||||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentIndex = -1
|
private var currentIndex = -1
|
||||||
|
|
||||||
/** Execute a command and add it to history.
|
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||||
* Discards any redo history if not at the end of the stack.
|
|
||||||
*/
|
*/
|
||||||
def execute(command: Command): Boolean = synchronized {
|
def execute(command: Command): Boolean = synchronized {
|
||||||
if command.execute() then
|
if command.execute() then
|
||||||
// Remove any commands after current index (redo stack is discarded)
|
// Remove any commands after current index (redo stack is discarded)
|
||||||
while currentIndex < executedCommands.size - 1 do
|
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||||
executedCommands.remove(executedCommands.size - 1)
|
|
||||||
executedCommands += command
|
executedCommands += command
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last executed command if possible. */
|
/** Undo the last executed command if possible. */
|
||||||
@@ -27,10 +25,8 @@ class CommandInvoker:
|
|||||||
if command.undo() then
|
if command.undo() then
|
||||||
currentIndex -= 1
|
currentIndex -= 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redo the next command in history if available. */
|
/** Redo the next command in history if available. */
|
||||||
@@ -40,10 +36,8 @@ class CommandInvoker:
|
|||||||
if command.execute() then
|
if command.execute() then
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the history of all executed commands. */
|
/** Get the history of all executed commands. */
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package de.nowchess.chess.controller
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.*
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Result ADT returned by the pure processMove function
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sealed trait MoveResult
|
|
||||||
object MoveResult:
|
|
||||||
case object Quit extends MoveResult
|
|
||||||
case class InvalidFormat(raw: String) extends MoveResult
|
|
||||||
case object NoPiece extends MoveResult
|
|
||||||
case object WrongColor extends MoveResult
|
|
||||||
case object IllegalMove extends MoveResult
|
|
||||||
case class PromotionRequired(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
boardBefore: Board,
|
|
||||||
historyBefore: GameHistory,
|
|
||||||
captured: Option[Piece],
|
|
||||||
turn: Color
|
|
||||||
) extends MoveResult
|
|
||||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
||||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
||||||
case class Checkmate(winner: Color) extends MoveResult
|
|
||||||
case object Stalemate extends MoveResult
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Controller
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
object GameController:
|
|
||||||
|
|
||||||
/** Pure function: interprets one raw input line against the current game context.
|
|
||||||
* Has no I/O side effects — all output must be handled by the caller.
|
|
||||||
*/
|
|
||||||
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
|
||||||
raw.trim match
|
|
||||||
case "quit" | "q" => MoveResult.Quit
|
|
||||||
case trimmed =>
|
|
||||||
Parser.parseMove(trimmed) match
|
|
||||||
case None => MoveResult.InvalidFormat(trimmed)
|
|
||||||
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
|
|
||||||
|
|
||||||
/** Apply a previously detected promotion move with the chosen piece.
|
|
||||||
* Called after processMove returned PromotionRequired.
|
|
||||||
*/
|
|
||||||
def completePromotion(
|
|
||||||
board: Board,
|
|
||||||
history: GameHistory,
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
piece: PromotionPiece,
|
|
||||||
turn: Color
|
|
||||||
): MoveResult =
|
|
||||||
val (boardAfterMove, captured) = board.withMove(from, to)
|
|
||||||
val promotedPieceType = piece match
|
|
||||||
case PromotionPiece.Queen => PieceType.Queen
|
|
||||||
case PromotionPiece.Rook => PieceType.Rook
|
|
||||||
case PromotionPiece.Bishop => PieceType.Bishop
|
|
||||||
case PromotionPiece.Knight => PieceType.Knight
|
|
||||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
|
||||||
// Promotion is always a pawn move → clock resets
|
|
||||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
|
||||||
toMoveResult(newBoard, newHistory, captured, turn)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Private helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case None => MoveResult.NoPiece
|
|
||||||
case Some(piece) if piece.color != turn => MoveResult.WrongColor
|
|
||||||
case Some(_) =>
|
|
||||||
if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
|
|
||||||
else if MoveValidator.isPromotionMove(board, from, to) then
|
|
||||||
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
|
|
||||||
else applyNormalMove(board, history, turn, from, to)
|
|
||||||
|
|
||||||
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
|
||||||
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
|
|
||||||
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
|
||||||
val (newBoard, captured) = castleOpt match
|
|
||||||
case Some(side) => (board.withCastle(turn, side), None)
|
|
||||||
case None =>
|
|
||||||
val (b, cap) = board.withMove(from, to)
|
|
||||||
if isEP then
|
|
||||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
|
||||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
|
||||||
else (b, cap)
|
|
||||||
val pieceType = board.pieceAt(from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
|
||||||
val wasPawnMove = pieceType == PieceType.Pawn
|
|
||||||
val wasCapture = captured.isDefined
|
|
||||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
|
|
||||||
toMoveResult(newBoard, newHistory, captured, turn)
|
|
||||||
|
|
||||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
|
||||||
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
|
||||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
|
||||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
|
||||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
|
||||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
|
||||||
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
|
|||||||
|
|
||||||
object Parser:
|
object Parser:
|
||||||
|
|
||||||
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||||
* Returns None for any input that does not match the expected format.
|
* format.
|
||||||
*/
|
*/
|
||||||
def parseMove(input: String): Option[(Square, Square)] =
|
def parseMove(input: String): Option[(Square, Square)] =
|
||||||
val trimmed = input.trim.toLowerCase
|
val trimmed = input.trim.toLowerCase
|
||||||
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
Option
|
||||||
|
.when(trimmed.length == 4)(trimmed)
|
||||||
|
.flatMap: s =>
|
||||||
for
|
for
|
||||||
from <- parseSquare(s.substring(0, 2))
|
from <- parseSquare(s.substring(0, 2))
|
||||||
to <- parseSquare(s.substring(2, 4))
|
to <- parseSquare(s.substring(2, 4))
|
||||||
yield (from, to)
|
yield (from, to)
|
||||||
|
|
||||||
private def parseSquare(s: String): Option[Square] =
|
private def parseSquare(s: String): Option[Square] =
|
||||||
Option.when(s.length == 2)(s).flatMap: sq =>
|
Option
|
||||||
|
.when(s.length == 2)(s)
|
||||||
|
.flatMap: sq =>
|
||||||
val fileIdx = sq(0) - 'a'
|
val fileIdx = sq(0) - 'a'
|
||||||
val rankIdx = sq(1) - '1'
|
val rankIdx = sq(1) - '1'
|
||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,65 +1,55 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||||
|
import de.nowchess.rules.RuleSet
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||||
* This class is the single source of truth for the game state.
|
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||||
* All user interactions must go through this engine via Commands, and all state changes
|
|
||||||
* are communicated to observers via GameEvent notifications.
|
|
||||||
*/
|
*/
|
||||||
class GameEngine(
|
class GameEngine(
|
||||||
initialBoard: Board = Board.initial,
|
val initialContext: GameContext = GameContext.initial,
|
||||||
initialHistory: GameHistory = GameHistory.empty,
|
val ruleSet: RuleSet = DefaultRules,
|
||||||
initialTurn: Color = Color.White,
|
|
||||||
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
|
||||||
GameController.completePromotion
|
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
private var currentBoard: Board = initialBoard
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentHistory: GameHistory = initialHistory
|
private var currentContext: GameContext = initialContext
|
||||||
private var currentTurn: Color = initialTurn
|
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Inner class for tracking pending promotion state */
|
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||||
private case class PendingPromotion(
|
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||||
from: Square, to: Square,
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
boardBefore: Board, historyBefore: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Current pending promotion, if any */
|
|
||||||
private var pendingPromotion: Option[PendingPromotion] = None
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized(currentContext.board)
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def turn: Color = synchronized(currentContext.turn)
|
||||||
def turn: Color = synchronized { currentTurn }
|
def context: GameContext = synchronized(currentContext)
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||||
|
|
||||||
/** Check if redo is available. */
|
/** Check if redo is available. */
|
||||||
def canRedo: Boolean = synchronized { invoker.canRedo }
|
def canRedo: Boolean = synchronized(invoker.canRedo)
|
||||||
|
|
||||||
/** Get the command history for inspection (testing/debugging). */
|
/** Get the command history for inspection (testing/debugging). */
|
||||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
|
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
|
||||||
|
|
||||||
/** Process a raw move input string and update game state if valid.
|
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||||
* Notifies all observers of the outcome via GameEvent.
|
* GameEvent.
|
||||||
*/
|
*/
|
||||||
def processUserInput(rawInput: String): Unit = synchronized {
|
def processUserInput(rawInput: String): Unit = synchronized {
|
||||||
val trimmed = rawInput.trim.toLowerCase
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
trimmed match
|
trimmed match
|
||||||
case "quit" | "q" =>
|
case "quit" | "q" =>
|
||||||
// Client should handle quit logic; we just return
|
|
||||||
()
|
()
|
||||||
|
|
||||||
case "undo" =>
|
case "undo" =>
|
||||||
@@ -69,284 +59,260 @@ class GameEngine(
|
|||||||
performRedo()
|
performRedo()
|
||||||
|
|
||||||
case "draw" =>
|
case "draw" =>
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
if currentContext.halfMoveClock >= 100 then
|
||||||
currentBoard = Board.initial
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
currentBoard, currentHistory, currentTurn,
|
InvalidMoveEvent(
|
||||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
currentContext,
|
||||||
))
|
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
case "" =>
|
case "" =>
|
||||||
val event = InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"Please enter a valid move or command."
|
|
||||||
)
|
|
||||||
notifyObservers(event)
|
|
||||||
|
|
||||||
case moveInput =>
|
case moveInput =>
|
||||||
Parser.parseMove(moveInput) match
|
Parser.parseMove(moveInput) match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
currentBoard, currentHistory, currentTurn,
|
InvalidMoveEvent(
|
||||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
currentContext,
|
||||||
))
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||||
case Some((from, to)) =>
|
),
|
||||||
handleParsedMove(from, to, moveInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = from,
|
|
||||||
to = to,
|
|
||||||
previousBoard = Some(currentBoard),
|
|
||||||
previousHistory = Some(currentHistory),
|
|
||||||
previousTurn = Some(currentTurn)
|
|
||||||
)
|
)
|
||||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
case Some((from, to)) =>
|
||||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
handleParsedMove(from, to)
|
||||||
handleFailedMove(moveInput)
|
|
||||||
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
|
||||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
|
||||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
|
||||||
|
|
||||||
/** Undo the last move. */
|
|
||||||
def undo(): Unit = synchronized {
|
|
||||||
performUndo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redo the last undone move. */
|
private def handleParsedMove(from: Square, to: Square): Unit =
|
||||||
def redo(): Unit = synchronized {
|
currentContext.board.pieceAt(from) match
|
||||||
performRedo()
|
case None =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
|
||||||
|
case Some(piece) if piece.color != currentContext.turn =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
|
||||||
|
case Some(piece) =>
|
||||||
|
val legal = ruleSet.legalMoves(currentContext)(from)
|
||||||
|
// Find all legal moves going to `to`
|
||||||
|
val candidates = legal.filter(_.to == to)
|
||||||
|
candidates match
|
||||||
|
case Nil =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
|
||||||
|
case moves if isPromotionMove(piece, to) =>
|
||||||
|
// Multiple moves (one per promotion piece) — ask user to choose
|
||||||
|
val contextBefore = currentContext
|
||||||
|
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
|
||||||
|
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
|
||||||
|
case move :: _ =>
|
||||||
|
executeMove(move)
|
||||||
|
|
||||||
|
private def isPromotionMove(piece: Piece, to: Square): Boolean =
|
||||||
|
piece.pieceType == PieceType.Pawn && {
|
||||||
|
val promoRank = if piece.color == Color.White then 7 else 0
|
||||||
|
to.rank.ordinal == promoRank
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a player's promotion piece choice.
|
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||||
* Must only be called when isPendingPromotion is true.
|
|
||||||
*/
|
*/
|
||||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||||
pendingPromotion match
|
pendingPromotion match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
|
||||||
case Some(pending) =>
|
case Some(pending) =>
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
val cmd = MoveCommand(
|
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||||
from = pending.from,
|
// Verify it's actually legal
|
||||||
to = pending.to,
|
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||||
previousBoard = Some(pending.boardBefore),
|
if legal.contains(move) then executeMove(move)
|
||||||
previousHistory = Some(pending.historyBefore),
|
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||||
previousTurn = Some(pending.turn)
|
|
||||||
)
|
|
||||||
completePromotionFn(
|
|
||||||
pending.boardBefore, pending.historyBefore,
|
|
||||||
pending.from, pending.to, piece, pending.turn
|
|
||||||
) match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate and load a PGN string.
|
/** Undo the last move. */
|
||||||
* Each move is replayed through the command system so undo/redo is available after loading.
|
def undo(): Unit = synchronized(performUndo())
|
||||||
* Returns Right(()) on success; Left(error) if any move is illegal or the position impossible. */
|
|
||||||
def loadPgn(pgn: String): Either[String, Unit] = synchronized {
|
|
||||||
PgnParser.validatePgn(pgn) match
|
|
||||||
case Left(err) =>
|
|
||||||
Left(err)
|
|
||||||
case Right(game) =>
|
|
||||||
val initialBoardBeforeLoad = currentBoard
|
|
||||||
val initialHistoryBeforeLoad = currentHistory
|
|
||||||
val initialTurnBeforeLoad = currentTurn
|
|
||||||
|
|
||||||
currentBoard = Board.initial
|
/** Redo the last undone move. */
|
||||||
currentHistory = GameHistory.empty
|
def redo(): Unit = synchronized(performRedo())
|
||||||
currentTurn = Color.White
|
|
||||||
|
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||||
|
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
|
||||||
|
*/
|
||||||
|
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||||
|
importer.importGameContext(input) match
|
||||||
|
case Left(err) => Left(err)
|
||||||
|
case Right(ctx) =>
|
||||||
|
replayGame(ctx).map { _ =>
|
||||||
|
notifyObservers(PgnLoadedEvent(currentContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def replayGame(ctx: GameContext): Either[String, Unit] =
|
||||||
|
val savedContext = currentContext
|
||||||
|
currentContext = GameContext.initial
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
|
|
||||||
var error: Option[String] = None
|
if ctx.moves.isEmpty then
|
||||||
import scala.util.control.Breaks._
|
currentContext = ctx
|
||||||
breakable {
|
|
||||||
game.moves.foreach { move =>
|
|
||||||
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
|
|
||||||
move.promotionPiece.foreach(completePromotion)
|
|
||||||
|
|
||||||
// If the move failed to execute properly, stop and report
|
|
||||||
// (validatePgn should have caught this, but we're being safe)
|
|
||||||
if pendingPromotion.isDefined && move.promotionPiece.isEmpty then
|
|
||||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
|
||||||
break()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
error match
|
|
||||||
case Some(err) =>
|
|
||||||
currentBoard = initialBoardBeforeLoad
|
|
||||||
currentHistory = initialHistoryBeforeLoad
|
|
||||||
currentTurn = initialTurnBeforeLoad
|
|
||||||
Left(err)
|
|
||||||
case None =>
|
|
||||||
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
|
|
||||||
Right(())
|
Right(())
|
||||||
|
else replayMoves(ctx.moves, savedContext)
|
||||||
|
|
||||||
|
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||||
|
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||||
|
acc.flatMap(_ => applyReplayMove(move))
|
||||||
|
}
|
||||||
|
result.left.foreach(_ => currentContext = savedContext)
|
||||||
|
result
|
||||||
|
|
||||||
|
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||||
|
handleParsedMove(move.from, move.to)
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
|
||||||
|
completePromotion(pp)
|
||||||
|
Right(())
|
||||||
|
case MoveType.Promotion(_) =>
|
||||||
|
Left(s"Promotion required for move ${move.from}${move.to}")
|
||||||
|
case _ => Right(())
|
||||||
|
|
||||||
|
/** Export the current game context using the provided exporter. */
|
||||||
|
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||||
|
exporter.exportGameContext(currentContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||||
def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized {
|
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||||
currentBoard = board
|
currentContext = newContext
|
||||||
currentHistory = history
|
|
||||||
currentTurn = turn
|
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the board to initial position. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentContext = GameContext.initial
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──── Private Helpers ────
|
// ──── Private helpers ────
|
||||||
|
|
||||||
|
private def executeMove(move: Move): Unit =
|
||||||
|
val contextBefore = currentContext
|
||||||
|
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||||
|
val captured = computeCaptured(currentContext, move)
|
||||||
|
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = move.from,
|
||||||
|
to = move.to,
|
||||||
|
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||||
|
previousContext = Some(contextBefore),
|
||||||
|
notation = translateMoveToNotation(move, contextBefore.board),
|
||||||
|
)
|
||||||
|
invoker.execute(cmd)
|
||||||
|
currentContext = nextContext
|
||||||
|
|
||||||
|
notifyObservers(
|
||||||
|
MoveExecutedEvent(
|
||||||
|
currentContext,
|
||||||
|
move.from.toString,
|
||||||
|
move.to.toString,
|
||||||
|
captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if ruleSet.isCheckmate(currentContext) then
|
||||||
|
val winner = currentContext.turn.opposite
|
||||||
|
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
|
||||||
|
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||||
|
invoker.clear()
|
||||||
|
else if ruleSet.isStalemate(currentContext) then
|
||||||
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||||
|
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||||
|
invoker.clear()
|
||||||
|
else if ruleSet.isInsufficientMaterial(currentContext) then
|
||||||
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
|
||||||
|
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
|
||||||
|
invoker.clear()
|
||||||
|
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
|
|
||||||
|
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
|
|
||||||
|
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.CastleKingside => "O-O"
|
||||||
|
case MoveType.CastleQueenside => "O-O-O"
|
||||||
|
case MoveType.EnPassant => enPassantNotation(move)
|
||||||
|
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||||
|
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
||||||
|
|
||||||
|
private def enPassantNotation(move: Move): String =
|
||||||
|
s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
|
||||||
|
private def promotionNotation(move: Move, piece: PromotionPiece): String =
|
||||||
|
val ppChar = piece match
|
||||||
|
case PromotionPiece.Queen => "Q"
|
||||||
|
case PromotionPiece.Rook => "R"
|
||||||
|
case PromotionPiece.Bishop => "B"
|
||||||
|
case PromotionPiece.Knight => "N"
|
||||||
|
s"${move.to}=$ppChar"
|
||||||
|
|
||||||
|
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
|
||||||
|
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||||
|
case Some(PieceType.Pawn) =>
|
||||||
|
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
else move.to.toString
|
||||||
|
case Some(pt) =>
|
||||||
|
val letter = pieceNotation(pt)
|
||||||
|
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
|
||||||
|
case None => move.to.toString
|
||||||
|
|
||||||
|
private[engine] def pieceNotation(pieceType: PieceType): String =
|
||||||
|
pieceType match
|
||||||
|
case PieceType.Knight => "N"
|
||||||
|
case PieceType.Bishop => "B"
|
||||||
|
case PieceType.Rook => "R"
|
||||||
|
case PieceType.Queen => "Q"
|
||||||
|
case PieceType.King => "K"
|
||||||
|
case _ => ""
|
||||||
|
|
||||||
|
private def computeCaptured(context: GameContext, move: Move): Option[Piece] =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.EnPassant =>
|
||||||
|
// Captured pawn is on the same rank as the moving pawn, same file as destination
|
||||||
|
val capturedSquare = Square(move.to.file, move.from.rank)
|
||||||
|
context.board.pieceAt(capturedSquare)
|
||||||
|
case MoveType.CastleKingside | MoveType.CastleQueenside =>
|
||||||
|
None
|
||||||
|
case _ =>
|
||||||
|
context.board.pieceAt(move.to)
|
||||||
|
|
||||||
private def performUndo(): Unit =
|
private def performUndo(): Unit =
|
||||||
if invoker.canUndo then
|
if invoker.canUndo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
moveCmd.previousContext.foreach(currentContext = _)
|
||||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
|
||||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
|
||||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
notifyObservers(MoveUndoneEvent(currentBoard, currentHistory, currentTurn, notation))
|
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||||
else
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
|
||||||
updateGameState(nb, nh, nt)
|
currentContext = nextCtx
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
|
||||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
notifyObservers(MoveRedoneEvent(currentBoard, currentHistory, currentTurn, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
|
notifyObservers(
|
||||||
else
|
MoveRedoneEvent(
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
currentContext,
|
||||||
|
moveCmd.notation,
|
||||||
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
|
moveCmd.from.toString,
|
||||||
currentBoard = newBoard
|
moveCmd.to.toString,
|
||||||
currentHistory = newHistory
|
capturedDesc,
|
||||||
currentTurn = newTurn
|
),
|
||||||
|
)
|
||||||
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||||
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
|
||||||
notifyObservers(MoveExecutedEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
newTurn,
|
|
||||||
fromSq,
|
|
||||||
toSq,
|
|
||||||
capturedDesc
|
|
||||||
))
|
|
||||||
|
|
||||||
private def handleFailedMove(moveInput: String): Unit =
|
|
||||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
|
||||||
case MoveResult.NoPiece =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"No piece on that square."
|
|
||||||
))
|
|
||||||
case MoveResult.WrongColor =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"That is not your piece."
|
|
||||||
))
|
|
||||||
case MoveResult.IllegalMove =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentBoard,
|
|
||||||
currentHistory,
|
|
||||||
currentTurn,
|
|
||||||
"Illegal move."
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
|
|
||||||
enum CastleSide:
|
|
||||||
case Kingside, Queenside
|
|
||||||
|
|
||||||
extension (b: Board)
|
|
||||||
def withCastle(color: Color, side: CastleSide): Board =
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
val kingFrom = Square(File.E, rank)
|
|
||||||
val (kingTo, rookFrom, rookTo) = side match
|
|
||||||
case CastleSide.Kingside =>
|
|
||||||
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
|
||||||
case CastleSide.Queenside =>
|
|
||||||
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
|
||||||
|
|
||||||
val king = b.pieceAt(kingFrom).get
|
|
||||||
val rook = b.pieceAt(rookFrom).get
|
|
||||||
|
|
||||||
b.removed(kingFrom).removed(rookFrom)
|
|
||||||
.updated(kingTo, king)
|
|
||||||
.updated(rookTo, rook)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square}
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
|
|
||||||
/** Derives castling rights from move history. */
|
|
||||||
object CastlingRightsCalculator:
|
|
||||||
|
|
||||||
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
|
|
||||||
val (kingRow, kingsideRookFile, queensideRookFile) = color match
|
|
||||||
case Color.White => (Rank.R1, File.H, File.A)
|
|
||||||
case Color.Black => (Rank.R8, File.H, File.A)
|
|
||||||
|
|
||||||
// Check if king has moved
|
|
||||||
val kingHasMoved = history.moves.exists: move =>
|
|
||||||
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
|
|
||||||
|
|
||||||
if kingHasMoved then
|
|
||||||
CastlingRights.None
|
|
||||||
else
|
|
||||||
// Check if kingside rook has moved or was captured
|
|
||||||
val kingsideLost = history.moves.exists: move =>
|
|
||||||
move.from == Square(kingsideRookFile, kingRow) ||
|
|
||||||
move.to == Square(kingsideRookFile, kingRow)
|
|
||||||
|
|
||||||
// Check if queenside rook has moved or was captured
|
|
||||||
val queensideLost = history.moves.exists: move =>
|
|
||||||
move.from == Square(queensideRookFile, kingRow) ||
|
|
||||||
move.to == Square(queensideRookFile, kingRow)
|
|
||||||
|
|
||||||
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
|
|
||||||
object EnPassantCalculator:
|
|
||||||
|
|
||||||
/** Returns the en passant target square if the last move was a double pawn push.
|
|
||||||
* The target is the square the pawn passed through (e.g. e2→e4 yields e3).
|
|
||||||
*/
|
|
||||||
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
|
|
||||||
history.moves.lastOption.flatMap: move =>
|
|
||||||
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
|
|
||||||
val isDoublePush = math.abs(rankDiff) == 2
|
|
||||||
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
|
|
||||||
if isDoublePush && isPawn then
|
|
||||||
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
|
|
||||||
Some(Square(move.to.file, Rank.values(midRankIdx)))
|
|
||||||
else None
|
|
||||||
|
|
||||||
/** True if moving from→to is an en passant capture. */
|
|
||||||
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
|
||||||
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
|
|
||||||
enPassantTarget(board, history).contains(to) &&
|
|
||||||
math.abs(to.file.ordinal - from.file.ordinal) == 1
|
|
||||||
|
|
||||||
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
|
|
||||||
* White captures upward → captured pawn is one rank below `to`.
|
|
||||||
* Black captures downward → captured pawn is one rank above `to`.
|
|
||||||
*/
|
|
||||||
def capturedPawnSquare(to: Square, color: Color): Square =
|
|
||||||
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
|
|
||||||
Square(to.file, Rank.values(capturedRankIdx))
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{PieceType, Square}
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
|
||||||
case class HistoryMove(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
castleSide: Option[CastleSide],
|
|
||||||
promotionPiece: Option[PromotionPiece] = None,
|
|
||||||
pieceType: PieceType = PieceType.Pawn,
|
|
||||||
isCapture: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
|
||||||
*
|
|
||||||
* @param moves moves played so far, oldest first
|
|
||||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
|
||||||
*/
|
|
||||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
|
||||||
|
|
||||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
|
||||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
|
||||||
*/
|
|
||||||
def addMove(move: HistoryMove): GameHistory =
|
|
||||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
|
||||||
|
|
||||||
/** Add a move by coordinates.
|
|
||||||
*
|
|
||||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
|
||||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
|
||||||
*
|
|
||||||
* If neither flag is set the clock increments by 1.
|
|
||||||
*/
|
|
||||||
def addMove(
|
|
||||||
from: Square,
|
|
||||||
to: Square,
|
|
||||||
castleSide: Option[CastleSide] = None,
|
|
||||||
promotionPiece: Option[PromotionPiece] = None,
|
|
||||||
wasPawnMove: Boolean = false,
|
|
||||||
wasCapture: Boolean = false,
|
|
||||||
pieceType: PieceType = PieceType.Pawn
|
|
||||||
): GameHistory =
|
|
||||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
|
||||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece, pieceType, wasCapture), newClock)
|
|
||||||
|
|
||||||
object GameHistory:
|
|
||||||
val empty: GameHistory = GameHistory()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
|
|
||||||
enum PositionStatus:
|
|
||||||
case Normal, InCheck, Mated, Drawn
|
|
||||||
|
|
||||||
object GameRules:
|
|
||||||
|
|
||||||
/** True if `color`'s king is under attack on this board. */
|
|
||||||
def isInCheck(board: Board, color: Color): Boolean =
|
|
||||||
board.pieces
|
|
||||||
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
|
|
||||||
.exists { kingSq =>
|
|
||||||
board.pieces.exists { case (sq, piece) =>
|
|
||||||
piece.color != color &&
|
|
||||||
MoveValidator.legalTargets(board, sq).contains(kingSq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
|
||||||
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
|
|
||||||
board.pieces
|
|
||||||
.collect { case (from, piece) if piece.color == color => from }
|
|
||||||
.flatMap { from =>
|
|
||||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
|
||||||
.filter { to =>
|
|
||||||
val newBoard =
|
|
||||||
if MoveValidator.isCastle(board, from, to) then
|
|
||||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
|
||||||
else
|
|
||||||
board.withMove(from, to)._1
|
|
||||||
!isInCheck(newBoard, color)
|
|
||||||
}
|
|
||||||
.map(to => from -> to)
|
|
||||||
}
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
/** Position status for the side whose turn it is (`color`). */
|
|
||||||
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
|
|
||||||
val moves = legalMoves(board, history, color)
|
|
||||||
val inCheck = isInCheck(board, color)
|
|
||||||
if moves.isEmpty && inCheck then PositionStatus.Mated
|
|
||||||
else if moves.isEmpty then PositionStatus.Drawn
|
|
||||||
else if inCheck then PositionStatus.InCheck
|
|
||||||
else PositionStatus.Normal
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
|
|
||||||
object MoveValidator:
|
|
||||||
|
|
||||||
/** Returns true if the move is geometrically legal for the piece on `from`,
|
|
||||||
* ignoring check/pin but respecting:
|
|
||||||
* - correct movement pattern for the piece type
|
|
||||||
* - cannot capture own pieces
|
|
||||||
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
|
|
||||||
*/
|
|
||||||
def isLegal(board: Board, from: Square, to: Square): Boolean =
|
|
||||||
legalTargets(board, from).contains(to)
|
|
||||||
|
|
||||||
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
|
|
||||||
def legalTargets(board: Board, from: Square): Set[Square] =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case None => Set.empty
|
|
||||||
case Some(piece) =>
|
|
||||||
piece.pieceType match
|
|
||||||
case PieceType.Pawn => pawnTargets(board, from, piece.color)
|
|
||||||
case PieceType.Knight => knightTargets(board, from, piece.color)
|
|
||||||
case PieceType.Bishop => slide(board, from, piece.color, diagonalDeltas)
|
|
||||||
case PieceType.Rook => slide(board, from, piece.color, orthogonalDeltas)
|
|
||||||
case PieceType.Queen => slide(board, from, piece.color, diagonalDeltas ++ orthogonalDeltas)
|
|
||||||
case PieceType.King => kingTargets(board, from, piece.color)
|
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val diagonalDeltas: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
|
||||||
private val orthogonalDeltas: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
||||||
private val knightDeltas: List[(Int, Int)] =
|
|
||||||
List((1, 2), (1, -2), (-1, 2), (-1, -2), (2, 1), (2, -1), (-2, 1), (-2, -1))
|
|
||||||
|
|
||||||
/** Try to construct a Square from integer file/rank indices (0-based). */
|
|
||||||
private def squareAt(fileIdx: Int, rankIdx: Int): Option[Square] =
|
|
||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
|
||||||
)
|
|
||||||
|
|
||||||
/** True when `sq` is occupied by a piece of `color`. */
|
|
||||||
private def isOwnPiece(board: Board, sq: Square, color: Color): Boolean =
|
|
||||||
board.pieceAt(sq).exists(_.color == color)
|
|
||||||
|
|
||||||
/** True when `sq` is occupied by a piece of the opposite color. */
|
|
||||||
private def isEnemyPiece(board: Board, sq: Square, color: Color): Boolean =
|
|
||||||
board.pieceAt(sq).exists(_.color != color)
|
|
||||||
|
|
||||||
/** Sliding move generation along a list of direction deltas.
|
|
||||||
* Each direction continues until the board edge, an own piece, or the first
|
|
||||||
* enemy piece (which is included as a capture target).
|
|
||||||
*/
|
|
||||||
private def slide(board: Board, from: Square, color: Color, deltas: List[(Int, Int)]): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
deltas.flatMap: (df, dr) =>
|
|
||||||
Iterator
|
|
||||||
.iterate((fi + df, ri + dr)) { case (f, r) => (f + df, r + dr) }
|
|
||||||
.takeWhile { case (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 }
|
|
||||||
.map { case (f, r) => Square(File.values(f), Rank.values(r)) }
|
|
||||||
.foldLeft((List.empty[Square], false)):
|
|
||||||
case ((acc, stopped), sq) =>
|
|
||||||
if stopped then (acc, true)
|
|
||||||
else if isOwnPiece(board, sq, color) then (acc, true) // blocked — stop, no capture
|
|
||||||
else if isEnemyPiece(board, sq, color) then (acc :+ sq, true) // capture — stop after
|
|
||||||
else (acc :+ sq, false) // empty — continue
|
|
||||||
._1
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
val dir = if color == Color.White then 1 else -1
|
|
||||||
val startRank = if color == Color.White then Rank.R2.ordinal else Rank.R7.ordinal
|
|
||||||
|
|
||||||
val oneStep = squareAt(fi, ri + dir)
|
|
||||||
|
|
||||||
// Forward one square (only if empty)
|
|
||||||
val forward1: Set[Square] = oneStep match
|
|
||||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
|
||||||
case _ => Set.empty
|
|
||||||
|
|
||||||
// Forward two squares from starting rank (only if both intermediate squares are empty)
|
|
||||||
val forward2: Set[Square] =
|
|
||||||
if ri == startRank && forward1.nonEmpty then
|
|
||||||
squareAt(fi, ri + 2 * dir) match
|
|
||||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
|
||||||
case _ => Set.empty
|
|
||||||
else Set.empty
|
|
||||||
|
|
||||||
// Diagonal captures (only if enemy piece present)
|
|
||||||
val captures: Set[Square] =
|
|
||||||
List(-1, 1).flatMap: df =>
|
|
||||||
squareAt(fi + df, ri + dir).filter(sq => isEnemyPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
forward1 ++ forward2 ++ captures
|
|
||||||
|
|
||||||
private def knightTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
knightDeltas.flatMap: (df, dr) =>
|
|
||||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
private def kingTargets(board: Board, from: Square, color: Color): Set[Square] =
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
|
||||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
|
||||||
.toSet
|
|
||||||
|
|
||||||
// ── Castling helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
|
|
||||||
board.pieces.exists { case (from, piece) =>
|
|
||||||
piece.color == attackerColor && legalTargets(board, from).contains(sq)
|
|
||||||
}
|
|
||||||
|
|
||||||
def isCastle(board: Board, from: Square, to: Square): Boolean =
|
|
||||||
board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
|
|
||||||
math.abs(to.file.ordinal - from.file.ordinal) == 2
|
|
||||||
|
|
||||||
def castleSide(from: Square, to: Square): CastleSide =
|
|
||||||
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
|
|
||||||
|
|
||||||
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
val kingSq = Square(File.E, rank)
|
|
||||||
val enemy = color.opposite
|
|
||||||
|
|
||||||
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
|
|
||||||
GameRules.isInCheck(board, color) then Set.empty
|
|
||||||
else
|
|
||||||
val kingsideSq = Option.when(
|
|
||||||
rights.kingSide &&
|
|
||||||
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
|
|
||||||
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
|
||||||
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
|
|
||||||
)(Square(File.G, rank))
|
|
||||||
|
|
||||||
val queensideSq = Option.when(
|
|
||||||
rights.queenSide &&
|
|
||||||
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
|
|
||||||
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
|
||||||
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
|
|
||||||
)(Square(File.C, rank))
|
|
||||||
|
|
||||||
kingsideSq.toSet ++ queensideSq.toSet
|
|
||||||
|
|
||||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
|
||||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
|
||||||
case Some(piece) if piece.pieceType == PieceType.Pawn =>
|
|
||||||
pawnTargets(board, history, from, piece.color)
|
|
||||||
case _ =>
|
|
||||||
legalTargets(board, from)
|
|
||||||
|
|
||||||
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
|
|
||||||
val existing = pawnTargets(board, from, color)
|
|
||||||
val fi = from.file.ordinal
|
|
||||||
val ri = from.rank.ordinal
|
|
||||||
val dir = if color == Color.White then 1 else -1
|
|
||||||
val epCapture: Set[Square] =
|
|
||||||
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
|
|
||||||
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
|
|
||||||
.toSet
|
|
||||||
existing ++ epCapture
|
|
||||||
|
|
||||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
|
||||||
legalTargets(board, history, from).contains(to)
|
|
||||||
|
|
||||||
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
|
|
||||||
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
|
||||||
board.pieceAt(from) match
|
|
||||||
case Some(Piece(_, PieceType.Pawn)) =>
|
|
||||||
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
|
|
||||||
(from.rank == Rank.R2 && to.rank == Rank.R1)
|
|
||||||
case _ => false
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.{CastlingRights, GameState}
|
|
||||||
import de.nowchess.api.board.Color
|
|
||||||
|
|
||||||
object FenExporter:
|
|
||||||
|
|
||||||
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
|
|
||||||
def boardToFen(board: Board): String =
|
|
||||||
Rank.values.reverse
|
|
||||||
.map(rank => buildRankString(board, rank))
|
|
||||||
.mkString("/")
|
|
||||||
|
|
||||||
/** Build the FEN representation for a single rank. */
|
|
||||||
private def buildRankString(board: Board, rank: Rank): String =
|
|
||||||
val rankSquares = File.values.map(file => Square(file, rank))
|
|
||||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
|
||||||
var emptyCount = 0
|
|
||||||
|
|
||||||
for square <- rankSquares do
|
|
||||||
board.pieceAt(square) match
|
|
||||||
case Some(piece) =>
|
|
||||||
if emptyCount > 0 then
|
|
||||||
rankChars += emptyCount.toString.charAt(0)
|
|
||||||
emptyCount = 0
|
|
||||||
rankChars += pieceToPgnChar(piece)
|
|
||||||
case None =>
|
|
||||||
emptyCount += 1
|
|
||||||
|
|
||||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
|
||||||
rankChars.mkString
|
|
||||||
|
|
||||||
/** Convert a GameState to a complete FEN string. */
|
|
||||||
def gameStateToFen(state: GameState): String =
|
|
||||||
val piecePlacement = state.piecePlacement
|
|
||||||
val activeColor = if state.activeColor == Color.White then "w" else "b"
|
|
||||||
val castling = castlingString(state.castlingWhite, state.castlingBlack)
|
|
||||||
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
|
|
||||||
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
|
|
||||||
|
|
||||||
/** Convert castling rights to FEN notation. */
|
|
||||||
private def castlingString(white: CastlingRights, black: CastlingRights): String =
|
|
||||||
val wk = if white.kingSide then "K" else ""
|
|
||||||
val wq = if white.queenSide then "Q" else ""
|
|
||||||
val bk = if black.kingSide then "k" else ""
|
|
||||||
val bq = if black.queenSide then "q" else ""
|
|
||||||
val result = s"$wk$wq$bk$bq"
|
|
||||||
if result.isEmpty then "-" else result
|
|
||||||
|
|
||||||
/** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */
|
|
||||||
private def pieceToPgnChar(piece: Piece): Char =
|
|
||||||
val base = piece.pieceType match
|
|
||||||
case PieceType.Pawn => 'p'
|
|
||||||
case PieceType.Knight => 'n'
|
|
||||||
case PieceType.Bishop => 'b'
|
|
||||||
case PieceType.Rook => 'r'
|
|
||||||
case PieceType.Queen => 'q'
|
|
||||||
case PieceType.King => 'k'
|
|
||||||
if piece.color == Color.White then base.toUpper else base
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
|
|
||||||
|
|
||||||
object FenParser:
|
|
||||||
|
|
||||||
/** Parse a complete FEN string into a GameState.
|
|
||||||
* Returns None if the format is invalid. */
|
|
||||||
def parseFen(fen: String): Option[GameState] =
|
|
||||||
val parts = fen.trim.split("\\s+")
|
|
||||||
Option.when(parts.length == 6)(parts).flatMap: parts =>
|
|
||||||
for
|
|
||||||
_ <- parseBoard(parts(0))
|
|
||||||
activeColor <- parseColor(parts(1))
|
|
||||||
castlingRights <- parseCastling(parts(2))
|
|
||||||
enPassant <- parseEnPassant(parts(3))
|
|
||||||
halfMoveClock <- parts(4).toIntOption
|
|
||||||
fullMoveNumber <- parts(5).toIntOption
|
|
||||||
if halfMoveClock >= 0 && fullMoveNumber >= 1
|
|
||||||
yield GameState(
|
|
||||||
piecePlacement = parts(0),
|
|
||||||
activeColor = activeColor,
|
|
||||||
castlingWhite = castlingRights._1,
|
|
||||||
castlingBlack = castlingRights._2,
|
|
||||||
enPassantTarget = enPassant,
|
|
||||||
halfMoveClock = halfMoveClock,
|
|
||||||
fullMoveNumber = fullMoveNumber,
|
|
||||||
status = GameStatus.InProgress
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Parse active color ("w" or "b"). */
|
|
||||||
private def parseColor(s: String): Option[Color] =
|
|
||||||
if s == "w" then Some(Color.White)
|
|
||||||
else if s == "b" then Some(Color.Black)
|
|
||||||
else None
|
|
||||||
|
|
||||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
|
|
||||||
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
|
|
||||||
if s == "-" then
|
|
||||||
Some((CastlingRights.None, CastlingRights.None))
|
|
||||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
|
||||||
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
|
|
||||||
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
|
|
||||||
Some((white, black))
|
|
||||||
else
|
|
||||||
None
|
|
||||||
|
|
||||||
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
|
||||||
private def parseEnPassant(s: String): Option[Option[Square]] =
|
|
||||||
if s == "-" then Some(None)
|
|
||||||
else Square.fromAlgebraic(s).map(Some(_))
|
|
||||||
|
|
||||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
|
|
||||||
* Returns None if the format is invalid. */
|
|
||||||
def parseBoard(fen: String): Option[Board] =
|
|
||||||
val rankStrings = fen.split("/", -1)
|
|
||||||
if rankStrings.length != 8 then None
|
|
||||||
else
|
|
||||||
// Parse each rank, collecting all (Square, Piece) pairs or failing on the first error
|
|
||||||
val parsedRanks: Option[List[List[(Square, Piece)]]] =
|
|
||||||
rankStrings.zipWithIndex.foldLeft(Option(List.empty[List[(Square, Piece)]])):
|
|
||||||
case (None, _) => None
|
|
||||||
case (Some(acc), (rankStr, rankIdx)) =>
|
|
||||||
val rank = Rank.values(7 - rankIdx) // ranks go 8→1, so reverse
|
|
||||||
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
|
||||||
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
|
||||||
|
|
||||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
|
|
||||||
* Returns None if the rank string contains invalid characters or the wrong number of files. */
|
|
||||||
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
|
||||||
var fileIdx = 0
|
|
||||||
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
|
|
||||||
var failed = false
|
|
||||||
|
|
||||||
for c <- rankStr if !failed do
|
|
||||||
if fileIdx > 7 then
|
|
||||||
failed = true
|
|
||||||
else if c.isDigit then
|
|
||||||
fileIdx += c.asDigit
|
|
||||||
else
|
|
||||||
charToPiece(c) match
|
|
||||||
case None => failed = true
|
|
||||||
case Some(piece) =>
|
|
||||||
val file = File.values(fileIdx)
|
|
||||||
squares += (Square(file, rank) -> piece)
|
|
||||||
fileIdx += 1
|
|
||||||
|
|
||||||
if failed || fileIdx != 8 then None
|
|
||||||
else Some(squares.toList)
|
|
||||||
|
|
||||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
|
||||||
private def charToPiece(c: Char): Option[Piece] =
|
|
||||||
val color = if Character.isUpperCase(c) then Color.White else Color.Black
|
|
||||||
val pieceTypeOpt = c.toLower match
|
|
||||||
case 'p' => Some(PieceType.Pawn)
|
|
||||||
case 'n' => Some(PieceType.Knight)
|
|
||||||
case 'b' => Some(PieceType.Bishop)
|
|
||||||
case 'r' => Some(PieceType.Rook)
|
|
||||||
case 'q' => Some(PieceType.Queen)
|
|
||||||
case 'k' => Some(PieceType.King)
|
|
||||||
case _ => None
|
|
||||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{PieceType, *}
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
|
||||||
|
|
||||||
object PgnExporter:
|
|
||||||
|
|
||||||
/** Export a game with headers and history to PGN format. */
|
|
||||||
def exportGame(headers: Map[String, String], history: GameHistory): String =
|
|
||||||
val headerLines = headers.map { case (key, value) =>
|
|
||||||
s"""[$key "$value"]"""
|
|
||||||
}.mkString("\n")
|
|
||||||
|
|
||||||
val moveText = if history.moves.isEmpty then ""
|
|
||||||
else
|
|
||||||
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
|
|
||||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
|
||||||
val moveNum = moveNumber + 1
|
|
||||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
|
||||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
|
||||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
|
||||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
|
||||||
|
|
||||||
val termination = headers.getOrElse("Result", "*")
|
|
||||||
moveLines.mkString(" ") + s" $termination"
|
|
||||||
|
|
||||||
if headerLines.isEmpty then moveText
|
|
||||||
else if moveText.isEmpty then headerLines
|
|
||||||
else s"$headerLines\n\n$moveText"
|
|
||||||
|
|
||||||
/** Convert a HistoryMove to Standard Algebraic Notation. */
|
|
||||||
def moveToAlgebraic(move: HistoryMove): String =
|
|
||||||
move.castleSide match
|
|
||||||
case Some(CastleSide.Kingside) => "O-O"
|
|
||||||
case Some(CastleSide.Queenside) => "O-O-O"
|
|
||||||
case None =>
|
|
||||||
val dest = move.to.toString
|
|
||||||
val capStr = if move.isCapture then "x" else ""
|
|
||||||
val promSuffix = move.promotionPiece match
|
|
||||||
case Some(PromotionPiece.Queen) => "=Q"
|
|
||||||
case Some(PromotionPiece.Rook) => "=R"
|
|
||||||
case Some(PromotionPiece.Bishop) => "=B"
|
|
||||||
case Some(PromotionPiece.Knight) => "=N"
|
|
||||||
case None => ""
|
|
||||||
move.pieceType match
|
|
||||||
case PieceType.Pawn =>
|
|
||||||
if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix"
|
|
||||||
else s"$dest$promSuffix"
|
|
||||||
case PieceType.Knight => s"N$capStr$dest$promSuffix"
|
|
||||||
case PieceType.Bishop => s"B$capStr$dest$promSuffix"
|
|
||||||
case PieceType.Rook => s"R$capStr$dest$promSuffix"
|
|
||||||
case PieceType.Queen => s"Q$capStr$dest$promSuffix"
|
|
||||||
case PieceType.King => s"K$capStr$dest$promSuffix"
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
package de.nowchess.chess.notation
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
|
||||||
|
|
||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
|
||||||
case class PgnGame(
|
|
||||||
headers: Map[String, String],
|
|
||||||
moves: List[HistoryMove]
|
|
||||||
)
|
|
||||||
|
|
||||||
object PgnParser:
|
|
||||||
|
|
||||||
/** Strictly validate a PGN text.
|
|
||||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
|
||||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
|
||||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
|
||||||
val headers = parseHeaders(headerLines)
|
|
||||||
val moveText = rest.mkString(" ")
|
|
||||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
|
||||||
|
|
||||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
|
||||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
|
||||||
def parsePgn(pgn: String): Option[PgnGame] =
|
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
|
||||||
|
|
||||||
val headers = parseHeaders(headerLines)
|
|
||||||
val moveText = rest.mkString(" ")
|
|
||||||
val moves = parseMovesText(moveText)
|
|
||||||
|
|
||||||
Some(PgnGame(headers, moves))
|
|
||||||
|
|
||||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
|
||||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
|
||||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
|
|
||||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
|
||||||
|
|
||||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
|
||||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
|
||||||
|
|
||||||
// Fold over tokens, threading (board, history, currentColor, accumulator)
|
|
||||||
val (_, _, _, moves) = tokens.foldLeft(
|
|
||||||
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
|
|
||||||
):
|
|
||||||
case (state @ (board, history, color, acc), token) =>
|
|
||||||
// Skip move-number markers (e.g. "1.", "2.") and result tokens
|
|
||||||
if isMoveNumberOrResult(token) then state
|
|
||||||
else
|
|
||||||
parseAlgebraicMove(token, board, history, color) match
|
|
||||||
case None => state // unrecognised token — skip silently
|
|
||||||
case Some(move) =>
|
|
||||||
val newBoard = applyMoveToBoard(board, move, color)
|
|
||||||
val newHistory = history.addMove(move)
|
|
||||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
|
||||||
|
|
||||||
moves
|
|
||||||
|
|
||||||
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
|
||||||
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
|
||||||
move.castleSide match
|
|
||||||
case Some(side) => board.withCastle(color, side)
|
|
||||||
case None =>
|
|
||||||
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
|
||||||
move.promotionPiece match
|
|
||||||
case Some(pp) =>
|
|
||||||
val pieceType = pp match
|
|
||||||
case PromotionPiece.Queen => PieceType.Queen
|
|
||||||
case PromotionPiece.Rook => PieceType.Rook
|
|
||||||
case PromotionPiece.Bishop => PieceType.Bishop
|
|
||||||
case PromotionPiece.Knight => PieceType.Knight
|
|
||||||
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
|
||||||
case None => boardAfterMove
|
|
||||||
|
|
||||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
|
||||||
token.matches("""\d+\.""") ||
|
|
||||||
token == "*" ||
|
|
||||||
token == "1-0" ||
|
|
||||||
token == "0-1" ||
|
|
||||||
token == "1/2-1/2"
|
|
||||||
|
|
||||||
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
|
|
||||||
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
notation match
|
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside), pieceType = PieceType.King))
|
|
||||||
|
|
||||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside), pieceType = PieceType.King))
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
parseRegularMove(notation, board, history, color)
|
|
||||||
|
|
||||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
|
||||||
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
|
|
||||||
val clean = notation
|
|
||||||
.replace("+", "")
|
|
||||||
.replace("#", "")
|
|
||||||
.replace("x", "")
|
|
||||||
.replaceAll("=[NBRQ]$", "")
|
|
||||||
|
|
||||||
// The destination square is always the last two characters
|
|
||||||
if clean.length < 2 then None
|
|
||||||
else
|
|
||||||
val destStr = clean.takeRight(2)
|
|
||||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
|
||||||
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
|
|
||||||
|
|
||||||
// Determine required piece type: upper-case first char = piece letter; else pawn
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
|
||||||
else Some(PieceType.Pawn)
|
|
||||||
|
|
||||||
// Collect the disambiguation hint that remains after stripping the piece letter
|
|
||||||
val hint =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
|
||||||
else disambig // hint is file/rank info or empty
|
|
||||||
|
|
||||||
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
|
|
||||||
// We prefer pieces that can actually reach the target; if none can (positionally illegal
|
|
||||||
// PGN input), fall back to any piece of the matching type belonging to `color`.
|
|
||||||
val reachable: Set[Square] =
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color &&
|
|
||||||
MoveValidator.legalTargets(board, from).contains(toSquare) => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
val candidates: Set[Square] =
|
|
||||||
if reachable.nonEmpty then reachable
|
|
||||||
else
|
|
||||||
// Fallback for positionally-illegal but syntactically valid PGN notation:
|
|
||||||
// find any piece of `color` with the correct piece type on the board.
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
// Filter by required piece type
|
|
||||||
val byPiece = candidates.filter(from =>
|
|
||||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Apply disambiguation hint (file letter or rank digit)
|
|
||||||
val disambiguated =
|
|
||||||
if hint.isEmpty then byPiece
|
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
|
||||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
|
||||||
val moveIsCapture = notation.contains('x')
|
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
|
||||||
|
|
||||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
|
||||||
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
|
||||||
else true)
|
|
||||||
|
|
||||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
|
||||||
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
|
||||||
val promotionPattern = """=([A-Z])""".r
|
|
||||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
|
||||||
m.group(1) match
|
|
||||||
case "Q" => Some(PromotionPiece.Queen)
|
|
||||||
case "R" => Some(PromotionPiece.Rook)
|
|
||||||
case "B" => Some(PromotionPiece.Bishop)
|
|
||||||
case "N" => Some(PromotionPiece.Knight)
|
|
||||||
case _ => None
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert a piece-letter character to a PieceType. */
|
|
||||||
private def charToPieceType(c: Char): Option[PieceType] =
|
|
||||||
c match
|
|
||||||
case 'N' => Some(PieceType.Knight)
|
|
||||||
case 'B' => Some(PieceType.Bishop)
|
|
||||||
case 'R' => Some(PieceType.Rook)
|
|
||||||
case 'Q' => Some(PieceType.Queen)
|
|
||||||
case 'K' => Some(PieceType.King)
|
|
||||||
case _ => None
|
|
||||||
|
|
||||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
|
||||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
|
||||||
tokens.foldLeft(Right((Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])): Either[String, (Board, GameHistory, Color, List[HistoryMove])]) {
|
|
||||||
case (acc, token) =>
|
|
||||||
acc.flatMap { case (board, history, color, moves) =>
|
|
||||||
if isMoveNumberOrResult(token) then Right((board, history, color, moves))
|
|
||||||
else
|
|
||||||
strictParseAlgebraicMove(token, board, history, color) match
|
|
||||||
case None => Left(s"Illegal or impossible move: '$token'")
|
|
||||||
case Some(move) =>
|
|
||||||
val newBoard = applyMoveToBoard(board, move, color)
|
|
||||||
val newHistory = history.addMove(move)
|
|
||||||
Right((newBoard, newHistory, color.opposite, moves :+ move))
|
|
||||||
}
|
|
||||||
}.map(_._4)
|
|
||||||
|
|
||||||
/** Strict algebraic move parse — no fallback to positionally-illegal moves. */
|
|
||||||
private def strictParseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
notation match
|
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
|
||||||
val dest = Square(File.G, rank)
|
|
||||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
|
||||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Kingside), pieceType = PieceType.King)
|
|
||||||
)
|
|
||||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
|
||||||
val dest = Square(File.C, rank)
|
|
||||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
|
||||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Queenside), pieceType = PieceType.King)
|
|
||||||
)
|
|
||||||
case _ =>
|
|
||||||
strictParseRegularMove(notation, board, history, color)
|
|
||||||
|
|
||||||
/** Strict regular move parse — uses only legally reachable squares, no fallback. */
|
|
||||||
private def strictParseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
val clean = notation
|
|
||||||
.replace("+", "")
|
|
||||||
.replace("#", "")
|
|
||||||
.replace("x", "")
|
|
||||||
.replaceAll("=[NBRQ]$", "")
|
|
||||||
|
|
||||||
if clean.length < 2 then None
|
|
||||||
else
|
|
||||||
val destStr = clean.takeRight(2)
|
|
||||||
Square.fromAlgebraic(destStr).flatMap { toSquare =>
|
|
||||||
val disambig = clean.dropRight(2)
|
|
||||||
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
|
||||||
else Some(PieceType.Pawn)
|
|
||||||
|
|
||||||
val hint =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
|
||||||
else disambig
|
|
||||||
|
|
||||||
// Strict: only squares from which a legal move (including en passant/castling awareness) exists.
|
|
||||||
val reachable: Set[Square] =
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color &&
|
|
||||||
MoveValidator.legalTargets(board, history, from).contains(toSquare) => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
val byPiece = reachable.filter(from =>
|
|
||||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
|
||||||
)
|
|
||||||
|
|
||||||
val disambiguated =
|
|
||||||
if hint.isEmpty then byPiece
|
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
|
||||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
|
||||||
val moveIsCapture = notation.contains('x')
|
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
|
||||||
}
|
|
||||||
@@ -1,110 +1,79 @@
|
|||||||
package de.nowchess.chess.observer
|
package de.nowchess.chess.observer
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Square}
|
import de.nowchess.api.board.{Color, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||||
* Events are immutable snapshots of game state changes.
|
|
||||||
*/
|
*/
|
||||||
sealed trait GameEvent:
|
sealed trait GameEvent:
|
||||||
def board: Board
|
def context: GameContext
|
||||||
def history: GameHistory
|
|
||||||
def turn: Color
|
|
||||||
|
|
||||||
/** Fired when a move is successfully executed. */
|
/** Fired when a move is successfully executed. */
|
||||||
case class MoveExecutedEvent(
|
case class MoveExecutedEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the current player is in check. */
|
/** Fired when the current player is in check. */
|
||||||
case class CheckDetectedEvent(
|
case class CheckDetectedEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches checkmate. */
|
/** Fired when the game reaches checkmate. */
|
||||||
case class CheckmateEvent(
|
case class CheckmateEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
winner: Color,
|
||||||
turn: Color,
|
|
||||||
winner: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches stalemate. */
|
/** Fired when the game ends in a draw. */
|
||||||
case class StalemateEvent(
|
case class DrawEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
reason: DrawReason,
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is invalid. */
|
/** Fired when a move is invalid. */
|
||||||
case class InvalidMoveEvent(
|
case class InvalidMoveEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
reason: String,
|
||||||
turn: Color,
|
|
||||||
reason: String
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||||
case class PromotionRequiredEvent(
|
case class PromotionRequiredEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square
|
to: Square,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the board is reset. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||||
case class FiftyMoveRuleAvailableEvent(
|
case class FiftyMoveRuleAvailableEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
|
||||||
|
|
||||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
|
||||||
case class DrawClaimedEvent(
|
|
||||||
board: Board,
|
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||||
case class MoveUndoneEvent(
|
case class MoveUndoneEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
pgnNotation: String,
|
||||||
turn: Color,
|
|
||||||
pgnNotation: String
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||||
case class MoveRedoneEvent(
|
case class MoveRedoneEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color,
|
|
||||||
pgnNotation: String,
|
pgnNotation: String,
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||||
case class PgnLoadedEvent(
|
case class PgnLoadedEvent(
|
||||||
board: Board,
|
context: GameContext,
|
||||||
history: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Observer trait: implement to receive game state updates. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package de.nowchess.chess.view
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
|
||||||
|
|
||||||
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"
|
|
||||||
+49
-112
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -9,208 +9,145 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
// ──── Helper: Command that always fails ────
|
|
||||||
private case class FailingCommand() extends Command:
|
private case class FailingCommand() extends Command:
|
||||||
override def execute(): Boolean = false
|
override def execute(): Boolean = false
|
||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Failing command"
|
override def description: String = "Failing command"
|
||||||
|
|
||||||
// ──── Helper: Command that conditionally fails on undo or execute ────
|
private class ConditionalFailCommand(
|
||||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
initialShouldFailOnUndo: Boolean = false,
|
||||||
override def execute(): Boolean = !shouldFailOnExecute
|
initialShouldFailOnExecute: Boolean = false,
|
||||||
override def undo(): Boolean = !shouldFailOnUndo
|
) extends Command:
|
||||||
|
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
|
||||||
|
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
|
||||||
|
override def execute(): Boolean = !shouldFailOnExecute.get()
|
||||||
|
override def undo(): Boolean = !shouldFailOnUndo.get()
|
||||||
override def description: String = "Conditional fail"
|
override def description: String = "Conditional fail"
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||||
val cmd = MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
|
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial),
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
cmd
|
|
||||||
|
|
||||||
// ──── BRANCH: execute() returns false ────
|
test("execute rejects failing commands and keeps history unchanged"):
|
||||||
test("CommandInvoker.execute() with failing command returns false"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = FailingCommand()
|
val cmd = FailingCommand()
|
||||||
invoker.execute(cmd) shouldBe false
|
invoker.execute(cmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker.execute() does not add failed command to history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val failingCmd = FailingCommand()
|
val failingCmd = FailingCommand()
|
||||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(failingCmd) shouldBe false
|
invoker.execute(failingCmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
invoker.execute(successCmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.history(0) shouldBe successCmd
|
invoker.history.head shouldBe successCmd
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
|
test("undo redo and history trimming cover all command state transitions"):
|
||||||
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
// currentIndex starts at -1
|
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns false when empty history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
invoker.canUndo shouldBe false
|
invoker.canUndo shouldBe false
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
|
{
|
||||||
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex now = 1, history.size = 2
|
invoker.undo()
|
||||||
|
invoker.undo()
|
||||||
invoker.undo() // currentIndex becomes 0
|
|
||||||
invoker.undo() // currentIndex becomes -1
|
|
||||||
invoker.undo() // currentIndex still -1, should fail
|
|
||||||
|
|
||||||
// ──── BRANCH: undo() command returns false ────
|
|
||||||
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
|
||||||
|
|
||||||
invoker.execute(failingCmd) shouldBe true
|
|
||||||
invoker.canUndo shouldBe true
|
|
||||||
|
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
// Index should not change when undo fails
|
}
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
|
||||||
|
invoker.execute(failingUndoCmd) shouldBe true
|
||||||
|
invoker.canUndo shouldBe true
|
||||||
|
invoker.undo() shouldBe false
|
||||||
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
{
|
||||||
|
val invoker = new CommandInvoker()
|
||||||
|
val successUndoCmd = ConditionalFailCommand()
|
||||||
|
invoker.execute(successUndoCmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
}
|
||||||
|
|
||||||
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
|
{
|
||||||
test("CommandInvoker.redo() returns false when nothing to redo"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns false when at end of history"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
// currentIndex = 0, history.size = 1
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val redoFailCmd = ConditionalFailCommand()
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(redoFailCmd)
|
||||||
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
|
|
||||||
// ──── BRANCH: redo() command returns false ────
|
|
||||||
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
|
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 0, redoFailCmd is at index 1
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
redoFailCmd.shouldFailOnExecute.set(true)
|
||||||
// Now modify to fail on next execute (redo)
|
|
||||||
redoFailCmd.shouldFailOnExecute = true
|
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
// currentIndex should not change
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
// ──── BRANCH: execute() with redo history discarding (while loop) ────
|
{
|
||||||
test("CommandInvoker.execute() discards redo history via while loop"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 0, size = 2
|
|
||||||
// Redo history exists: cmd2 is at index 1
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
// while loop should discard cmd2
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
|
}
|
||||||
|
|
||||||
test("CommandInvoker.execute() discards multiple redo commands"):
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.execute(cmd4)
|
invoker.execute(cmd4)
|
||||||
// currentIndex = 3, size = 4
|
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 1, size = 4
|
|
||||||
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||||
invoker.execute(newCmd)
|
invoker.execute(newCmd)
|
||||||
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
|
|
||||||
invoker.history.size shouldBe 3
|
invoker.history.size shouldBe 3
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
|
}
|
||||||
// ──── BRANCH: execute() with no redo history to discard ────
|
|
||||||
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
|
|
||||||
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
|
||||||
invoker.history.size shouldBe 3
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -13,69 +13,35 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial),
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
test("CommandInvoker executes a command and adds it to history"):
|
test("execute appends commands and updates index"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker executes multiple commands in sequence"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
invoker.execute(cmd1) shouldBe true
|
|
||||||
invoker.execute(cmd2) shouldBe true
|
invoker.execute(cmd2) shouldBe true
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns false when empty"):
|
test("undo and redo update index and availability flags"):
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns true after execution"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
invoker.canUndo shouldBe false
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.undo decrements current index"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker.canRedo returns true after undo"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.redo re-executes a command"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
test("clear removes full history and resets index"):
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker clear removes all history"):
|
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
@@ -83,7 +49,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
test("CommandInvoker discards all history when executing after undoing all"):
|
test("execute after undo discards redo history"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
@@ -91,33 +57,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.undo()
|
|
||||||
// After undoing twice, we're at the beginning (before any commands)
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
// Executing a new command from the beginning discards all redo history
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.history.size shouldBe 1
|
|
||||||
invoker.history(0) shouldBe cmd3
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("CommandInvoker discards redo history when executing mid-history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.undo()
|
|
||||||
// After one undo, we're at the end of cmd1
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
// Executing a new command discards cmd2 (the redo history)
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.history(0) shouldBe cmd1
|
invoker.history.head shouldBe cmd1
|
||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
|
|||||||
-131
@@ -1,131 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import scala.collection.mutable
|
|
||||||
|
|
||||||
class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
|
||||||
MoveCommand(
|
|
||||||
from = from,
|
|
||||||
to = to,
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
|
|
||||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
@volatile var raceDetected = false
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
|
|
||||||
// Thread 1: executes commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for i <- 1 to 1000 do
|
|
||||||
val cmd = createMoveCommand(
|
|
||||||
sq(File.E, Rank.R2),
|
|
||||||
sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
invoker.execute(cmd)
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: reads history during execution
|
|
||||||
val readerThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 1000 do
|
|
||||||
val _ = invoker.history
|
|
||||||
val _ = invoker.getCurrentIndex
|
|
||||||
Thread.sleep(0) // Yield to increase contention
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
executorThread.start()
|
|
||||||
readerThread.start()
|
|
||||||
executorThread.join()
|
|
||||||
readerThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
raceDetected shouldBe false
|
|
||||||
|
|
||||||
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
@volatile var raceDetected = false
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
|
|
||||||
// Pre-populate with some commands
|
|
||||||
for _ <- 1 to 5 do
|
|
||||||
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
|
||||||
|
|
||||||
// Thread 1: executes new commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: undoes commands
|
|
||||||
val undoThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
if invoker.canUndo then
|
|
||||||
invoker.undo()
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 3: redoes commands
|
|
||||||
val redoThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
if invoker.canRedo then
|
|
||||||
invoker.redo()
|
|
||||||
} catch {
|
|
||||||
case e: Exception =>
|
|
||||||
exceptions += e
|
|
||||||
raceDetected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
executorThread.start()
|
|
||||||
undoThread.start()
|
|
||||||
redoThread.start()
|
|
||||||
executorThread.join()
|
|
||||||
undoThread.join()
|
|
||||||
redoThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
raceDetected shouldBe false
|
|
||||||
@@ -1,52 +1,23 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandTest extends AnyFunSuite with Matchers:
|
class CommandTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("QuitCommand can be created"):
|
test("QuitCommand properties and behavior"):
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd shouldNot be(null)
|
|
||||||
|
|
||||||
test("QuitCommand execute returns true"):
|
|
||||||
val cmd = QuitCommand()
|
val cmd = QuitCommand()
|
||||||
cmd.execute() shouldBe true
|
cmd.execute() shouldBe true
|
||||||
|
|
||||||
test("QuitCommand undo returns false (cannot undo quit)"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.undo() shouldBe false
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
test("QuitCommand description"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.description shouldBe "Quit game"
|
cmd.description shouldBe "Quit game"
|
||||||
|
|
||||||
test("ResetCommand with no prior state"):
|
test("ResetCommand behavior depends on previousContext"):
|
||||||
val cmd = ResetCommand()
|
val noState = ResetCommand()
|
||||||
cmd.execute() shouldBe true
|
noState.execute() shouldBe true
|
||||||
cmd.undo() shouldBe false
|
noState.undo() shouldBe false
|
||||||
|
noState.description shouldBe "Reset board"
|
||||||
test("ResetCommand with prior state can undo"):
|
|
||||||
val cmd = ResetCommand(
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
cmd.undo() shouldBe true
|
|
||||||
|
|
||||||
test("ResetCommand with partial state cannot undo"):
|
|
||||||
val cmd = ResetCommand(
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = None, // missing
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("ResetCommand description"):
|
|
||||||
val cmd = ResetCommand()
|
|
||||||
cmd.description shouldBe "Reset board"
|
|
||||||
|
|
||||||
|
val withState = ResetCommand(previousContext = Some(GameContext.initial))
|
||||||
|
withState.execute() shouldBe true
|
||||||
|
withState.undo() shouldBe true
|
||||||
|
|||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create second command with filled state
|
|
||||||
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
|
|
||||||
val cmd2 = cmd1.copy(
|
|
||||||
moveResult = Some(result),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Original should be unchanged
|
|
||||||
cmd1.moveResult shouldBe None
|
|
||||||
cmd1.previousBoard shouldBe None
|
|
||||||
cmd1.previousHistory shouldBe None
|
|
||||||
cmd1.previousTurn shouldBe None
|
|
||||||
|
|
||||||
// New should have values
|
|
||||||
cmd2.moveResult shouldBe Some(result)
|
|
||||||
cmd2.previousBoard shouldBe Some(Board.initial)
|
|
||||||
cmd2.previousHistory shouldBe Some(GameHistory.empty)
|
|
||||||
cmd2.previousTurn shouldBe Some(Color.White)
|
|
||||||
|
|
||||||
test("MoveCommand equals and hashCode respect immutability"):
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
|
|
||||||
val cmd2 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
|
|
||||||
// Same values should be equal
|
|
||||||
cmd1 shouldBe cmd2
|
|
||||||
cmd1.hashCode shouldBe cmd2.hashCode
|
|
||||||
|
|
||||||
// Hash should be consistent (required for use as map keys)
|
|
||||||
val hash1 = cmd1.hashCode
|
|
||||||
val hash2 = cmd1.hashCode
|
|
||||||
hash1 shouldBe hash2
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
|
test("MoveCommand defaults to empty optional state and false execute/undo"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
cmd.moveResult shouldBe None
|
||||||
|
cmd.previousContext shouldBe None
|
||||||
|
cmd.execute() shouldBe false
|
||||||
|
cmd.undo() shouldBe false
|
||||||
|
cmd.description shouldBe "Move from e2 to e4"
|
||||||
|
|
||||||
|
test("MoveCommand execute/undo succeed when state is present"):
|
||||||
|
val executable = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
|
)
|
||||||
|
executable.execute() shouldBe true
|
||||||
|
|
||||||
|
val undoable = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
|
previousContext = Some(GameContext.initial),
|
||||||
|
)
|
||||||
|
undoable.undo() shouldBe true
|
||||||
|
|
||||||
|
test("MoveCommand is immutable and preserves equality/hash semantics"):
|
||||||
|
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
|
||||||
|
val result = MoveResult.Successful(GameContext.initial, None)
|
||||||
|
val cmd2 = cmd1.copy(
|
||||||
|
moveResult = Some(result),
|
||||||
|
previousContext = Some(GameContext.initial),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd1.moveResult shouldBe None
|
||||||
|
cmd1.previousContext shouldBe None
|
||||||
|
|
||||||
|
cmd2.moveResult shouldBe Some(result)
|
||||||
|
cmd2.previousContext shouldBe Some(GameContext.initial)
|
||||||
|
|
||||||
|
val eq1 = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = None,
|
||||||
|
previousContext = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
val eq2 = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = None,
|
||||||
|
previousContext = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
eq1 shouldBe eq2
|
||||||
|
eq1.hashCode shouldBe eq2.hashCode
|
||||||
|
|
||||||
|
val hash1 = eq1.hashCode
|
||||||
|
val hash2 = eq1.hashCode
|
||||||
|
hash1 shouldBe hash2
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
package de.nowchess.chess.controller
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
import de.nowchess.chess.notation.FenParser
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameControllerTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
|
||||||
GameController.processMove(board, history, turn, raw)
|
|
||||||
|
|
||||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
|
||||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
|
||||||
|
|
||||||
// ──── processMove ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: 'quit' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: 'q' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: quit with surrounding whitespace returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: unparseable input returns InvalidFormat"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
|
||||||
|
|
||||||
test("processMove: valid format but empty square returns NoPiece"):
|
|
||||||
// E3 is empty in the initial position
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
|
||||||
|
|
||||||
test("processMove: piece of wrong color returns WrongColor"):
|
|
||||||
// E7 has a Black pawn; it is White's turn
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
|
||||||
|
|
||||||
test("processMove: geometrically illegal move returns IllegalMove"):
|
|
||||||
// White pawn at E2 cannot jump three squares to E5
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that leaves own king in check returns IllegalMove"):
|
|
||||||
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
|
|
||||||
// geometrically legal but does not resolve the check — must be rejected.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.D, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that resolves check is allowed"):
|
|
||||||
// White King E1 is in check from Black Rook E8 along the E-file.
|
|
||||||
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a5e5") match
|
|
||||||
case _: MoveResult.Moved => succeed
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
|
||||||
captured shouldBe None
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal capture returns Moved with the captured piece"):
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(board, GameHistory.empty, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
|
||||||
|
|
||||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
|
||||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
|
|
||||||
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a1a8") match
|
|
||||||
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal move that results in checkmate returns Checkmate"):
|
|
||||||
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
|
|
||||||
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
|
|
||||||
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
|
|
||||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a1h8") match
|
|
||||||
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
|
|
||||||
case other => fail(s"Expected Checkmate(White), got $other")
|
|
||||||
|
|
||||||
test("processMove: legal move that results in stalemate returns Stalemate"):
|
|
||||||
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
|
|
||||||
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
|
|
||||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "b1b6") match
|
|
||||||
case MoveResult.Stalemate => succeed
|
|
||||||
case other => fail(s"Expected Stalemate, got $other")
|
|
||||||
|
|
||||||
// ──── castling execution ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
|
||||||
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
|
||||||
captured shouldBe None
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1c1") match
|
|
||||||
case MoveResult.Moved(newBoard, _, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── rights revocation ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 revokes both white castling rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h1 revokes white kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "h1h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving king from e1 revokes both white rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1e2") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on h1 revokes white kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rights revoked returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
|
|
||||||
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: moving king from e8 revokes both black rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from a8 revokes black queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h8 revokes black kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on a1 revokes white queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.A, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
// ──── en passant ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("en passant capture removes the captured pawn from the board"):
|
|
||||||
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
|
|
||||||
val result = GameController.processMove(b, h, Color.White, "e5d6")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
test("en passant capture by black removes the captured white pawn"):
|
|
||||||
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.D, Rank.R4) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R4) -> Piece.WhitePawn,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
|
||||||
val result = GameController.processMove(b, h, Color.Black, "d4e3")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.WhitePawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
// ──── pawn promotion detection ───────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R7))
|
|
||||||
to should be (sq(File.E, Rank.R8))
|
|
||||||
turn should be (Color.White)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R2))
|
|
||||||
to should be (sq(File.E, Rank.R1))
|
|
||||||
turn should be (Color.Black)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
// ──── completePromotion ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("completePromotion applies move and places queen"):
|
|
||||||
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
|
||||||
newHistory.moves should have length 1
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion"):
|
|
||||||
// Black king on h1: not attacked by rook on e8 (different file and rank)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Rook, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
||||||
case _ => fail("Expected Moved with Rook")
|
|
||||||
|
|
||||||
test("completePromotion with bishop underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Bishop, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
|
||||||
case _ => fail("Expected Moved with Bishop")
|
|
||||||
|
|
||||||
test("completePromotion with knight underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved with Knight")
|
|
||||||
|
|
||||||
test("completePromotion captures opponent piece"):
|
|
||||||
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected Moved with captured piece")
|
|
||||||
|
|
||||||
test("completePromotion for black pawn to R1"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
|
|
||||||
PromotionPiece.Knight, Color.Black
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion evaluates check after promotion"):
|
|
||||||
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
|
||||||
|
|
||||||
test("completePromotion full round-trip via processMove then completePromotion"):
|
|
||||||
// Black king on h1: not attacked by queen on e8
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
|
||||||
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
|
||||||
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
|
||||||
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("completePromotion results in checkmate when promotion delivers checkmate"):
|
|
||||||
// Black king a8, white pawn h7, white king b6.
|
|
||||||
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
|
|
||||||
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
|
|
||||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case MoveResult.Checkmate(_) => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Checkmate(winner) => winner should be (Color.White)
|
|
||||||
case _ => fail("Expected Checkmate")
|
|
||||||
|
|
||||||
test("completePromotion results in stalemate when promotion stalemates opponent"):
|
|
||||||
// Black king a8, white pawn b7, white bishop c7, white king b6.
|
|
||||||
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
|
|
||||||
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
|
|
||||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result should be (MoveResult.Stalemate)
|
|
||||||
|
|
||||||
// ──── half-move clock propagation ────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
|
||||||
// g1f3 is a knight move — not a pawn, not a capture
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 1
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: capture resets halfMoveClock to 0"):
|
|
||||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory(halfMoveClock = 10)
|
|
||||||
processMove(board, history, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
|
||||||
val history = GameHistory(halfMoveClock = 5)
|
|
||||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 6
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, Color}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.chess.observer.*
|
||||||
|
import de.nowchess.io.fen.FenParser
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
|
object EngineTestHelpers:
|
||||||
|
|
||||||
|
def makeEngine(): GameEngine =
|
||||||
|
new GameEngine(ruleSet = DefaultRules)
|
||||||
|
|
||||||
|
def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine =
|
||||||
|
GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||||
|
|
||||||
|
def loadFen(engine: GameEngine, fen: String): Unit =
|
||||||
|
engine.loadGame(FenParser, fen)
|
||||||
|
|
||||||
|
def captureEvents(engine: GameEngine): mutable.ListBuffer[GameEvent] =
|
||||||
|
val events = mutable.ListBuffer[GameEvent]()
|
||||||
|
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||||
|
events
|
||||||
|
|
||||||
|
class MockObserver extends Observer:
|
||||||
|
private val _events = mutable.ListBuffer[GameEvent]()
|
||||||
|
|
||||||
|
def events: mutable.ListBuffer[GameEvent] = _events
|
||||||
|
def eventCount: Int = _events.length
|
||||||
|
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
||||||
|
_events.exists(ct.runtimeClass.isInstance(_))
|
||||||
|
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||||
|
_events.collectFirst { case e: T => e }
|
||||||
|
|
||||||
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
|
_events += event
|
||||||
|
|
||||||
|
def clear(): Unit =
|
||||||
|
_events.clear()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user