Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0399a4e48 | |||
| ec2ab2f365 | |||
| fd4e67d4f7 | |||
| 3cb3160731 | |||
| dbcafd2869 | |||
| 3ecb2c9d66 | |||
| 9ad11fb97a | |||
| e158b0a7f0 | |||
| f1c9df16b6 | |||
| 9d11d25b99 | |||
| 7a045d31d7 | |||
| b518c704fa | |||
| fe8e3c0539 | |||
| 1b16adcc72 | |||
| b4bc72f7e4 |
@@ -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 ###
|
||||
.vscode/
|
||||
graphify-out/
|
||||
.graphify_*.json
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
YOU CAN:
|
||||
- Edit and use the asset in any commercial or non commercial project
|
||||
- Use the asset in any commercial or non commercial project
|
||||
|
||||
YOU CAN'T:
|
||||
- Resell or distribute the asset to others
|
||||
YOU CAN:
|
||||
- Edit and use the asset in any commercial or non commercial project
|
||||
- Use the asset in any commercial or non commercial project
|
||||
|
||||
YOU CAN'T:
|
||||
- Resell or distribute the asset to others
|
||||
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
|
||||
@@ -37,6 +37,11 @@ Try to stick to these commands for consistency.
|
||||
|
||||
- **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.
|
||||
@@ -47,4 +52,44 @@ Try to stick to these commands for consistency.
|
||||
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
+31
-1
@@ -1,6 +1,8 @@
|
||||
plugins {
|
||||
id("org.sonarqube") version "7.2.3.7755"
|
||||
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"
|
||||
@@ -32,7 +34,35 @@ val versions = mapOf(
|
||||
"SCOVERAGE" to "2.1.1",
|
||||
"SCALAFX" to "21.0.0-R32",
|
||||
"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
|
||||
|
||||
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,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
|
||||
@@ -21,3 +21,9 @@
|
||||
### 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))
|
||||
|
||||
@@ -7,11 +7,11 @@ object Board:
|
||||
def apply(pieces: Map[Square, Piece]): Board = pieces
|
||||
|
||||
extension (b: Board)
|
||||
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
||||
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
||||
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
|
||||
def removed(sq: Square): Board = b.removed(sq)
|
||||
def removed(sq: Square): Board = b.removed(sq)
|
||||
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
|
||||
val captured = b.get(to)
|
||||
val captured = b.get(to)
|
||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||
(updatedBoard, captured)
|
||||
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||
@@ -21,8 +21,14 @@ object Board:
|
||||
|
||||
val initial: Board =
|
||||
val backRank: Vector[PieceType] = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
val entries = for
|
||||
fileIdx <- 0 until 8
|
||||
@@ -30,7 +36,7 @@ object Board:
|
||||
(Color.White, Rank.R1, backRank(fileIdx)),
|
||||
(Color.White, Rank.R2, PieceType.Pawn),
|
||||
(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)
|
||||
Board(entries.toMap)
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
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
|
||||
*/
|
||||
/** 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
|
||||
whiteKingSide: Boolean,
|
||||
whiteQueenSide: Boolean,
|
||||
blackKingSide: Boolean,
|
||||
blackQueenSide: Boolean,
|
||||
):
|
||||
/**
|
||||
* Check if either side has any castling rights remaining.
|
||||
*/
|
||||
/** 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.
|
||||
*/
|
||||
/** 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.
|
||||
*/
|
||||
/** 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.
|
||||
*/
|
||||
/** 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.
|
||||
*/
|
||||
/** Revoke a specific castling right.
|
||||
*/
|
||||
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteQueenSide = false)
|
||||
case Color.Black => copy(blackQueenSide = false)
|
||||
@@ -55,7 +53,7 @@ object CastlingRights:
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
blackQueenSide = false,
|
||||
)
|
||||
|
||||
/** All castling rights available. */
|
||||
@@ -63,7 +61,7 @@ object CastlingRights:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = true,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
|
||||
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||
|
||||
@@ -5,16 +5,16 @@ final case class Piece(color: Color, pieceType: PieceType)
|
||||
|
||||
object Piece:
|
||||
// Convenience constructors
|
||||
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
|
||||
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
|
||||
val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight)
|
||||
val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
|
||||
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
||||
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
||||
|
||||
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
|
||||
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
|
||||
val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight)
|
||||
val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
|
||||
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
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:
|
||||
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:
|
||||
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||
|
||||
/**
|
||||
* A unique square on the board, identified by its file and rank.
|
||||
*
|
||||
* @param file the column, a–h
|
||||
* @param rank the row, 1–8
|
||||
*/
|
||||
/** A unique square on the board, identified by its file and rank.
|
||||
*
|
||||
* @param file
|
||||
* the column, a–h
|
||||
* @param rank
|
||||
* the row, 1–8
|
||||
*/
|
||||
final case class Square(file: File, rank: Rank):
|
||||
/** Algebraic notation string, e.g. "e4". */
|
||||
override def toString: String =
|
||||
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||
|
||||
object Square:
|
||||
/** Parse a square from algebraic notation (e.g. "e4").
|
||||
* Returns None if the input is not a valid square name. */
|
||||
/** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
|
||||
*/
|
||||
def fromAlgebraic(s: String): Option[Square] =
|
||||
if s.length != 2 then None
|
||||
else
|
||||
val fileChar = s.charAt(0)
|
||||
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 =
|
||||
rankChar.toString.toIntOption.flatMap(n =>
|
||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
||||
)
|
||||
rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
|
||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||
|
||||
val all: IndexedSeq[Square] =
|
||||
@@ -46,12 +41,13 @@ object Square:
|
||||
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). */
|
||||
/** 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
|
||||
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
|
||||
@@ -1,18 +1,18 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
|
||||
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.
|
||||
*/
|
||||
/** 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]
|
||||
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)
|
||||
@@ -32,6 +32,9 @@ case class GameContext(
|
||||
/** 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(
|
||||
@@ -40,5 +43,5 @@ object GameContext:
|
||||
castlingRights = CastlingRights.Initial,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
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)
|
||||
@@ -10,24 +10,30 @@ enum PromotionPiece:
|
||||
enum MoveType:
|
||||
/** A normal move or capture with no special rule. */
|
||||
case Normal(isCapture: Boolean = false)
|
||||
|
||||
/** Kingside castling (O-O). */
|
||||
case CastleKingside
|
||||
|
||||
/** Queenside castling (O-O-O). */
|
||||
case CastleQueenside
|
||||
|
||||
/** En-passant pawn capture. */
|
||||
case EnPassant
|
||||
|
||||
/** Pawn promotion; carries the chosen promotion piece. */
|
||||
case Promotion(piece: PromotionPiece)
|
||||
|
||||
/**
|
||||
* A half-move (ply) in a chess game.
|
||||
*
|
||||
* @param from origin square
|
||||
* @param to destination square
|
||||
* @param moveType special semantics; defaults to Normal
|
||||
*/
|
||||
/** A half-move (ply) in a chess game.
|
||||
*
|
||||
* @param from
|
||||
* origin square
|
||||
* @param to
|
||||
* destination square
|
||||
* @param moveType
|
||||
* special semantics; defaults to Normal
|
||||
*/
|
||||
final case class Move(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal()
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal(),
|
||||
)
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
package de.nowchess.api.player
|
||||
|
||||
/**
|
||||
* An opaque player identifier.
|
||||
*
|
||||
* Wraps a plain String so that IDs are not accidentally interchanged with
|
||||
* other String values at compile time.
|
||||
*/
|
||||
/** An opaque player identifier.
|
||||
*
|
||||
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
|
||||
*/
|
||||
opaque type PlayerId = String
|
||||
|
||||
object PlayerId:
|
||||
def apply(value: String): PlayerId = value
|
||||
def apply(value: String): PlayerId = value
|
||||
extension (id: PlayerId) def value: String = id
|
||||
|
||||
/**
|
||||
* The minimal cross-service identity stub for a player.
|
||||
*
|
||||
* Full profile data (email, rating history, etc.) lives in the user-management
|
||||
* service. Only what every service needs is held here.
|
||||
*
|
||||
* @param id unique identifier
|
||||
* @param displayName human-readable name shown in the UI
|
||||
*/
|
||||
/** The minimal cross-service identity stub for a player.
|
||||
*
|
||||
* Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs
|
||||
* is held here.
|
||||
*
|
||||
* @param id
|
||||
* unique identifier
|
||||
* @param displayName
|
||||
* human-readable name shown in the UI
|
||||
*/
|
||||
final case class PlayerInfo(
|
||||
id: PlayerId,
|
||||
displayName: String
|
||||
id: PlayerId,
|
||||
displayName: String,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package de.nowchess.api.response
|
||||
|
||||
/**
|
||||
* A standardised envelope for every API response.
|
||||
*
|
||||
* Success and failure are modelled as subtypes so that callers
|
||||
* can pattern-match exhaustively.
|
||||
*
|
||||
* @tparam A the payload type for a successful response
|
||||
*/
|
||||
/** A standardised envelope for every API response.
|
||||
*
|
||||
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
|
||||
*
|
||||
* @tparam A
|
||||
* the payload type for a successful response
|
||||
*/
|
||||
sealed trait ApiResponse[+A]
|
||||
|
||||
object ApiResponse:
|
||||
@@ -20,43 +19,49 @@ object ApiResponse:
|
||||
/** Convenience constructor for a single-error failure. */
|
||||
def error(err: ApiError): Failure = Failure(List(err))
|
||||
|
||||
/**
|
||||
* A structured error descriptor.
|
||||
*
|
||||
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||
* @param message human-readable explanation
|
||||
* @param field optional field name when the error relates to a specific input
|
||||
*/
|
||||
/** A structured error descriptor.
|
||||
*
|
||||
* @param code
|
||||
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||
* @param message
|
||||
* human-readable explanation
|
||||
* @param field
|
||||
* optional field name when the error relates to a specific input
|
||||
*/
|
||||
final case class ApiError(
|
||||
code: String,
|
||||
message: String,
|
||||
field: Option[String] = None
|
||||
code: String,
|
||||
message: String,
|
||||
field: Option[String] = None,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pagination metadata for list responses.
|
||||
*
|
||||
* @param page current 0-based page index
|
||||
* @param pageSize number of items per page
|
||||
* @param totalItems total number of items across all pages
|
||||
*/
|
||||
/** Pagination metadata for list responses.
|
||||
*
|
||||
* @param page
|
||||
* current 0-based page index
|
||||
* @param pageSize
|
||||
* number of items per page
|
||||
* @param totalItems
|
||||
* total number of items across all pages
|
||||
*/
|
||||
final case class Pagination(
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
totalItems: Long
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
totalItems: Long,
|
||||
):
|
||||
def totalPages: Int =
|
||||
if pageSize <= 0 then 0
|
||||
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||
|
||||
/**
|
||||
* A paginated list response envelope.
|
||||
*
|
||||
* @param items the items on the current page
|
||||
* @param pagination pagination metadata
|
||||
* @tparam A the item type
|
||||
*/
|
||||
/** A paginated list response envelope.
|
||||
*
|
||||
* @param items
|
||||
* the items on the current page
|
||||
* @param pagination
|
||||
* pagination metadata
|
||||
* @tparam A
|
||||
* the item type
|
||||
*/
|
||||
final case class PagedResponse[A](
|
||||
items: List[A],
|
||||
pagination: Pagination
|
||||
items: List[A],
|
||||
pagination: Pagination,
|
||||
)
|
||||
|
||||
@@ -22,9 +22,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("withMove returns captured piece when destination is occupied") {
|
||||
val from = Square(File.A, Rank.R1)
|
||||
val to = Square(File.A, Rank.R8)
|
||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||
val from = Square(File.A, Rank.R1)
|
||||
val to = Square(File.A, Rank.R8)
|
||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||
val (board, captured) = b.withMove(from, to)
|
||||
captured shouldBe Some(Piece.BlackRook)
|
||||
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
|
||||
@@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("initial board white back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||
@@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("initial board black back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||
@@ -76,12 +88,11 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
for
|
||||
rank <- emptyRanks
|
||||
file <- File.values
|
||||
do
|
||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
do Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
}
|
||||
|
||||
test("updated adds and replaces piece at squares") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val added = b.updated(e4, Piece.WhiteKnight)
|
||||
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
@@ -91,7 +102,7 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("removed deletes piece from board") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
|
||||
val removed = b.removed(e2)
|
||||
removed.pieceAt(e2) shouldBe None
|
||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
@@ -105,4 +116,3 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||
moved.pieceAt(e2) shouldBe None
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
|
||||
rights.hasAnyRights shouldBe true
|
||||
@@ -54,4 +54,3 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
|
||||
test("Color values expose opposite and label consistently"):
|
||||
val cases = List(
|
||||
(Color.White, Color.Black, "White"),
|
||||
(Color.Black, Color.White, "Black")
|
||||
(Color.Black, Color.White, "Black"),
|
||||
)
|
||||
|
||||
cases.foreach { (color, opposite, label) =>
|
||||
|
||||
@@ -7,24 +7,24 @@ class PieceTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Piece holds color and pieceType") {
|
||||
val p = Piece(Color.White, PieceType.Queen)
|
||||
p.color shouldBe Color.White
|
||||
p.color shouldBe Color.White
|
||||
p.pieceType shouldBe PieceType.Queen
|
||||
}
|
||||
|
||||
test("all convenience constants map to expected color and piece type") {
|
||||
val expected = List(
|
||||
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
||||
Piece.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.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)
|
||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
|
||||
expected.foreach { case (actual, wanted) =>
|
||||
|
||||
@@ -7,12 +7,12 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PieceType values expose the expected labels"):
|
||||
val expectedLabels = List(
|
||||
PieceType.Pawn -> "Pawn",
|
||||
PieceType.Pawn -> "Pawn",
|
||||
PieceType.Knight -> "Knight",
|
||||
PieceType.Bishop -> "Bishop",
|
||||
PieceType.Rook -> "Rook",
|
||||
PieceType.Queen -> "Queen",
|
||||
PieceType.King -> "King"
|
||||
PieceType.Rook -> "Rook",
|
||||
PieceType.Queen -> "Queen",
|
||||
PieceType.King -> "King",
|
||||
)
|
||||
|
||||
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
|
||||
"a1" -> Square(File.A, Rank.R1),
|
||||
"e4" -> Square(File.E, Rank.R4),
|
||||
"h8" -> Square(File.H, Rank.R8),
|
||||
"E4" -> Square(File.E, Rank.R4)
|
||||
"E4" -> Square(File.E, Rank.R4),
|
||||
)
|
||||
expected.foreach { case (raw, sq) =>
|
||||
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||
@@ -34,4 +34,3 @@ class SquareTest extends AnyFunSuite with Matchers:
|
||||
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -16,11 +17,12 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
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 square = Square(File.E, Rank.R4)
|
||||
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
||||
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||
updated.board shouldBe updatedBoard
|
||||
updated.turn shouldBe GameContext.initial.turn
|
||||
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||
@@ -34,13 +36,13 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
val square = Some(Square(File.E, Rank.R3))
|
||||
val updatedTurn = initial.withTurn(Color.Black)
|
||||
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)
|
||||
val updatedEp = initial.withEnPassantSquare(square)
|
||||
val updatedClock = initial.withHalfMoveClock(17)
|
||||
|
||||
updatedTurn.turn shouldBe Color.Black
|
||||
updatedTurn.board shouldBe initial.board
|
||||
@@ -58,3 +60,14 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@ class MoveTest extends AnyFunSuite with Matchers:
|
||||
MoveType.Promotion(PromotionPiece.Queen),
|
||||
MoveType.Promotion(PromotionPiece.Rook),
|
||||
MoveType.Promotion(PromotionPiece.Bishop),
|
||||
MoveType.Promotion(PromotionPiece.Knight)
|
||||
MoveType.Promotion(PromotionPiece.Knight),
|
||||
)
|
||||
|
||||
moveTypes.foreach { moveType =>
|
||||
|
||||
@@ -7,12 +7,12 @@ class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||
val raw = "player-123"
|
||||
val id = PlayerId(raw)
|
||||
val id = PlayerId(raw)
|
||||
|
||||
id.value shouldBe raw
|
||||
|
||||
val playerId = PlayerId("p1")
|
||||
val info = PlayerInfo(playerId, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
val info = PlayerInfo(playerId, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||
|
||||
val e = ApiError("CODE", "message")
|
||||
e.code shouldBe "CODE"
|
||||
e.code shouldBe "CODE"
|
||||
e.message shouldBe "message"
|
||||
e.field shouldBe None
|
||||
e.field shouldBe None
|
||||
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||
test("PagedResponse holds items and pagination") {
|
||||
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
|
||||
val pr = PagedResponse(List("a", "b"), pagination)
|
||||
pr.items shouldBe List("a", "b")
|
||||
pr.items shouldBe List("a", "b")
|
||||
pr.pagination shouldBe pagination
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=3
|
||||
MINOR=4
|
||||
PATCH=0
|
||||
|
||||
@@ -211,3 +211,27 @@
|
||||
* 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))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, Piece}
|
||||
import de.nowchess.api.board.{Piece, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone.
|
||||
* Commands encapsulate user actions and game state transitions.
|
||||
*/
|
||||
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||
* transitions.
|
||||
*/
|
||||
trait Command:
|
||||
/** Execute the command and return true if successful, false otherwise. */
|
||||
def execute(): Boolean
|
||||
@@ -16,15 +16,14 @@ trait Command:
|
||||
/** A human-readable description of this command. */
|
||||
def description: String
|
||||
|
||||
/** Command to move a piece from one square to another.
|
||||
* Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = ""
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = "",
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
@@ -39,18 +38,18 @@ case class MoveCommand(
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
/** Command to quit the game. */
|
||||
case class QuitCommand() extends Command:
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Quit game"
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousContext: Option[GameContext] = None
|
||||
previousContext: Option[GameContext] = None,
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
|
||||
/** Manages command execution and history for undo/redo support. */
|
||||
class CommandInvoker:
|
||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentIndex = -1
|
||||
|
||||
/** Execute a command and add it to history.
|
||||
* Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
def execute(command: Command): Boolean = synchronized {
|
||||
if command.execute() then
|
||||
// Remove any commands after current index (redo stack is discarded)
|
||||
while currentIndex < executedCommands.size - 1 do
|
||||
executedCommands.remove(executedCommands.size - 1)
|
||||
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||
executedCommands += command
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Undo the last executed command if possible. */
|
||||
@@ -27,10 +25,8 @@ class CommandInvoker:
|
||||
if command.undo() then
|
||||
currentIndex -= 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Redo the next command in history if available. */
|
||||
@@ -40,10 +36,8 @@ class CommandInvoker:
|
||||
if command.execute() then
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Get the history of all executed commands. */
|
||||
|
||||
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
|
||||
|
||||
object Parser:
|
||||
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
||||
* Returns None for any input that does not match the expected format.
|
||||
*/
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||
* format.
|
||||
*/
|
||||
def parseMove(input: String): Option[(Square, Square)] =
|
||||
val trimmed = input.trim.toLowerCase
|
||||
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
Option
|
||||
.when(trimmed.length == 4)(trimmed)
|
||||
.flatMap: s =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
|
||||
private def parseSquare(s: String): Option[Square] =
|
||||
Option.when(s.length == 2)(s).flatMap: sq =>
|
||||
val fileIdx = sq(0) - 'a'
|
||||
val rankIdx = sq(1) - '1'
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
||||
)
|
||||
Option
|
||||
.when(s.length == 2)(s)
|
||||
.flatMap: sq =>
|
||||
val fileIdx = sq(0) - 'a'
|
||||
val rankIdx = sq(1) - '1'
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||
)
|
||||
|
||||
@@ -2,49 +2,50 @@ package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||
import de.nowchess.io.{GameContextImport, GameContextExport}
|
||||
import de.nowchess.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.
|
||||
* All rule queries delegate to the injected RuleSet.
|
||||
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
*/
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
*/
|
||||
class GameEngine(
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules,
|
||||
) extends Observable:
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentContext: GameContext = initialContext
|
||||
private val invoker = new CommandInvoker()
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized { currentContext.board }
|
||||
def turn: Color = synchronized { currentContext.turn }
|
||||
def context: GameContext = synchronized { currentContext }
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||
|
||||
/** 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). */
|
||||
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.
|
||||
* Notifies all observers of the outcome via GameEvent.
|
||||
*/
|
||||
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||
* GameEvent.
|
||||
*/
|
||||
def processUserInput(rawInput: String): Unit = synchronized {
|
||||
val trimmed = rawInput.trim.toLowerCase
|
||||
trimmed match
|
||||
@@ -59,13 +60,16 @@ class GameEngine(
|
||||
|
||||
case "draw" =>
|
||||
if currentContext.halfMoveClock >= 100 then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentContext))
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentContext,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(
|
||||
currentContext,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||
),
|
||||
)
|
||||
|
||||
case "" =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||
@@ -73,10 +77,12 @@ class GameEngine(
|
||||
case moveInput =>
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentContext,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
))
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(
|
||||
currentContext,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||
),
|
||||
)
|
||||
case Some((from, to)) =>
|
||||
handleParsedMove(from, to)
|
||||
}
|
||||
@@ -108,9 +114,8 @@ class GameEngine(
|
||||
to.rank.ordinal == promoRank
|
||||
}
|
||||
|
||||
/** Apply a player's promotion piece choice.
|
||||
* Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||
pendingPromotion match
|
||||
case None =>
|
||||
@@ -120,23 +125,19 @@ class GameEngine(
|
||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||
// Verify it's actually legal
|
||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||
if legal.contains(move) then
|
||||
executeMove(move)
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
if legal.contains(move) then executeMove(move)
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized { performUndo() }
|
||||
def undo(): Unit = synchronized(performUndo())
|
||||
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized { performRedo() }
|
||||
def redo(): Unit = synchronized(performRedo())
|
||||
|
||||
/** Load a game using the provided importer.
|
||||
* If the imported context has moves, they are replayed through the command system.
|
||||
* Otherwise, the position is set directly.
|
||||
* Notifies observers with PgnLoadedEvent on success.
|
||||
*/
|
||||
/** 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)
|
||||
@@ -155,29 +156,24 @@ class GameEngine(
|
||||
if ctx.moves.isEmpty then
|
||||
currentContext = ctx
|
||||
Right(())
|
||||
else
|
||||
replayMoves(ctx.moves, savedContext)
|
||||
else replayMoves(ctx.moves, savedContext)
|
||||
|
||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||
var error: Option[String] = None
|
||||
moves.foreach: move =>
|
||||
if error.isEmpty then
|
||||
handleParsedMove(move.from, move.to)
|
||||
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||
acc.flatMap(_ => applyReplayMove(move))
|
||||
}
|
||||
result.left.foreach(_ => currentContext = savedContext)
|
||||
result
|
||||
|
||||
move.moveType match {
|
||||
case MoveType.Promotion(pp) =>
|
||||
if pendingPromotion.isDefined then
|
||||
completePromotion(pp)
|
||||
else
|
||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||
case _ => ()
|
||||
}
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentContext = savedContext
|
||||
Left(err)
|
||||
case None =>
|
||||
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 {
|
||||
@@ -203,47 +199,51 @@ class GameEngine(
|
||||
|
||||
private def executeMove(move: Move): Unit =
|
||||
val contextBefore = currentContext
|
||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||
val captured = computeCaptured(currentContext, move)
|
||||
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)
|
||||
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}")
|
||||
))
|
||||
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()
|
||||
currentContext = GameContext.initial
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
notifyObservers(StalemateEvent(currentContext))
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||
invoker.clear()
|
||||
currentContext = GameContext.initial
|
||||
else if ruleSet.isCheck(currentContext) then
|
||||
notifyObservers(CheckDetectedEvent(currentContext))
|
||||
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))
|
||||
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.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 =
|
||||
@@ -295,8 +295,7 @@ class GameEngine(
|
||||
moveCmd.previousContext.foreach(currentContext = _)
|
||||
invoker.undo()
|
||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||
|
||||
private def performRedo(): Unit =
|
||||
if invoker.canRedo then
|
||||
@@ -307,12 +306,13 @@ class GameEngine(
|
||||
currentContext = nextCtx
|
||||
invoker.redo()
|
||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveRedoneEvent(
|
||||
currentContext,
|
||||
moveCmd.notation,
|
||||
moveCmd.from.toString,
|
||||
moveCmd.to.toString,
|
||||
capturedDesc
|
||||
))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||
notifyObservers(
|
||||
MoveRedoneEvent(
|
||||
currentContext,
|
||||
moveCmd.notation,
|
||||
moveCmd.from.toString,
|
||||
moveCmd.to.toString,
|
||||
capturedDesc,
|
||||
),
|
||||
)
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||
|
||||
@@ -1,84 +1,79 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||
|
||||
/** Base trait for all game state events.
|
||||
* Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
sealed trait GameEvent:
|
||||
def context: GameContext
|
||||
|
||||
/** Fired when a move is successfully executed. */
|
||||
case class MoveExecutedEvent(
|
||||
context: GameContext,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
context: GameContext,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String],
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the current player is in check. */
|
||||
case class CheckDetectedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches checkmate. */
|
||||
case class CheckmateEvent(
|
||||
context: GameContext,
|
||||
winner: Color
|
||||
context: GameContext,
|
||||
winner: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches stalemate. */
|
||||
case class StalemateEvent(
|
||||
context: GameContext
|
||||
/** Fired when the game ends in a draw. */
|
||||
case class DrawEvent(
|
||||
context: GameContext,
|
||||
reason: DrawReason,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is invalid. */
|
||||
case class InvalidMoveEvent(
|
||||
context: GameContext,
|
||||
reason: String
|
||||
context: GameContext,
|
||||
reason: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||
case class MoveUndoneEvent(
|
||||
context: GameContext,
|
||||
pgnNotation: String
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||
case class MoveRedoneEvent(
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String],
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||
case class PgnLoadedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
|
||||
+30
-25
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
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
|
||||
@@ -10,13 +10,18 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
private case class FailingCommand() extends Command:
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Failing command"
|
||||
|
||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||
override def execute(): Boolean = !shouldFailOnExecute
|
||||
override def undo(): Boolean = !shouldFailOnUndo
|
||||
private class ConditionalFailCommand(
|
||||
initialShouldFailOnUndo: Boolean = false,
|
||||
initialShouldFailOnExecute: Boolean = false,
|
||||
) 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"
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||
@@ -24,12 +29,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
|
||||
test("execute rejects failing commands and keeps history unchanged"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = FailingCommand()
|
||||
val cmd = FailingCommand()
|
||||
invoker.execute(cmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
@@ -52,8 +57,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
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 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)
|
||||
invoker.undo()
|
||||
@@ -62,8 +67,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||
val invoker = new CommandInvoker()
|
||||
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
|
||||
invoker.execute(failingUndoCmd) shouldBe true
|
||||
invoker.canUndo shouldBe true
|
||||
invoker.undo() shouldBe false
|
||||
@@ -71,7 +76,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val invoker = new CommandInvoker()
|
||||
val successUndoCmd = ConditionalFailCommand()
|
||||
invoker.execute(successUndoCmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
@@ -85,28 +90,28 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
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.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val redoFailCmd = ConditionalFailCommand()
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
redoFailCmd.shouldFailOnExecute = true
|
||||
redoFailCmd.shouldFailOnExecute.set(true)
|
||||
invoker.redo() shouldBe false
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
}
|
||||
|
||||
{
|
||||
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.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
@@ -115,9 +120,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
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))
|
||||
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()
|
||||
@@ -130,10 +135,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.execute(cmd3)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
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
|
||||
@@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
|
||||
test("execute appends commands and updates index"):
|
||||
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.history.size shouldBe 1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
@@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("undo and redo update index and availability flags"):
|
||||
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.canUndo shouldBe true
|
||||
@@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
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))
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.clear()
|
||||
invoker.history.size shouldBe 0
|
||||
@@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("execute after undo discards redo 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))
|
||||
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()
|
||||
|
||||
@@ -8,7 +8,6 @@ class CommandTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("QuitCommand properties and behavior"):
|
||||
val cmd = QuitCommand()
|
||||
cmd shouldNot be(null)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
cmd.description shouldBe "Quit game"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
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
|
||||
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
val executable = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
)
|
||||
executable.execute() shouldBe true
|
||||
|
||||
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
undoable.undo() shouldBe true
|
||||
|
||||
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
val result = MoveResult.Successful(GameContext.initial, None)
|
||||
val cmd2 = cmd1.copy(
|
||||
moveResult = Some(result),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
|
||||
cmd1.moveResult shouldBe None
|
||||
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
previousContext = None,
|
||||
)
|
||||
|
||||
val eq2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
previousContext = None,
|
||||
)
|
||||
|
||||
eq1 shouldBe eq2
|
||||
|
||||
@@ -27,11 +27,11 @@ object EngineTestHelpers:
|
||||
private val _events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
def events: mutable.ListBuffer[GameEvent] = _events
|
||||
def eventCount: Int = _events.length
|
||||
def eventCount: Int = _events.length
|
||||
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
||||
_events.exists(ct.runtimeClass.isInstance(_))
|
||||
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||
_events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
|
||||
_events.collectFirst { case e: T => e }
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
_events += event
|
||||
|
||||
+42
-39
@@ -1,8 +1,9 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -10,82 +11,84 @@ import org.scalatest.matchers.should.Matchers
|
||||
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles Checkmate (Fool's Mate)"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
// Play Fool's mate
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||
observer.events.last shouldBe a[CheckmateEvent]
|
||||
|
||||
val event = observer.events.last.asInstanceOf[CheckmateEvent]
|
||||
|
||||
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
|
||||
event.winner shouldBe Color.Black
|
||||
|
||||
// Board should be reset after checkmate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
// Play a simple check
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("f1c4")
|
||||
engine.processUserInput("g8f6")
|
||||
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput("c4f7") // Check!
|
||||
|
||||
|
||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
||||
checkEvents.size shouldBe 1
|
||||
checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
|
||||
|
||||
|
||||
// Shortest known stalemate is 19 moves. Here is a faster one:
|
||||
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
||||
// Wait, let's just use Sam Loyd's 10-move stalemate:
|
||||
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
|
||||
test("GameEngine handles Stalemate via 10-move known sequence"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
"c8e6",
|
||||
)
|
||||
|
||||
|
||||
moves.dropRight(1).foreach(engine.processUserInput)
|
||||
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput(moves.last)
|
||||
|
||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
||||
stalemateEvents.size shouldBe 1
|
||||
|
||||
// Board should be reset after stalemate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
val drawEvents = observer.events.collect { case e: DrawEvent => e }
|
||||
drawEvents.size shouldBe 1
|
||||
drawEvents.head.reason shouldBe DrawReason.Stalemate
|
||||
|
||||
private class EndingMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
events += event
|
||||
|
||||
+20
-23
@@ -38,7 +38,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
|
||||
events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3
|
||||
|
||||
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
|
||||
val engine = new GameEngine()
|
||||
@@ -69,7 +69,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.context shouldBe target
|
||||
engine.commandHistory shouldBe empty
|
||||
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
|
||||
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true
|
||||
|
||||
test("redo event includes captured piece description when replaying a capture"):
|
||||
val engine = new GameEngine()
|
||||
@@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
if square == sq("e2") then List(promotionMove) else List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
|
||||
|
||||
val engine = new GameEngine(ruleSet = permissiveRules)
|
||||
@@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val noLegalMoves = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||
|
||||
val engine = new GameEngine(ruleSet = noLegalMoves)
|
||||
engine.processUserInput("e2e4")
|
||||
@@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
|
||||
engine.context.moves.lastOption shouldBe Some(normalMove)
|
||||
|
||||
test("replayMoves skips later moves after the first move triggers an error"):
|
||||
val engine = new GameEngine()
|
||||
val saved = engine.context
|
||||
val engine = new GameEngine()
|
||||
val saved = engine.context
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||
engine.context shouldBe saved
|
||||
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
val engine = new GameEngine()
|
||||
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
||||
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.observerCount shouldBe 1
|
||||
engine.unsubscribe(observer)
|
||||
engine.observerCount shouldBe 0
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
|
||||
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
|
||||
import de.nowchess.io.pgn.PgnParser
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
@@ -15,7 +15,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
|
||||
val engine = new GameEngine()
|
||||
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
|
||||
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
|
||||
val result = engine.loadGame(PgnParser, pgn)
|
||||
result shouldBe Right(())
|
||||
engine.context.moves.size shouldBe 2
|
||||
@@ -23,7 +23,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("loadGame with FenParser: loads position without replaying moves"):
|
||||
val engine = new GameEngine()
|
||||
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
||||
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
||||
val result = engine.loadGame(FenParser, fen)
|
||||
result shouldBe Right(())
|
||||
engine.context.moves.isEmpty shouldBe true
|
||||
|
||||
@@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests that exercise moveToPgn branches not covered by other test files:
|
||||
* - CastleQueenside (line 223)
|
||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||
* - Promotion(Bishop) notation (line 230)
|
||||
* - King normal move notation (line 246)
|
||||
*/
|
||||
* - CastleQueenside (line 223)
|
||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||
* - Promotion(Bishop) notation (line 230)
|
||||
* - King normal move notation (line 246)
|
||||
*/
|
||||
class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||
@@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
|
||||
// Castling rights: white queen-side only (no king-side rook present)
|
||||
val castlingRights = de.nowchess.api.board.CastlingRights(
|
||||
whiteKingSide = false,
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false,
|
||||
)
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White castles queenside: e1c1
|
||||
engine.processUserInput("e1c1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
|
||||
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
|
||||
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||
val epSquare = Square.fromAlgebraic("d6")
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White pawn on e5 captures en passant to d6
|
||||
engine.processUserInput("e5d6")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
|
||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||
moveEvt.capturedPiece shouldBe defined
|
||||
moveEvt.capturedPiece.get should include ("Black")
|
||||
moveEvt.capturedPiece.get should include("Black")
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -84,7 +84,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||
|
||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
|
||||
// Extra white pawn on h2 ensures K+B+P vs K — sufficient material, so draw is not triggered
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -105,8 +106,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── King normal move notation (line 246) ───────────────────────────
|
||||
|
||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||
// White king on e1, no castling rights, black king far away
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
|
||||
// White king on e1, white rook on h1 — K+R vs K ensures sufficient material after the king move
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -117,11 +118,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// King moves e1 -> f1
|
||||
engine.processUserInput("e1f1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
evt.pgnNotation should startWith ("K")
|
||||
evt.pgnNotation should include ("f1")
|
||||
evt.pgnNotation should startWith("K")
|
||||
evt.pgnNotation should include("f1")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.{DrawReason, GameResult}
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -10,7 +11,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Checkmate ───────────────────────────────────────────────────
|
||||
|
||||
test("checkmate ends game with CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -22,9 +23,10 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
|
||||
|
||||
test("checkmate with white winner"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -41,59 +43,81 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
val evt = observer.getEvent[CheckmateEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.winner shouldBe Color.White
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.White))
|
||||
|
||||
// ── Stalemate ───────────────────────────────────────────────────
|
||||
|
||||
test("stalemate ends game with StalemateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
test("stalemate ends game with DrawEvent(Stalemate)"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
)
|
||||
moves.foreach(engine.processUserInput)
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("c8e6")
|
||||
|
||||
observer.hasEvent[StalemateEvent] shouldBe true
|
||||
val evt = observer.getEvent[DrawEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.reason shouldBe DrawReason.Stalemate
|
||||
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.Stalemate))
|
||||
|
||||
test("stalemate when king has no moves and no pieces"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
test("stalemate board is not reset after draw"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
"c8e6",
|
||||
)
|
||||
|
||||
moves.foreach(engine.processUserInput)
|
||||
|
||||
observer.hasEvent[StalemateEvent] shouldBe true
|
||||
engine.turn shouldBe Color.White
|
||||
observer.hasEvent[DrawEvent] shouldBe true
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────
|
||||
|
||||
test("check detected after move puts king in check"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -108,11 +132,12 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("check by knight"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/4K3 w - - 0 1")
|
||||
// White has K+N+Q so the position is not insufficient material after Nd4f5
|
||||
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/3QK3 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("d4f5")
|
||||
@@ -122,7 +147,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move rule triggers when half-move clock reaches 100"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -155,7 +180,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Draw claim ────────────────────────────────────────────────
|
||||
|
||||
test("draw can be claimed when fifty-move rule is available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -164,13 +189,35 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||
val evt = observer.getEvent[DrawEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.reason shouldBe DrawReason.FiftyMoveRule
|
||||
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.FiftyMoveRule))
|
||||
|
||||
test("draw cannot be claimed when not available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
// ── Insufficient material ──────────────────────────────────────────
|
||||
|
||||
test("insufficient material fires DrawEvent(InsufficientMaterial) after capture"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White Bishop d4 captures Black Rook g7, leaving K+B vs K (insufficient material).
|
||||
// Black king on g8 can still move (f7/h7 not controlled), so it is not stalemate.
|
||||
EngineTestHelpers.loadFen(engine, "6k1/6r1/8/8/3B4/8/8/K7 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("d4g7")
|
||||
|
||||
val evt = observer.getEvent[DrawEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.reason shouldBe DrawReason.InsufficientMaterial
|
||||
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
|
||||
|
||||
+43
-43
@@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
||||
events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||
}
|
||||
|
||||
test("isPendingPromotion is true after PromotionRequired input") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
engine.isPendingPromotion should be (true)
|
||||
engine.isPendingPromotion should be(true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is false before any promotion input") {
|
||||
val engine = new GameEngine()
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with rook underpromotion") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||
}
|
||||
|
||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||
@@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: CheckmateEvent => true; case _ => false } should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: DrawEvent => true; case _ => false } should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with black pawn promotion results in Moved") {
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||
val engine = engineWith(board, Color.Black)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||
@@ -177,21 +177,21 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
DefaultRules.applyMove(context)(move)
|
||||
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be (true)
|
||||
engine.isPendingPromotion should be(true)
|
||||
|
||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||
// but only Normal moves exist → fires InvalidMoveEvent
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||
invalidEvt.reason should include ("Error completing promotion")
|
||||
invalidEvt.reason should include("Error completing promotion")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
|
||||
import de.nowchess.api.board.{Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.io.fen.FenParser
|
||||
@@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Observer wiring ────────────────────────────────────────────
|
||||
|
||||
test("observer subscribe and unsubscribe behavior"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
@@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Invalid moves (minimal) ────────────────────────────────────
|
||||
|
||||
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
engine.turn shouldBe Color.White // turn unchanged
|
||||
engine.turn shouldBe Color.White // turn unchanged
|
||||
|
||||
engine.processUserInput("e7e5") // try to move black pawn on white's turn
|
||||
engine.processUserInput("e7e5") // try to move black pawn on white's turn
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e5e4") // pawn backward
|
||||
engine.processUserInput("e5e4") // pawn backward
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
// ── Undo/Redo ────────────────────────────────────────────────
|
||||
|
||||
test("undo redo success and empty-history failures"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move event and draw claim success/failure"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -122,7 +122,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||
observer.hasEvent[DrawEvent] shouldBe true
|
||||
|
||||
// Initial position has no draw available
|
||||
observer.clear()
|
||||
|
||||
+12
-11
@@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── Castling ────────────────────────────────────────────────────
|
||||
|
||||
test("kingside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("queenside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("undo castling emits PGN notation"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── En passant ──────────────────────────────────────────────────
|
||||
|
||||
test("en passant capture executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
val moveEvt = observer.getEvent[MoveExecutedEvent]
|
||||
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
|
||||
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
|
||||
|
||||
test("undo en passant emits file-x-destination notation"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── Pawn promotion ─────────────────────────────────────────────
|
||||
|
||||
test("pawn reaching back rank requires promotion"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -171,11 +171,12 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
test("undo promotion emits notation with piece suffix"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/8 w - - 0 1")
|
||||
// White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
observer.clear()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=11
|
||||
MINOR=12
|
||||
PATCH=0
|
||||
|
||||
@@ -1,2 +1,40 @@
|
||||
## (2026-04-06)
|
||||
## (2026-04-07)
|
||||
## (2026-04-07)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
## (2026-04-08)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
|
||||
|
||||
@@ -19,6 +19,7 @@ scala {
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedFiles.set(listOf(".*FenParserFastParse.*"))
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
@@ -38,9 +39,18 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
implementation("org.scala-lang.modules:scala-parser-combinators_3:${versions["SCALA_PARSER_COMBINATORS"]!!}")
|
||||
implementation("com.lihaoyi:fastparse_3:${versions["FASTPARSE"]!!}")
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:rule"))
|
||||
|
||||
// Jackson for JSON serialization/deserialization
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||
implementation("com.fasterxml.jackson.core:jackson-core:${versions["JACKSON"]!!}")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import java.nio.file.{Files, Path}
|
||||
import java.nio.charset.StandardCharsets
|
||||
import scala.util.Try
|
||||
|
||||
/** Service for persisting and loading game states to/from disk.
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||
*/
|
||||
trait GameFileService:
|
||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||
|
||||
/** Default implementation using the file system. */
|
||||
object FileSystemGameService extends GameFileService:
|
||||
|
||||
/** Save a game context to a file using the specified exporter. */
|
||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] =
|
||||
Try {
|
||||
val json = exporter.exportGameContext(context)
|
||||
Files.write(path, json.getBytes(StandardCharsets.UTF_8))
|
||||
()
|
||||
}.fold(
|
||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||
_ => Right(()),
|
||||
)
|
||||
|
||||
/** Load a game context from a file using the specified importer. */
|
||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] =
|
||||
Try {
|
||||
val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
|
||||
importer.importGameContext(json)
|
||||
}.fold(
|
||||
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||
result => result,
|
||||
)
|
||||
@@ -15,28 +15,22 @@ object FenExporter extends GameContextExport:
|
||||
/** 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 += pieceToFenChar(piece)
|
||||
case None =>
|
||||
emptyCount += 1
|
||||
|
||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
||||
rankChars.mkString
|
||||
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
|
||||
case ((acc, empty), square) =>
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
val flushed = if empty > 0 then acc + empty.toString else acc
|
||||
(flushed + pieceToFenChar(piece), 0)
|
||||
case None =>
|
||||
(acc, empty + 1)
|
||||
if emptyCount > 0 then result + emptyCount.toString else result
|
||||
|
||||
/** Convert a GameContext to a complete FEN string. */
|
||||
def gameContextToFen(context: GameContext): String =
|
||||
val piecePlacement = boardToFen(context.board)
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val fullMoveNumber = 1 + (context.moves.length / 2)
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||
|
||||
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
|
||||
|
||||
/** Convert castling rights to FEN notation. */
|
||||
private def castlingString(rights: CastlingRights): String =
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val result = s"$wk$wq$bk$bq"
|
||||
if result.isEmpty then "-" else result
|
||||
|
||||
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
|
||||
|
||||
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
|
||||
|
||||
object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse a complete FEN string into a GameContext.
|
||||
* Returns Left with error message if the format is invalid. */
|
||||
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
|
||||
*/
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
val parts = fen.trim.split("\\s+")
|
||||
if parts.length != 6 then
|
||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
else
|
||||
for
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = activeColor,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassant,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
||||
private def parseCastling(s: String): Option[CastlingRights] =
|
||||
if s == "-" then
|
||||
Some(CastlingRights.None)
|
||||
if s == "-" then Some(CastlingRights.None)
|
||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||
Some(CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
))
|
||||
else
|
||||
None
|
||||
Some(
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
),
|
||||
)
|
||||
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. */
|
||||
/** 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
|
||||
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
|
||||
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. */
|
||||
/** 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
|
||||
|
||||
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
|
||||
case ((idx, true, acc), _) => (idx, true, acc)
|
||||
case ((idx, false, acc), c) =>
|
||||
if idx > 7 then (idx, true, acc)
|
||||
else if c.isDigit then (idx + c.asDigit, false, acc)
|
||||
else
|
||||
charToPiece(c) match
|
||||
case None => (idx, true, acc)
|
||||
case Some(piece) =>
|
||||
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
|
||||
if failed || fileIdx != 8 then None
|
||||
else Some(squares.toList)
|
||||
else Some(squares)
|
||||
|
||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
||||
private def charToPiece(c: Char): Option[Piece] =
|
||||
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
|
||||
case 'k' => Some(PieceType.King)
|
||||
case _ => None
|
||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import scala.util.parsing.combinator.RegexParsers
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
override val skipWhitespace: Boolean = false
|
||||
|
||||
// ── Piece character ──────────────────────────────────────────────────────
|
||||
|
||||
private def pieceChar: Parser[Piece] =
|
||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
|
||||
private def emptyCount: Parser[Int] =
|
||||
"[1-8]".r ^^ { s => s.toInt }
|
||||
|
||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
||||
|
||||
private def rankToken: Parser[RankToken] =
|
||||
pieceChar ^^ PieceToken.apply | emptyCount ^^ EmptyToken.apply
|
||||
|
||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
|
||||
* placement exceeds board bounds.
|
||||
*/
|
||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||
rankTokens >> { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => success(squares)
|
||||
case None => failure(s"Rank $rank is invalid")
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def rankSep: Parser[String] = "/"
|
||||
|
||||
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
|
||||
private def boardParser: Parser[Board] =
|
||||
rankParser(Rank.R8) ~
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def colorParser: Parser[Color] =
|
||||
("w" | "b") ^^ {
|
||||
case "w" => Color.White
|
||||
case _ => Color.Black
|
||||
}
|
||||
|
||||
// ── Castling parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def castlingParser: Parser[CastlingRights] =
|
||||
"-" ^^^ CastlingRights.None |
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser: Parser[Option[Square]] =
|
||||
"-" ^^^ Option.empty[Square] |
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def clockParser: Parser[Int] =
|
||||
"""\d+""".r ^^ { _.toInt }
|
||||
|
||||
// ── Full FEN parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def fenParser: Parser[GameContext] =
|
||||
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
parseAll(fenParser, fen) match
|
||||
case Success(ctx, _) => Right(ctx)
|
||||
case other => Left(s"Invalid FEN: ${other.toString}")
|
||||
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
parseAll(boardParser, fen) match
|
||||
case Success(board, _) => Some(board)
|
||||
case _ => None
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import fastparse.*
|
||||
import fastparse.NoWhitespace.*
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserFastParse extends GameContextImport:
|
||||
|
||||
// ── Low-level parsers ────────────────────────────────────────────────────
|
||||
|
||||
private def pieceChar(using P[Any]): P[Piece] =
|
||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
|
||||
private def emptyCount(using P[Any]): P[Int] =
|
||||
CharIn("1-8").!.map(_.toInt)
|
||||
|
||||
private def rankToken(using P[Any]): P[RankToken] =
|
||||
pieceChar.map(PieceToken.apply) | emptyCount.map(EmptyToken.apply)
|
||||
|
||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
||||
|
||||
private def rankParser(rank: Rank)(using P[Any]): P[List[(Square, Piece)]] =
|
||||
rankToken.rep(1).flatMap { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => Pass(squares)
|
||||
case None => Fail
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sep(using P[Any]): P[Unit] = LiteralStr("/").map(_ => ())
|
||||
|
||||
private def boardParser(using P[Any]): P[Board] =
|
||||
(rankParser(Rank.R8) ~ sep ~
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def colorParser(using P[Any]): P[Color] =
|
||||
(LiteralStr("w") | LiteralStr("b")).!.map {
|
||||
case "w" => Color.White
|
||||
case _ => Color.Black
|
||||
}
|
||||
|
||||
// ── Castling parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def castlingParser(using P[Any]): P[CastlingRights] =
|
||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
||||
LiteralStr("-").map(_ => Option.empty[Square]) |
|
||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def clockParser(using P[Any]): P[Int] =
|
||||
CharsWhileIn("0-9").!.map(_.toInt)
|
||||
|
||||
// ── Space helper ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sp(using P[Any]): P[Unit] = LiteralStr(" ").map(_ => ())
|
||||
|
||||
// ── Full FEN parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def fenParser(using P[Any]): P[GameContext] =
|
||||
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||
case (board, color, castling, ep, halfMove, _) =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
parse(fen, fenParser(using _)) match
|
||||
case Parsed.Success(ctx, _) => Right(ctx)
|
||||
case f: Parsed.Failure => Left(s"Invalid FEN: ${f.msg}")
|
||||
|
||||
private def boardParserFull(using P[Any]): P[Board] =
|
||||
boardParser ~ End
|
||||
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
parse(fen, boardParserFull(using _)) match
|
||||
case Parsed.Success(board, _) => Some(board)
|
||||
case _ => None
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
private[fen] object FenParserSupport:
|
||||
|
||||
sealed trait RankToken
|
||||
case class PieceToken(piece: Piece) extends RankToken
|
||||
case class EmptyToken(count: Int) extends RankToken
|
||||
|
||||
val charToPieceType: Map[Char, PieceType] = Map(
|
||||
'p' -> PieceType.Pawn,
|
||||
'r' -> PieceType.Rook,
|
||||
'n' -> PieceType.Knight,
|
||||
'b' -> PieceType.Bishop,
|
||||
'q' -> PieceType.Queen,
|
||||
'k' -> PieceType.King,
|
||||
)
|
||||
|
||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||
tokens
|
||||
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import com.fasterxml.jackson.databind.{ObjectMapper, SerializationFeature}
|
||||
import com.fasterxml.jackson.core.util.{DefaultIndenter, DefaultPrettyPrinter}
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
import java.time.{LocalDate, ZoneId, ZonedDateTime}
|
||||
|
||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
object JsonExporter extends GameContextExport:
|
||||
private val mapper = createMapper()
|
||||
|
||||
private def createMapper(): ObjectMapper =
|
||||
val mapper = new ObjectMapper()
|
||||
.registerModule(DefaultScalaModule)
|
||||
|
||||
// Configure pretty printer with custom spacing to match test expectations
|
||||
val indenter = new DefaultIndenter(" ", "\n")
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
printer.indentArraysWith(indenter)
|
||||
printer.indentObjectsWith(indenter)
|
||||
|
||||
mapper.setDefaultPrettyPrinter(printer)
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
mapper
|
||||
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val record = buildGameRecord(context)
|
||||
formatJson(mapper.writeValueAsString(record))
|
||||
|
||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||
val pgn =
|
||||
try
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
JsonGameRecord(
|
||||
metadata = Some(buildMetadata()),
|
||||
gameState = Some(buildGameState(context)),
|
||||
moveHistory = pgn,
|
||||
moves = Some(buildMoves(context.moves)),
|
||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
|
||||
)
|
||||
|
||||
private def buildMetadata(): JsonMetadata =
|
||||
JsonMetadata(
|
||||
event = Some("Game"),
|
||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||
date = Some(LocalDate.now().toString),
|
||||
result = Some("*"),
|
||||
)
|
||||
|
||||
private def buildGameState(context: GameContext): JsonGameState =
|
||||
JsonGameState(
|
||||
board = Some(buildBoardPieces(context.board)),
|
||||
turn = Some(context.turn.label),
|
||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||
halfMoveClock = Some(context.halfMoveClock),
|
||||
)
|
||||
|
||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||
board.pieces.toList.map { case (sq, p) =>
|
||||
JsonPiece(Some(sq.toString), Some(p.color.label), Some(p.pieceType.label))
|
||||
}
|
||||
|
||||
private def buildCastlingRights(rights: CastlingRights): JsonCastlingRights =
|
||||
JsonCastlingRights(
|
||||
Some(rights.whiteKingSide),
|
||||
Some(rights.whiteQueenSide),
|
||||
Some(rights.blackKingSide),
|
||||
Some(rights.blackQueenSide),
|
||||
)
|
||||
|
||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||
moves.map { m =>
|
||||
val moveType = convertMoveType(m.moveType)
|
||||
JsonMove(Some(m.from.toString), Some(m.to.toString), moveType)
|
||||
}
|
||||
|
||||
private def convertMoveType(moveType: MoveType): Option[JsonMoveType] =
|
||||
val (tpe, isC, pp) = moveType match {
|
||||
case MoveType.Normal(isCapture) =>
|
||||
(Some("normal"), Some(isCapture), None)
|
||||
case MoveType.CastleKingside =>
|
||||
(Some("castleKingside"), None, None)
|
||||
case MoveType.CastleQueenside =>
|
||||
(Some("castleQueenside"), None, None)
|
||||
case MoveType.EnPassant =>
|
||||
(Some("enPassant"), Some(true), None)
|
||||
case MoveType.Promotion(piece) =>
|
||||
val pName = piece match {
|
||||
case PromotionPiece.Queen => "queen"
|
||||
case PromotionPiece.Rook => "rook"
|
||||
case PromotionPiece.Bishop => "bishop"
|
||||
case PromotionPiece.Knight => "knight"
|
||||
}
|
||||
(Some("promotion"), None, Some(pName))
|
||||
}
|
||||
Some(JsonMoveType(tpe, isC, pp))
|
||||
|
||||
private def buildCapturedPieces(board: Board): JsonCapturedPieces =
|
||||
val (byWhite, byBlack) = getCapturedPieces(board)
|
||||
JsonCapturedPieces(Some(byWhite), Some(byBlack))
|
||||
|
||||
private def formatJson(json: String): String =
|
||||
json
|
||||
.replace(" : ", ": ")
|
||||
.replaceAll("\\[\\s*\\]", "[]")
|
||||
.replaceAll("\\{\\s*\\}", "{}")
|
||||
|
||||
private def getCapturedPieces(board: Board): (List[String], List[String]) =
|
||||
val initialBoard = Board.initial
|
||||
val captured = Square.all.flatMap { square =>
|
||||
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||
board.pieceAt(square) match
|
||||
case None => Some(initialPiece)
|
||||
case Some(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||
(blackCaptured, whiteCaptured)
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
case class JsonMetadata(
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonPiece(
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonCastlingRights(
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None,
|
||||
)
|
||||
|
||||
case class JsonGameState(
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None,
|
||||
)
|
||||
|
||||
case class JsonCapturedPieces(
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None,
|
||||
)
|
||||
|
||||
case class JsonMoveType(
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonMove(
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None,
|
||||
)
|
||||
|
||||
case class JsonGameRecord(
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import scala.util.Try
|
||||
|
||||
/** Imports a GameContext from JSON format using Jackson.
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
object JsonParser extends GameContextImport:
|
||||
|
||||
private val mapper = new ObjectMapper()
|
||||
.registerModule(DefaultScalaModule)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||
.map(e => "JSON parsing error: " + e.getMessage)
|
||||
.flatMap { data =>
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val rawBoard = gs.board.getOrElse(Nil)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawMoves = data.moves.getOrElse(Nil)
|
||||
|
||||
for
|
||||
board <- parseBoard(rawBoard)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||
moves <- parseMoves(rawMoves)
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = turn,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = rawHmc,
|
||||
moves = moves,
|
||||
)
|
||||
}
|
||||
|
||||
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||
val parsedPieces = pieces.flatMap { p =>
|
||||
for
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
color <- p.color.flatMap(parseColor)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
yield (sq, Piece(color, pt))
|
||||
}
|
||||
Right(Board(parsedPieces.toMap))
|
||||
|
||||
private def parseTurn(color: String): Either[String, Color] =
|
||||
parseColor(color).toRight(s"Invalid turn color: $color")
|
||||
|
||||
private def parseColor(color: String): Option[Color] =
|
||||
if color == "White" then Some(Color.White)
|
||||
else if color == "Black" then Some(Color.Black)
|
||||
else None
|
||||
|
||||
private def parsePieceType(pt: String): Option[PieceType] =
|
||||
pt match
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Knight" => Some(PieceType.Knight)
|
||||
case "Bishop" => Some(PieceType.Bishop)
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||
CastlingRights(
|
||||
cr.whiteKingSide.getOrElse(false),
|
||||
cr.whiteQueenSide.getOrElse(false),
|
||||
cr.blackKingSide.getOrElse(false),
|
||||
cr.blackQueenSide.getOrElse(false),
|
||||
)
|
||||
|
||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||
Right(moves.flatMap { m =>
|
||||
for
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
moveType <- m.`type`.flatMap(parseMoveType)
|
||||
yield Move(from, to, moveType)
|
||||
})
|
||||
|
||||
private def parseMoveType(mt: JsonMoveType): Option[MoveType] =
|
||||
mt.`type` match
|
||||
case Some("normal") =>
|
||||
Some(MoveType.Normal(mt.isCapture.getOrElse(false)))
|
||||
case Some("castleKingside") =>
|
||||
Some(MoveType.CastleKingside)
|
||||
case Some("castleQueenside") =>
|
||||
Some(MoveType.CastleQueenside)
|
||||
case Some("enPassant") =>
|
||||
Some(MoveType.EnPassant)
|
||||
case Some("promotion") =>
|
||||
val piece = mt.promotionPiece match
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("bishop") => PromotionPiece.Bishop
|
||||
case Some("knight") => PromotionPiece.Knight
|
||||
case _ => PromotionPiece.Queen // default
|
||||
Some(MoveType.Promotion(piece))
|
||||
case _ => None
|
||||
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*"
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*",
|
||||
)
|
||||
|
||||
exportGame(headers, context.moves)
|
||||
|
||||
/** Export a game with headers and moves to PGN format. */
|
||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if moves.isEmpty then ""
|
||||
else
|
||||
var ctx = GameContext.initial
|
||||
val sanMoves = moves.map { move =>
|
||||
val algebraic = moveToAlgebraic(move, ctx.board)
|
||||
ctx = DefaultRules.applyMove(ctx)(move)
|
||||
algebraic
|
||||
val headerLines = headers
|
||||
.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}
|
||||
.mkString("\n")
|
||||
|
||||
val groupedMoves = sanMoves.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(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
val moveText =
|
||||
if moves.isEmpty then ""
|
||||
else
|
||||
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
|
||||
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
val groupedMoves = sanMoves.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(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._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
|
||||
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
case MoveType.Promotion(pp) =>
|
||||
case MoveType.Promotion(pp) =>
|
||||
val promSuffix = pp match
|
||||
case PromotionPiece.Queen => "=Q"
|
||||
case PromotionPiece.Rook => "=R"
|
||||
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
|
||||
case PieceType.Rook => s"R$capStr$dest"
|
||||
case PieceType.Queen => s"Q$capStr$dest"
|
||||
case PieceType.King => s"K$capStr$dest"
|
||||
|
||||
|
||||
|
||||
@@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
/** A parsed PGN game containing headers and the resolved move list. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[Move]
|
||||
headers: Map[String, String],
|
||||
moves: List[Move],
|
||||
)
|
||||
|
||||
object PgnParser extends GameContextImport:
|
||||
|
||||
/** 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. */
|
||||
/** 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 lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
|
||||
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
|
||||
* issue.
|
||||
*/
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||
}
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
/** 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 lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
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"]. */
|
||||
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
|
||||
private def parseMovesText(moveText: String): List[Move] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
val (_, _, moves) = tokens.foldLeft(
|
||||
(GameContext.initial, Color.White, List.empty[Move])
|
||||
(GameContext.initial, Color.White, List.empty[Move]),
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case None => state
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
(nextCtx, color.opposite, acc :+ move)
|
||||
moves
|
||||
|
||||
/** 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"
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(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 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
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val promotion = extractPromotion(notation)
|
||||
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board.pieceAt(move.from).exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType)
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board
|
||||
.pieceAt(move.from)
|
||||
.exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType),
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
|
||||
candidates.headOption
|
||||
candidates.headOption
|
||||
|
||||
/** 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
|
||||
else true,
|
||||
)
|
||||
|
||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||
promotion match
|
||||
case None => move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case None =>
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
||||
case (acc, token) =>
|
||||
tokens
|
||||
.foldLeft(
|
||||
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
|
||||
) { case (acc, token) =>
|
||||
acc.flatMap { case (ctx, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
}
|
||||
.map(_._3)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||
import java.nio.file.{Files, Paths}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.util.Using
|
||||
|
||||
class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("saveGameToFile: writes JSON file successfully") {
|
||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||
try
|
||||
val context = GameContext.initial
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(Files.exists(tmpFile))
|
||||
assert(Files.size(tmpFile) > 0)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: reads JSON file successfully") {
|
||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||
try
|
||||
val originalContext = GameContext.initial
|
||||
|
||||
// Save
|
||||
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
|
||||
|
||||
// Load
|
||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
assert(result.isRight)
|
||||
val loaded = result.getOrElse(GameContext.initial)
|
||||
assert(loaded == originalContext)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: returns error on missing file") {
|
||||
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
|
||||
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
||||
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("saveGameToFile: persists game with moves") {
|
||||
val tmpFile = Files.createTempFile("chess_test_moves_", ".json")
|
||||
try
|
||||
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||
val context = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
|
||||
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
assert(saveResult.isRight)
|
||||
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: overwrites existing file") {
|
||||
val tmpFile = Files.createTempFile("chess_test_overwrite_", ".json")
|
||||
try
|
||||
// Write first file
|
||||
val context1 = GameContext.initial
|
||||
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
|
||||
val size1 = Files.size(tmpFile)
|
||||
|
||||
// Write second file (should overwrite)
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context2 = GameContext.initial.withMove(move)
|
||||
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
||||
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 1)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: handles invalid JSON in file") {
|
||||
val tmpFile = Files.createTempFile("chess_test_invalid_", ".json")
|
||||
try
|
||||
Files.write(tmpFile, "{ invalid json}".getBytes())
|
||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
assert(result.isLeft)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("round-trip: save and load preserves game state") {
|
||||
val tmpFile = Files.createTempFile("chess_test_roundtrip_", ".json")
|
||||
try
|
||||
val move1 = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R4))
|
||||
val move2 = Move(Square(File.H, Rank.R7), Square(File.H, Rank.R5))
|
||||
val original = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
.withHalfMoveClock(3)
|
||||
|
||||
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
assert(loaded.halfMoveClock == 3)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: handles exporter that throws exception") {
|
||||
val tmpFile = Files.createTempFile("chess_test_exporter_error_", ".json")
|
||||
try
|
||||
val context = GameContext.initial
|
||||
val faultyExporter = new GameContextExport {
|
||||
def exportGameContext(c: GameContext): String =
|
||||
throw new RuntimeException("Export failed") // scalafix:ok DisableSyntax.throw
|
||||
}
|
||||
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
@@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers
|
||||
class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def context(
|
||||
piecePlacement: String,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moveCount: Int
|
||||
piecePlacement: String,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moveCount: Int,
|
||||
): GameContext =
|
||||
val board = FenParser.parseBoard(piecePlacement).getOrElse(
|
||||
fail(s"Invalid test board FEN: $piecePlacement")
|
||||
)
|
||||
val board = FenParser
|
||||
.parseBoard(piecePlacement)
|
||||
.getOrElse(
|
||||
fail(s"Invalid test board FEN: $piecePlacement"),
|
||||
)
|
||||
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
||||
GameContext(
|
||||
board = board,
|
||||
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.fill(moveCount)(dummyMove)
|
||||
moves = List.fill(moveCount)(dummyMove),
|
||||
)
|
||||
|
||||
test("exportGameContextToFen handles initial and typical developed position"):
|
||||
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
moveCount = 0,
|
||||
)
|
||||
FenExporter.gameContextToFen(gameContext) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||
@@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = CastlingRights.None,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
moveCount = 0,
|
||||
)
|
||||
FenExporter.gameContextToFen(noCastling) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 5,
|
||||
moveCount = 4
|
||||
moveCount = 4,
|
||||
)
|
||||
FenExporter.gameContextToFen(partialCastling) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
||||
@@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
||||
halfMoveClock = 2,
|
||||
moveCount = 4
|
||||
moveCount = 4,
|
||||
)
|
||||
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
||||
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||
@@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 42,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
val fen = FenExporter.gameContextToFen(gameContext)
|
||||
FenParser.parseFen(fen) match
|
||||
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
val ctx = GameContext.initial
|
||||
|
||||
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
|
||||
Some(Piece.WhitePawn),
|
||||
)
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(
|
||||
Some(Piece.BlackKing),
|
||||
)
|
||||
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(
|
||||
Some(Piece.BlackKing),
|
||||
)
|
||||
|
||||
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
|
||||
|
||||
test("importGameContext returns Right for valid and Left for invalid FEN"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
FenParserCombinators.importGameContext(fen).isRight shouldBe true
|
||||
FenParserCombinators.importGameContext("invalid fen string").isLeft shouldBe true
|
||||
|
||||
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||
FenParserCombinators.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
test("parseBoard rejects ranks that overflow via multiple tokens"):
|
||||
// EmptyToken overflow: piece then 8 empties = 9 total
|
||||
FenParserCombinators.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
|
||||
// fold short-circuit: 8 empties followed by two pieces = 10 total, exercises the None-propagation path
|
||||
FenParserCombinators.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||
@@ -0,0 +1,174 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
FenParserFastParse.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||
FenParserFastParse.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
FenParserFastParse.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
|
||||
|
||||
test("importGameContext returns Right for valid and Left for invalid FEN"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
FenParserFastParse.importGameContext(fen).isRight shouldBe true
|
||||
FenParserFastParse.importGameContext("invalid fen string").isLeft shouldBe true
|
||||
|
||||
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||
FenParserFastParse.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
test("parseBoard rejects ranks that overflow via multiple tokens"):
|
||||
FenParserFastParse.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||
|
||||
test("parseFen handles all individual castling rights"):
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w K - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.castlingRights.whiteQueenSide shouldBe false
|
||||
ctx.castlingRights.blackKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Q - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteQueenSide shouldBe true
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w k - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.blackKingSide shouldBe true
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w q - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.blackQueenSide shouldBe true
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
)
|
||||
|
||||
test("parseFen parses all en passant squares"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - a3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.A, Rank.R3))
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - h6 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.H, Rank.R6))
|
||||
)
|
||||
|
||||
test("parseFen parses different halfMove and fullMove clocks"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 5 10").fold(_ => fail(), ctx =>
|
||||
ctx.halfMoveClock shouldBe 5
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 100").fold(_ => fail(), ctx =>
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
|
||||
test("parseBoard parses boards with mixed empty and piece tokens"):
|
||||
val mixed = "8/1p1p1p1p/8/1P1P1P1P/8/8/8/8"
|
||||
FenParserFastParse.parseBoard(mixed) should not be empty
|
||||
|
||||
test("parseFen handles turn transitions"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid piece characters"):
|
||||
FenParserFastParse.parseFen("8x/8/8/8/8/8/8/8 w - - 0 1").isLeft shouldBe true
|
||||
|
||||
test("parseFen rejects incomplete FEN strings"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - -").isLeft shouldBe true
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w").isLeft shouldBe true
|
||||
|
||||
test("parseBoard tests all piece types in various positions"):
|
||||
// Test each piece type: pawn, rook, knight, bishop, queen, king (both colors)
|
||||
val allPieces = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val parsed = FenParserFastParse.parseBoard(allPieces)
|
||||
parsed.map(_.pieces.size) shouldBe Some(32)
|
||||
parsed.map(_.pieceAt(Square(File.A, Rank.R8))) shouldBe Some(Some(Piece.BlackRook))
|
||||
parsed.map(_.pieceAt(Square(File.B, Rank.R8))) shouldBe Some(Some(Piece.BlackKnight))
|
||||
parsed.map(_.pieceAt(Square(File.C, Rank.R8))) shouldBe Some(Some(Piece.BlackBishop))
|
||||
parsed.map(_.pieceAt(Square(File.D, Rank.R8))) shouldBe Some(Some(Piece.BlackQueen))
|
||||
parsed.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
test("parseBoard tests all empty counts from 1 to 8"):
|
||||
FenParserFastParse.parseBoard("1p6/2p5/3p4/4p3/5p2/6p1/7p/8") should not be empty
|
||||
FenParserFastParse.parseBoard("8/1p6/2p5/3p4/4p3/5p2/6p1/7p") should not be empty
|
||||
|
||||
test("parseFen tests all valid colors"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 1").fold(_ => fail(), _.turn shouldBe Color.White)
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), _.turn shouldBe Color.Black)
|
||||
|
||||
test("parseFen tests all castling combinations"):
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.castlingRights.whiteQueenSide shouldBe true
|
||||
ctx.castlingRights.blackKingSide shouldBe true
|
||||
ctx.castlingRights.blackQueenSide shouldBe true
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w Kq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.castlingRights.whiteQueenSide shouldBe false
|
||||
ctx.castlingRights.blackKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe true
|
||||
)
|
||||
|
||||
test("parseFen tests all en passant files"):
|
||||
for file <- Seq("a", "b", "c", "d", "e", "f", "g", "h") do
|
||||
FenParserFastParse.parseFen(s"8/8/8/8/8/8/8/8 w - ${file}3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.enPassantSquare should not be empty
|
||||
)
|
||||
|
||||
test("parseBoard with mixed pieces and empty squares"):
|
||||
FenParserFastParse.parseBoard("r1bqkb1r/pppppppp/2n2n2/8/8/2N2N2/PPPPPPPP/R1BQKB1R") should not be empty
|
||||
@@ -8,7 +8,7 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
@@ -20,22 +20,34 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
||||
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
@@ -53,3 +65,9 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
||||
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
test("parseBoard rejects rank strings with invalid character followed by more characters"):
|
||||
FenParser.parseBoard("3X3p/8/8/8/8/8/8/8") shouldBe None
|
||||
|
||||
test("parseFen rejects invalid move counts"):
|
||||
FenParser.parseFen("8/8/8/8/8/8/8/8 w - - -1 1").isLeft shouldBe true
|
||||
FenParser.parseFen("8/8/8/8/8/8/8/8 w - - 0 0").isLeft shouldBe true
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export all promotion pieces separately for full branch coverage") {
|
||||
val promotions = List(
|
||||
(PromotionPiece.Queen, "queen"),
|
||||
(PromotionPiece.Rook, "rook"),
|
||||
(PromotionPiece.Bishop, "bishop"),
|
||||
(PromotionPiece.Knight, "knight"),
|
||||
)
|
||||
|
||||
for (piece, expectedName) <- promotions do
|
||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||
// Empty boards can cause issues in PgnExporter, using initial
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include(s""""$expectedName"""")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export normal non-capture move") {
|
||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
}
|
||||
|
||||
test("export normal capture move manually") {
|
||||
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export all move type categories") {
|
||||
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
}
|
||||
|
||||
test("export castle queenside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleQueenside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle kingside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleKingside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export en passant move manually") {
|
||||
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"enPassant\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: exports initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"metadata\"")
|
||||
json should include("\"gameState\"")
|
||||
json should include("\"moveHistory\"")
|
||||
json should include("\"capturedPieces\"")
|
||||
json should include("\"timestamp\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: includes board pieces") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"a1\"")
|
||||
json should include("\"Rook\"")
|
||||
json should include("\"White\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: includes turn information") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"turn\": \"White\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: includes castling rights") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": true")
|
||||
json should include("\"whiteQueenSide\": true")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports with moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
json should include("\"e2\"")
|
||||
json should include("\"e4\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: valid JSON structure") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should startWith("{")
|
||||
json should endWith("}")
|
||||
json should include("\"metadata\": {")
|
||||
json should include("\"gameState\": {")
|
||||
}
|
||||
|
||||
test("exportGameContext: empty move history for initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports en passant square") {
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": \"e3\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports null en passant square") {
|
||||
val context = GameContext.initial.copy(enPassantSquare = None)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": null")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports different move destinations") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports empty board") {
|
||||
val emptyBoard = Board(Map.empty)
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"board\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": false")
|
||||
json should include("\"whiteQueenSide\": false")
|
||||
json should include("\"blackKingSide\": false")
|
||||
json should include("\"blackQueenSide\": false")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("JsonMetadata with all fields") {
|
||||
val meta = JsonMetadata(Some("Event"), Some(Map("a" -> "b")), Some("2026-04-08"), Some("1-0"))
|
||||
assert(meta.event.contains("Event"))
|
||||
assert(meta.players.exists(_.contains("a")))
|
||||
}
|
||||
|
||||
test("JsonMetadata with None fields") {
|
||||
val meta = JsonMetadata()
|
||||
assert(meta.event.isEmpty)
|
||||
assert(meta.players.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonPiece with square and piece") {
|
||||
val piece = JsonPiece(Some("e4"), Some("White"), Some("Pawn"))
|
||||
assert(piece.square.contains("e4"))
|
||||
assert(piece.color.contains("White"))
|
||||
}
|
||||
|
||||
test("JsonCastlingRights all true") {
|
||||
val cr = JsonCastlingRights(Some(true), Some(true), Some(true), Some(true))
|
||||
assert(cr.whiteKingSide.contains(true))
|
||||
assert(cr.blackQueenSide.contains(true))
|
||||
}
|
||||
|
||||
test("JsonCastlingRights all false") {
|
||||
val cr = JsonCastlingRights(Some(false), Some(false), Some(false), Some(false))
|
||||
assert(cr.whiteKingSide.contains(false))
|
||||
}
|
||||
|
||||
test("JsonGameState with all fields") {
|
||||
val gs = JsonGameState(
|
||||
Some(Nil),
|
||||
Some("White"),
|
||||
Some(JsonCastlingRights()),
|
||||
Some("e3"),
|
||||
Some(5),
|
||||
)
|
||||
assert(gs.board.contains(Nil))
|
||||
assert(gs.halfMoveClock.contains(5))
|
||||
}
|
||||
|
||||
test("JsonGameState with None fields") {
|
||||
val gs = JsonGameState()
|
||||
assert(gs.board.isEmpty)
|
||||
assert(gs.halfMoveClock.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonCapturedPieces with pieces") {
|
||||
val cp = JsonCapturedPieces(Some(List("Pawn")), Some(List("Knight")))
|
||||
assert(cp.byWhite.exists(_.contains("Pawn")))
|
||||
assert(cp.byBlack.exists(_.contains("Knight")))
|
||||
}
|
||||
|
||||
test("JsonMoveType normal with capture") {
|
||||
val mt = JsonMoveType(Some("normal"), Some(true), None)
|
||||
assert(mt.`type`.contains("normal"))
|
||||
assert(mt.isCapture.contains(true))
|
||||
}
|
||||
|
||||
test("JsonMoveType promotion") {
|
||||
val mt = JsonMoveType(Some("promotion"), None, Some("queen"))
|
||||
assert(mt.`type`.contains("promotion"))
|
||||
assert(mt.promotionPiece.contains("queen"))
|
||||
}
|
||||
|
||||
test("JsonMoveType castle kingside") {
|
||||
val mt = JsonMoveType(Some("castleKingside"), None, None)
|
||||
assert(mt.`type`.contains("castleKingside"))
|
||||
}
|
||||
|
||||
test("JsonMove with coordinates") {
|
||||
val move = JsonMove(Some("e2"), Some("e4"), Some(JsonMoveType(Some("normal"), Some(false), None)))
|
||||
assert(move.from.contains("e2"))
|
||||
assert(move.to.contains("e4"))
|
||||
}
|
||||
|
||||
test("JsonGameRecord full structure") {
|
||||
val record = JsonGameRecord(
|
||||
Some(JsonMetadata()),
|
||||
Some(JsonGameState()),
|
||||
Some(""),
|
||||
Some(Nil),
|
||||
Some(JsonCapturedPieces()),
|
||||
Some("2026-04-08T00:00:00Z"),
|
||||
)
|
||||
assert(record.metadata.nonEmpty)
|
||||
assert(record.timestamp.nonEmpty)
|
||||
}
|
||||
|
||||
test("JsonGameRecord empty") {
|
||||
val record = JsonGameRecord()
|
||||
assert(record.metadata.isEmpty)
|
||||
assert(record.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonPiece with no fields") {
|
||||
val piece = JsonPiece()
|
||||
assert(piece.square.isEmpty)
|
||||
assert(piece.color.isEmpty)
|
||||
assert(piece.piece.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonMoveType with no fields") {
|
||||
val mt = JsonMoveType()
|
||||
assert(mt.`type`.isEmpty)
|
||||
assert(mt.isCapture.isEmpty)
|
||||
assert(mt.promotionPiece.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonMove with empty fields") {
|
||||
val move = JsonMove()
|
||||
assert(move.from.isEmpty)
|
||||
assert(move.to.isEmpty)
|
||||
assert(move.`type`.isEmpty)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, PieceType}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse invalid turn color returns error") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "Invalid", "board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Invalid turn color"))
|
||||
}
|
||||
|
||||
test("parse invalid piece type filters it out") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "InvalidPiece"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid color in board filters piece") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "InvalidColor", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing turn uses default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.turn == Color.White)
|
||||
}
|
||||
|
||||
test("parse with missing board uses empty") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White"},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing moves uses empty list") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid square in board filters it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "invalid99", "color": "White", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse all valid piece types") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Pawn"},
|
||||
{"square": "b1", "color": "White", "piece": "Knight"},
|
||||
{"square": "c1", "color": "White", "piece": "Bishop"},
|
||||
{"square": "d1", "color": "White", "piece": "Rook"},
|
||||
{"square": "e1", "color": "White", "piece": "Queen"},
|
||||
{"square": "f1", "color": "White", "piece": "King"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.size == 6)
|
||||
assert(
|
||||
ctx.board
|
||||
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||
.get
|
||||
.pieceType == PieceType.Pawn,
|
||||
)
|
||||
}
|
||||
|
||||
test("parse with all castling rights false") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [],
|
||||
"castlingRights": {
|
||||
"whiteKingSide": false,
|
||||
"whiteQueenSide": false,
|
||||
"blackKingSide": false,
|
||||
"blackQueenSide": false
|
||||
}
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.castlingRights.whiteKingSide == false)
|
||||
assert(ctx.castlingRights.blackQueenSide == false)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse completely invalid JSON returns error") {
|
||||
val invalidJson = "{ this is not valid json at all }"
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse empty string returns error") {
|
||||
val result = JsonParser.importGameContext("")
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse number value returns error") {
|
||||
val result = JsonParser.importGameContext("123")
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse malformed JSON object returns error") {
|
||||
val malformed = """{"metadata": {"unclosed": """
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse invalid JSON array returns error") {
|
||||
val invalidArray = "[1, 2, 3"
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse JSON with missing required fields") {
|
||||
val json = """{"metadata": {}}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Should still succeed because all fields have defaults
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse valid JSON with invalid turn falls back to default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse all move type variations") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "result": "*"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [
|
||||
{"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}},
|
||||
{"from": "e1", "to": "g1", "type": {"type": "castleKingside"}},
|
||||
{"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}},
|
||||
{"from": "e5", "to": "d4", "type": {"type": "enPassant"}},
|
||||
{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}},
|
||||
{"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}},
|
||||
{"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}},
|
||||
{"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}}
|
||||
]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.length == 8)
|
||||
assert(ctx.moves(0).moveType == MoveType.Normal(false))
|
||||
assert(ctx.moves(1).moveType == MoveType.CastleKingside)
|
||||
assert(ctx.moves(2).moveType == MoveType.CastleQueenside)
|
||||
assert(ctx.moves(3).moveType == MoveType.EnPassant)
|
||||
}
|
||||
|
||||
test("parse invalid move type defaults to None") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Invalid move type is skipped, so moves list should be empty
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse promotion with default piece") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Invalid promotion piece should use default
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse move with missing from/to skips it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
// Invalid square should be filtered out
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with invalid JSON returns error") {
|
||||
val json = """{"invalid json"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse normal move with isCapture true") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
val move = ctx.moves.head
|
||||
assert(move.moveType == MoveType.Normal(true))
|
||||
}
|
||||
|
||||
test("parse board with invalid pieces filters them") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Rook"},
|
||||
{"square": "invalid", "color": "White", "piece": "King"},
|
||||
{"square": "a2", "color": "Invalid", "piece": "Pawn"}
|
||||
]
|
||||
}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
// Only valid piece should be in board
|
||||
assert(ctx.board.pieces.size == 1)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: parses valid JSON") {
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: restores board state") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result == Right(context))
|
||||
}
|
||||
|
||||
test("importGameContext: restores turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: restores moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles empty board") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||
"gameState": {
|
||||
"board": [],
|
||||
"turn": "White",
|
||||
"castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true},
|
||||
"enPassantSquare": null,
|
||||
"halfMoveClock": 0
|
||||
},
|
||||
"moves": [],
|
||||
"moveHistory": "",
|
||||
"capturedPieces": {"byWhite": [], "byBlack": []},
|
||||
"timestamp": "2026-04-06T00:00:00Z"
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||
}
|
||||
|
||||
test("importGameContext: returns error on invalid JSON") {
|
||||
val result = JsonParser.importGameContext("not valid json {{{")
|
||||
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("importGameContext: handles missing fields with defaults") {
|
||||
val json =
|
||||
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: handles castling rights") {
|
||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||
}
|
||||
|
||||
test("importGameContext: round-trip consistency") {
|
||||
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||
val context = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
.withTurn(Color.White)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val restored = JsonParser.importGameContext(json)
|
||||
|
||||
assert(restored.map(_.moves.length) == Right(2))
|
||||
assert(restored.map(_.turn) == Right(Color.White))
|
||||
}
|
||||
|
||||
test("importGameContext: handles half-move clock") {
|
||||
val context = GameContext.initial.withHalfMoveClock(5)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.halfMoveClock) == Right(5))
|
||||
}
|
||||
|
||||
test("importGameContext: parses en passant square") {
|
||||
// Create a context with en passant square
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||
}
|
||||
|
||||
test("importGameContext: handles black turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||
// Use simple move without explicit moveType to let system handle it
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||
}
|
||||
|
||||
test("importGameContext: handles mixed castling rights") {
|
||||
val mixed = CastlingRights(true, false, false, true)
|
||||
val context = GameContext.initial.withCastlingRights(mixed)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(mixed))
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGame renders headers and basic move text"):
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
|
||||
emptyPgn.contains("[Event \"Test\"]") shouldBe true
|
||||
emptyPgn.contains("[White \"A\"]") shouldBe true
|
||||
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
||||
|
||||
test("exportGame renders castling grouping and result markers"):
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
|
||||
PgnExporter.exportGame(
|
||||
Map("Event" -> "Test"),
|
||||
List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)),
|
||||
) should include("O-O")
|
||||
PgnExporter.exportGame(
|
||||
Map("Event" -> "Test"),
|
||||
List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)),
|
||||
) should include("O-O-O")
|
||||
|
||||
val seq = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
|
||||
)
|
||||
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
||||
grouped should include("1. e4 c5")
|
||||
@@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGame handles promotion suffixes and normal move formatting"):
|
||||
List(
|
||||
PromotionPiece.Queen -> "=Q",
|
||||
PromotionPiece.Rook -> "=R",
|
||||
PromotionPiece.Queen -> "=Q",
|
||||
PromotionPiece.Rook -> "=R",
|
||||
PromotionPiece.Bishop -> "=B",
|
||||
PromotionPiece.Knight -> "=N"
|
||||
PromotionPiece.Knight -> "=N",
|
||||
).foreach { (piece, suffix) =>
|
||||
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
||||
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
|
||||
}
|
||||
|
||||
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
val normal =
|
||||
PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
normal should include("e4")
|
||||
normal should not include "="
|
||||
|
||||
test("exportGameContext preserves moves and default headers"):
|
||||
val moves = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
|
||||
)
|
||||
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
||||
withMoves.contains("e4") shouldBe true
|
||||
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
Move(sq("c7"), sq("c6")),
|
||||
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
|
||||
)
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||
@@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
pgn should include("Kxe2")
|
||||
|
||||
test("exportGame emits en-passant and promotion capture notation"):
|
||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
||||
|
||||
pgn should include("exd3")
|
||||
pgn should include("exf8=Q")
|
||||
pawnCapturePgn should include("exd3")
|
||||
quietPromotionPgn should include("e8=Q")
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
|
||||
val headerOnly = """[Event "Test Game"]
|
||||
val headerOnly = """[Event "Test Game"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]"""
|
||||
@@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
capture.map(_.moves.length) shouldBe Some(3)
|
||||
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||
|
||||
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
|
||||
val whiteKs = PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""")
|
||||
.get
|
||||
.moves
|
||||
.last
|
||||
whiteKs.moveType shouldBe MoveType.CastleKingside
|
||||
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
||||
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
||||
|
||||
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
|
||||
val whiteQs = PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""")
|
||||
.get
|
||||
.moves
|
||||
.last
|
||||
whiteQs.moveType shouldBe MoveType.CastleQueenside
|
||||
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
||||
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
||||
|
||||
val blackKs = PgnParser.parsePgn("""[Event "Test"]
|
||||
val blackKs = PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
|
||||
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""")
|
||||
.get
|
||||
.moves
|
||||
.last
|
||||
blackKs.moveType shouldBe MoveType.CastleKingside
|
||||
blackKs.from shouldBe Square(File.E, Rank.R8)
|
||||
|
||||
val blackQs = PgnParser.parsePgn("""[Event "Test"]
|
||||
val blackQs = PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""")
|
||||
.get
|
||||
.moves
|
||||
.last
|
||||
blackQs.moveType shouldBe MoveType.CastleQueenside
|
||||
blackQs.from shouldBe Square(File.E, Rank.R8)
|
||||
blackQs.to shouldBe Square(File.C, Rank.R8)
|
||||
|
||||
PgnParser.parsePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
|
||||
PgnParser.parsePgn("""[Event "Test"]
|
||||
1. e4 e5 1-0""")
|
||||
.map(_.moves.length) shouldBe Some(2)
|
||||
PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
|
||||
1. e4 INVALID e5""")
|
||||
.map(_.moves.length) shouldBe Some(2)
|
||||
|
||||
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
||||
val board = Board.initial
|
||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
|
||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||
File.E,
|
||||
Rank.R4,
|
||||
)
|
||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||
File.F,
|
||||
Rank.R3,
|
||||
)
|
||||
|
||||
val rookPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
val rankPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White)
|
||||
.get
|
||||
.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White)
|
||||
.get
|
||||
.from shouldBe Square(File.A, Rank.R1)
|
||||
|
||||
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||
king.isDefined shouldBe true
|
||||
king.get.from shouldBe Square(File.E, Rank.R1)
|
||||
king.get.to shouldBe Square(File.E, Rank.R2)
|
||||
|
||||
test("parseAlgebraicMove handles all promotion targets"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
|
||||
test("importGameContext accepts valid and empty PGN"):
|
||||
val pgn = """[Event "Test"]
|
||||
@@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
||||
|
||||
test("parseAlgebraicMove rejects notation with invalid promotion piece"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||
val context = GameContext.initial.withBoard(board)
|
||||
|
||||
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
||||
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||
|
||||
parsed.map(_.moves.size) shouldBe Some(2)
|
||||
|
||||
|
||||
@@ -37,17 +37,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
||||
|
||||
test("validatePgn rejects impossible illegal and garbage tokens"):
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. Qd4
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. O-O
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. e4 GARBAGE e5
|
||||
""").isLeft shouldBe true
|
||||
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
test("validatePgn accepts empty move text and minimal valid header"):
|
||||
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
||||
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=2
|
||||
MINOR=6
|
||||
PATCH=0
|
||||
|
||||
@@ -1,2 +1,36 @@
|
||||
## (2026-04-06)
|
||||
## (2026-04-07)
|
||||
## (2026-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
## (2026-04-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
## (2026-04-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
## (2026-04-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
## (2026-04-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* 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))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.move.Move
|
||||
|
||||
/** Extension point for chess rule variants (standard, Chess960, etc.).
|
||||
* All rule queries are stateless: given a GameContext, return the answer.
|
||||
*/
|
||||
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
|
||||
* GameContext, return the answer.
|
||||
*/
|
||||
trait RuleSet:
|
||||
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move]
|
||||
@@ -32,8 +32,7 @@ trait RuleSet:
|
||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||
def isFiftyMoveRule(context: GameContext): Boolean
|
||||
|
||||
/** Apply a legal move to produce the next game context.
|
||||
* Handles all special move types: castling, en passant, promotion.
|
||||
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
|
||||
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
def applyMove(context: GameContext)(move: Move): GameContext
|
||||
|
||||
@@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet
|
||||
|
||||
import scala.annotation.tailrec
|
||||
|
||||
/** Standard chess rules implementation.
|
||||
* Handles move generation, validation, check/checkmate/stalemate detection.
|
||||
*/
|
||||
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
|
||||
*/
|
||||
object DefaultRules extends RuleSet:
|
||||
|
||||
// ── Direction vectors ──────────────────────────────────────────────
|
||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||
private val KnightJumps: List[(Int, Int)] =
|
||||
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
|
||||
|
||||
// ── Pawn configuration helpers ─────────────────────────────────────
|
||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
||||
private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
|
||||
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
|
||||
|
||||
@@ -29,13 +28,14 @@ object DefaultRules extends RuleSet:
|
||||
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
||||
if piece.color != context.turn then List.empty[Move]
|
||||
else piece.pieceType match
|
||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
||||
else
|
||||
piece.pieceType match
|
||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
||||
}
|
||||
|
||||
override def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
@@ -65,18 +65,18 @@ object DefaultRules extends RuleSet:
|
||||
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
|
||||
|
||||
private def slidingMoves(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dirs: List[(Int, Int)]
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dirs: List[(Int, Int)],
|
||||
): List[Move] =
|
||||
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
||||
|
||||
private def castRay(
|
||||
board: Board,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dir: (Int, Int)
|
||||
board: Board,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dir: (Int, Int),
|
||||
): List[Move] =
|
||||
@tailrec
|
||||
def loop(sq: Square, acc: List[Move]): List[Move] =
|
||||
@@ -84,40 +84,40 @@ object DefaultRules extends RuleSet:
|
||||
case None => acc
|
||||
case Some(next) =>
|
||||
board.pieceAt(next) match
|
||||
case None => loop(next, Move(from, next) :: acc)
|
||||
case None => loop(next, Move(from, next) :: acc)
|
||||
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
|
||||
case Some(_) => acc
|
||||
case Some(_) => acc
|
||||
loop(from, Nil).reverse
|
||||
|
||||
// ── Knight ─────────────────────────────────────────────────────────
|
||||
|
||||
private def knightCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
KnightJumps.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
// ── King ───────────────────────────────────────────────────────────
|
||||
|
||||
private def kingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
val steps = QueenDirs.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
steps ++ castlingCandidates(context, from, color)
|
||||
@@ -125,17 +125,17 @@ object DefaultRules extends RuleSet:
|
||||
// ── Castling ───────────────────────────────────────────────────────
|
||||
|
||||
private case class CastlingMove(
|
||||
kingFromAlg: String,
|
||||
kingToAlg: String,
|
||||
middleAlg: String,
|
||||
rookFromAlg: String,
|
||||
moveType: MoveType
|
||||
kingFromAlg: String,
|
||||
kingToAlg: String,
|
||||
middleAlg: String,
|
||||
rookFromAlg: String,
|
||||
moveType: MoveType,
|
||||
)
|
||||
|
||||
private def castlingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
color match
|
||||
case Color.White => whiteCastles(context, from)
|
||||
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
|
||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
|
||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.whiteKingSide,
|
||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
|
||||
)
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.whiteQueenSide,
|
||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
|
||||
)
|
||||
moves.toList
|
||||
|
||||
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
||||
@@ -157,20 +165,35 @@ object DefaultRules extends RuleSet:
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.blackKingSide,
|
||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.blackKingSide,
|
||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
|
||||
)
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.blackQueenSide,
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
|
||||
)
|
||||
moves.toList
|
||||
|
||||
private def queensideBSquare(kingToAlg: String): List[String] =
|
||||
kingToAlg match
|
||||
case "c1" => List("b1")
|
||||
case "c8" => List("b8")
|
||||
case _ => List.empty
|
||||
|
||||
private def addCastleMove(
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
castlingRight: Boolean,
|
||||
castlingMove: CastlingMove
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
castlingRight: Boolean,
|
||||
castlingMove: CastlingMove,
|
||||
): Unit =
|
||||
if castlingRight then
|
||||
val clearSqs = List(castlingMove.middleAlg, castlingMove.kingToAlg).flatMap(Square.fromAlgebraic)
|
||||
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
||||
.flatMap(Square.fromAlgebraic)
|
||||
if squaresEmpty(context.board, clearSqs) then
|
||||
for
|
||||
kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
|
||||
@@ -178,16 +201,15 @@ object DefaultRules extends RuleSet:
|
||||
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
|
||||
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
|
||||
do
|
||||
val color = context.turn
|
||||
val color = context.turn
|
||||
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
|
||||
val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
|
||||
val squaresSafe =
|
||||
!isAttackedBy(context.board, kf, color.opposite) &&
|
||||
!isAttackedBy(context.board, km, color.opposite) &&
|
||||
!isAttackedBy(context.board, kt, color.opposite)
|
||||
!isAttackedBy(context.board, km, color.opposite) &&
|
||||
!isAttackedBy(context.board, kt, color.opposite)
|
||||
|
||||
if kingPresent && rookPresent && squaresSafe then
|
||||
moves += Move(kf, kt, castlingMove.moveType)
|
||||
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
|
||||
|
||||
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
||||
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
||||
@@ -195,22 +217,26 @@ object DefaultRules extends RuleSet:
|
||||
// ── Pawn ───────────────────────────────────────────────────────────
|
||||
|
||||
private def pawnCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
val fwd = pawnForward(color)
|
||||
val fwd = pawnForward(color)
|
||||
val startRank = pawnStartRank(color)
|
||||
val promoRank = pawnPromoRank(color)
|
||||
|
||||
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
val double = Option.when(from.rank.ordinal == startRank) {
|
||||
from.offset(0, fwd).flatMap { mid =>
|
||||
Option.when(context.board.pieceAt(mid).isEmpty) {
|
||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
}.flatten
|
||||
val double = Option
|
||||
.when(from.rank.ordinal == startRank) {
|
||||
from.offset(0, fwd).flatMap { mid =>
|
||||
Option
|
||||
.when(context.board.pieceAt(mid).isEmpty) {
|
||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
}
|
||||
.flatten
|
||||
}
|
||||
}
|
||||
}.flatten
|
||||
.flatten
|
||||
|
||||
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
||||
from.offset(df, fwd).flatMap { to =>
|
||||
@@ -229,22 +255,22 @@ object DefaultRules extends RuleSet:
|
||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||
if dest.rank.ordinal == promoRank then
|
||||
List(
|
||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
||||
PromotionPiece.Queen,
|
||||
PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop,
|
||||
PromotionPiece.Knight,
|
||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
||||
stepMoves ++ captureMoves ++ epCaptures
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────────
|
||||
|
||||
private def kingSquare(board: Board, color: Color): Option[Square] =
|
||||
Square.all.find(sq =>
|
||||
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
|
||||
)
|
||||
Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
|
||||
|
||||
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
||||
Square.all.exists { sq =>
|
||||
@@ -259,26 +285,26 @@ object DefaultRules extends RuleSet:
|
||||
case PieceType.Pawn =>
|
||||
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
||||
case PieceType.Knight =>
|
||||
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||
case PieceType.King =>
|
||||
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||
|
||||
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
||||
dirs.exists { dir =>
|
||||
@tailrec
|
||||
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
|
||||
case None => false
|
||||
case Some(next) if next == target => true
|
||||
case None => false
|
||||
case Some(next) if next == target => true
|
||||
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
|
||||
case Some(_) => false
|
||||
case Some(_) => false
|
||||
loop(from)
|
||||
}
|
||||
|
||||
private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
|
||||
val nextBoard = context.board.applyMove(move)
|
||||
val nextBoard = context.board.applyMove(move)
|
||||
val nextContext = context.withBoard(nextBoard)
|
||||
isCheck(nextContext)
|
||||
|
||||
@@ -286,7 +312,7 @@ object DefaultRules extends RuleSet:
|
||||
|
||||
override def applyMove(context: GameContext)(move: Move): GameContext =
|
||||
val color = context.turn
|
||||
val board = context.board
|
||||
val board = context.board
|
||||
|
||||
val newBoard = move.moveType match
|
||||
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
|
||||
@@ -295,14 +321,14 @@ object DefaultRules extends RuleSet:
|
||||
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
||||
case MoveType.Normal(_) => board.applyMove(move)
|
||||
|
||||
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||
val newEnPassantSquare = computeEnPassantSquare(board, move)
|
||||
val isCapture = move.moveType match
|
||||
case MoveType.Normal(capture) => capture
|
||||
case MoveType.EnPassant => true
|
||||
case _ => board.pieceAt(move.to).isDefined
|
||||
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
|
||||
context
|
||||
.withBoard(newBoard)
|
||||
@@ -315,19 +341,18 @@ object DefaultRules extends RuleSet:
|
||||
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||
if kingside then
|
||||
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
else
|
||||
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||
board
|
||||
.removed(kingFrom).removed(rookFrom)
|
||||
.removed(kingFrom)
|
||||
.removed(rookFrom)
|
||||
.updated(kingTo, king)
|
||||
.updated(rookTo, rook)
|
||||
|
||||
private def applyEnPassant(board: Board, move: Move): Board =
|
||||
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||
val capturedSquare = Square(move.to.file, capturedRank)
|
||||
board.applyMove(move).removed(capturedSquare)
|
||||
|
||||
@@ -340,7 +365,7 @@ object DefaultRules extends RuleSet:
|
||||
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
||||
|
||||
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
||||
val piece = board.pieceAt(move.from)
|
||||
val piece = board.pieceAt(move.from)
|
||||
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
||||
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
||||
|
||||
@@ -350,19 +375,25 @@ object DefaultRules extends RuleSet:
|
||||
val blackKingsideRook = Square(File.H, Rank.R8)
|
||||
val blackQueensideRook = Square(File.A, Rank.R8)
|
||||
|
||||
var r = rights
|
||||
if isKingMove then r = r.revokeColor(color)
|
||||
else if isRookMove then
|
||||
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
|
||||
|
||||
val afterRookMove =
|
||||
if !isRookMove then afterKingMove
|
||||
else
|
||||
move.from match
|
||||
case `whiteKingsideRook` => afterKingMove.revokeKingSide(Color.White)
|
||||
case `whiteQueensideRook` => afterKingMove.revokeQueenSide(Color.White)
|
||||
case `blackKingsideRook` => afterKingMove.revokeKingSide(Color.Black)
|
||||
case `blackQueensideRook` => afterKingMove.revokeQueenSide(Color.Black)
|
||||
case _ => afterKingMove
|
||||
|
||||
// Also revoke if a rook is captured
|
||||
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
r
|
||||
move.to match
|
||||
case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White)
|
||||
case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White)
|
||||
case `blackKingsideRook` => afterRookMove.revokeKingSide(Color.Black)
|
||||
case `blackQueensideRook` => afterRookMove.revokeQueenSide(Color.Black)
|
||||
case _ => afterRookMove
|
||||
|
||||
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
|
||||
val piece = board.pieceAt(move.from)
|
||||
@@ -376,12 +407,14 @@ object DefaultRules extends RuleSet:
|
||||
|
||||
// ── Insufficient material ──────────────────────────────────────────
|
||||
|
||||
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
|
||||
|
||||
private def insufficientMaterial(board: Board): Boolean =
|
||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
||||
pieces match
|
||||
case Nil => true
|
||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case List(p1, p2)
|
||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||
&& p1.color != p2.color => true
|
||||
val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King }
|
||||
nonKings match
|
||||
case Nil => true
|
||||
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
|
||||
// All non-king pieces are bishops: draw only if they all share the same square color
|
||||
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1
|
||||
case _ => false
|
||||
|
||||
+59
-28
@@ -40,6 +40,11 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns true for king and knight versus king"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/4KN2 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns false for king and rook versus king"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
|
||||
|
||||
@@ -65,7 +70,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove clears en passant square for non double pawn push"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
|
||||
val move = Move(sq("e2"), sq("e3"))
|
||||
val move = Move(sq("e2"), sq("e3"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -73,7 +78,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove resets halfMoveClock on pawn move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -81,7 +86,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove increments halfMoveClock on quiet non pawn move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
|
||||
val move = Move(sq("g1"), sq("f3"))
|
||||
val move = Move(sq("g1"), sq("f3"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -89,7 +94,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove resets halfMoveClock on capture"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -98,7 +103,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove updates castling rights after king move"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||
val move = Move(sq("e1"), sq("e2"))
|
||||
val move = Move(sq("e1"), sq("e2"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -109,7 +114,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove updates castling rights after rook move from h1"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
|
||||
val move = Move(sq("h1"), sq("h2"))
|
||||
val move = Move(sq("h1"), sq("h2"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -118,7 +123,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes opponent castling right when rook on starting square is captured"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -126,7 +131,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes kingside castling and repositions king and rook"):
|
||||
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -137,7 +142,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes queenside castling and repositions king and rook"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
|
||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -148,7 +153,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes en passant and removes captured pawn"):
|
||||
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
||||
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -158,7 +163,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes promotion with selected piece type"):
|
||||
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -179,7 +184,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove preserves black castling rights after white kingside castling"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -190,25 +195,46 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||
val context = GameContext(
|
||||
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||
board =
|
||||
contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||
turn = Color.Black,
|
||||
castlingRights = CastlingRights(true, true, false, false),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
||||
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||
val afterH1Capture =
|
||||
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||
|
||||
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
||||
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
||||
|
||||
test("isInsufficientMaterial returns true for opposite color bishops only"):
|
||||
test("isInsufficientMaterial returns true for two same-square-color bishops (one each side)"):
|
||||
// White bishop d1 (dark), black bishop g2 (dark) — same square color → draw
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns false for two different-square-color bishops (one each side)"):
|
||||
// White bishop d1 (dark), black bishop d2 (light) — different square colors → not a draw
|
||||
val context = contextFromFen("8/8/8/4k3/8/8/3b4/3BK3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe false
|
||||
|
||||
test("isInsufficientMaterial returns true for two same-color bishops vs lone king"):
|
||||
// White bishops on c1 (light) and e3 (light), black king only → draw
|
||||
val context = contextFromFen("4k3/8/8/8/8/4B3/8/2B1K3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns false for bishop and knight versus king"):
|
||||
// K+B+N vs K is sufficient material
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/8/3BKN2 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe false
|
||||
|
||||
test("candidateMoves for rook includes enemy capture move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
|
||||
|
||||
@@ -233,7 +259,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
||||
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -244,7 +270,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes black castling rights when black rook moves from h8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("h8"), sq("h7"))
|
||||
val move = Move(sq("h8"), sq("h7"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -253,7 +279,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes black queenside castling right when black rook moves from a8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("a8"), sq("a7"))
|
||||
val move = Move(sq("a8"), sq("a7"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -262,7 +288,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
|
||||
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
|
||||
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -270,31 +296,36 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("candidateMoves creates all promotion move variants for black pawn"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
|
||||
val to = sq("a1")
|
||||
val to = sq("a1")
|
||||
|
||||
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
|
||||
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
|
||||
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
|
||||
|
||||
promotions.toSet shouldBe Set(
|
||||
PromotionPiece.Queen,
|
||||
PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop,
|
||||
PromotionPiece.Knight
|
||||
PromotionPiece.Knight,
|
||||
)
|
||||
|
||||
test("applyMove promotion supports queen rook and bishop targets"):
|
||||
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
|
||||
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||
|
||||
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||
|
||||
test("applyMove preserves castling rights when rook moves from non-starting square"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/4R3/4K3 w KQkq - 0 1")
|
||||
val move = Move(sq("e2"), sq("e3"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
|
||||
|
||||
|
||||
next.castlingRights.whiteKingSide shouldBe true
|
||||
next.castlingRights.whiteQueenSide shouldBe true
|
||||
next.castlingRights.blackKingSide shouldBe true
|
||||
next.castlingRights.blackQueenSide shouldBe true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.rule
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
@@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
// ── Pawn moves ──────────────────────────────────────────────────
|
||||
|
||||
test("pawn can move forward one square"):
|
||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("pawn can move forward two squares from starting position"):
|
||||
val context = GameContext.initial
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
|
||||
|
||||
test("pawn can capture diagonally"):
|
||||
// FEN: white pawn e4, black pawn d5
|
||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
|
||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && (m.moveType match { case _: MoveType.Normal => true; case _ => false }))
|
||||
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
||||
|
||||
test("pawn cannot move backward"):
|
||||
// FEN: white pawn on e4
|
||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
|
||||
|
||||
@@ -47,18 +47,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("moving king out of check removes it from legal moves if king stays in check"):
|
||||
// FEN: white king e1, black rook e8, white tries to move away
|
||||
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
|
||||
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
|
||||
moves.filter(m => m.from == Square(File.E, Rank.R1)).nonEmpty shouldBe true
|
||||
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
|
||||
|
||||
test("king cannot move to square attacked by opponent"):
|
||||
// FEN: white king e1, black rook e2 defended by black king e3
|
||||
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King cannot move to e2 (occupied and attacked)
|
||||
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
|
||||
@@ -67,64 +67,89 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
// ── Castling legality ────────────────────────────────────────────
|
||||
|
||||
test("castling kingside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling queenside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when castling rights are false"):
|
||||
// FEN: king and rook in position, but castling rights disabled
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when king is in check"):
|
||||
// FEN: white king e1 in check from black rook e8
|
||||
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when path has piece in the way"):
|
||||
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling queenside is illegal when knight blocks on b8"):
|
||||
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
),
|
||||
)
|
||||
val context = GameContext(
|
||||
board = board,
|
||||
turn = Color.Black,
|
||||
castlingRights =
|
||||
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty,
|
||||
)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
// ── En passant legality ──────────────────────────────────────────
|
||||
|
||||
test("en passant is legal when en passant square is set"):
|
||||
// FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("en passant is illegal when en passant square is none"):
|
||||
// FEN: white pawn e5, black pawn d5, but no en passant square
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.isEmpty shouldBe true
|
||||
@@ -133,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("pinned piece cannot move and expose king to check"):
|
||||
// FEN: white king e1, white bishop d2 (pinned), black rook a2
|
||||
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// Bishop on d2 is pinned by rook on a2; it cannot move
|
||||
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
|
||||
@@ -144,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
test("piece blocking a check is legal"):
|
||||
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
|
||||
// Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2
|
||||
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// White is in check; only moves that block or move the king are legal
|
||||
moves.nonEmpty shouldBe true
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=2
|
||||
MINOR=1
|
||||
PATCH=0
|
||||
|
||||
@@ -41,3 +41,30 @@
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
## (2026-04-07)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
|
||||
## (2026-04-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.ui.terminal.TerminalUI
|
||||
import de.nowchess.ui.gui.ChessGUILauncher
|
||||
|
||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
|
||||
* GameEngine via Observer pattern.
|
||||
*/
|
||||
object Main:
|
||||
def main(args: Array[String]): Unit =
|
||||
// Create the core game engine (single source of truth)
|
||||
@@ -18,4 +18,3 @@ object Main:
|
||||
// Create and start the terminal UI (blocks on main thread)
|
||||
val tui = new TerminalUI(engine)
|
||||
tui.start()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import scalafx.Includes.*
|
||||
import scalafx.application.Platform
|
||||
import scalafx.geometry.{Insets, Pos}
|
||||
@@ -16,34 +16,47 @@ import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||
import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||
import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
|
||||
import java.nio.file.Paths
|
||||
import scalafx.stage.FileChooser
|
||||
import scalafx.stage.FileChooser.ExtensionFilter
|
||||
|
||||
/** ScalaFX chess board view that displays the game state.
|
||||
* Uses chess sprites and color palette.
|
||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
|
||||
* interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
||||
|
||||
private val squareSize = 70.0
|
||||
|
||||
private val squareSize = 70.0
|
||||
private val comicSansFontFamily = "Comic Sans MS"
|
||||
private val boardGrid = new GridPane()
|
||||
private val boardGrid = new GridPane()
|
||||
private val messageLabel = new Label {
|
||||
text = "Welcome!"
|
||||
font = Font.font(comicSansFontFamily, 16)
|
||||
padding = Insets(10)
|
||||
}
|
||||
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
private var selectedSquare: Option[Square] = None
|
||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||
|
||||
private var undoButton: Button = uninitialized
|
||||
private var redoButton: Button = uninitialized
|
||||
|
||||
private val currentBoard = new AtomicReference[Board](engine.board)
|
||||
private val currentTurn = new AtomicReference[Color](engine.turn)
|
||||
private val selectedSquare = new AtomicReference[Option[Square]](None)
|
||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||
|
||||
private val undoButton: Button = new Button("Undo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canUndo then engine.undo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
||||
disable = !engine.canUndo
|
||||
}
|
||||
private val redoButton: Button = new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canRedo then engine.redo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||
disable = !engine.canRedo
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
initializeBoard()
|
||||
|
||||
|
||||
top = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 5
|
||||
@@ -54,17 +67,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 24)
|
||||
style = "-fx-font-weight: bold;"
|
||||
},
|
||||
messageLabel
|
||||
messageLabel,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
center = new VBox {
|
||||
padding = Insets(20)
|
||||
alignment = Pos.Center
|
||||
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
|
||||
children = boardGrid
|
||||
}
|
||||
|
||||
|
||||
bottom = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 8
|
||||
@@ -74,29 +87,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
spacing = 10
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
{
|
||||
undoButton = new Button("Undo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canUndo then engine.undo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
||||
disable = !engine.canUndo
|
||||
}
|
||||
undoButton
|
||||
},
|
||||
{
|
||||
redoButton = new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canRedo then engine.redo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||
disable = !engine.canRedo
|
||||
}
|
||||
redoButton
|
||||
},
|
||||
undoButton,
|
||||
redoButton,
|
||||
new Button("Reset") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => engine.reset()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
new HBox {
|
||||
@@ -122,17 +119,33 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doPgnImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
new HBox {
|
||||
spacing = 10
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
new Button("JSON Export") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doJsonExport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;"
|
||||
},
|
||||
new Button("JSON Import") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doJsonImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private def initializeBoard(): Unit =
|
||||
boardGrid.padding = Insets(5)
|
||||
boardGrid.hgap = 0
|
||||
boardGrid.vgap = 0
|
||||
|
||||
|
||||
// Create 8x8 board with rank/file labels
|
||||
for
|
||||
rank <- 0 until 8
|
||||
@@ -141,13 +154,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
val square = createSquare(rank, file)
|
||||
squareViews((rank, file)) = square
|
||||
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
||||
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
|
||||
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
|
||||
private def createSquare(rank: Int, file: Int): StackPane =
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||
|
||||
|
||||
val bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
@@ -155,62 +168,61 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
|
||||
val square = new StackPane {
|
||||
children = Seq(bgRect)
|
||||
onMouseClicked = _ => handleSquareClick(rank, file)
|
||||
style = "-fx-cursor: hand;"
|
||||
}
|
||||
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if engine.isPendingPromotion then
|
||||
return // Don't allow moves during promotion
|
||||
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
selectedSquare match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn then
|
||||
selectedSquare = Some(clickedSquare)
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
|
||||
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare = None
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
else
|
||||
// Try to move
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare = None
|
||||
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if !engine.isPendingPromotion then
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
selectedSquare.get() match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn.get() then
|
||||
selectedSquare.set(Some(clickedSquare))
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
|
||||
val legalDests = engine.ruleSet
|
||||
.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare.set(None)
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
else
|
||||
// Try to move
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare.set(None)
|
||||
|
||||
def updateBoard(board: Board, turn: Color): Unit =
|
||||
currentBoard = board
|
||||
currentTurn = turn
|
||||
selectedSquare = None
|
||||
|
||||
currentBoard.set(board)
|
||||
currentTurn.set(turn)
|
||||
selectedSquare.set(None)
|
||||
|
||||
// Update all squares
|
||||
for
|
||||
rank <- 0 until 8
|
||||
file <- 0 until 8
|
||||
do
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val isWhite = (rank + file) % 2 == 0
|
||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||
|
||||
|
||||
val bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
@@ -218,24 +230,24 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = board.pieceAt(square)
|
||||
|
||||
val children = pieceOption match
|
||||
|
||||
val children: Seq[scalafx.scene.Node] = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
|
||||
|
||||
stackPane.children = children
|
||||
}
|
||||
|
||||
updateUndoRedoButtons()
|
||||
|
||||
def updateUndoRedoButtons(): Unit =
|
||||
if undoButton != null then undoButton.disable = !engine.canUndo
|
||||
if redoButton != null then redoButton.disable = !engine.canRedo
|
||||
undoButton.disable = !engine.canUndo
|
||||
redoButton.disable = !engine.canRedo
|
||||
|
||||
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
@@ -246,20 +258,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = currentBoard.pieceAt(square)
|
||||
|
||||
stackPane.children = pieceOption match
|
||||
|
||||
val square = Square(File.values(file), Rank.values(rank))
|
||||
val pieceOption = currentBoard.get().pieceAt(square)
|
||||
|
||||
stackPane.children = (pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
Seq(bgRect)): Seq[scalafx.scene.Node]
|
||||
}
|
||||
|
||||
|
||||
def showMessage(msg: String): Unit =
|
||||
messageLabel.text = msg
|
||||
|
||||
|
||||
def showPromotionDialog(from: Square, to: Square): Unit =
|
||||
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
|
||||
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
|
||||
@@ -268,14 +280,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
headerText = "Choose promotion piece"
|
||||
contentText = "Promote to:"
|
||||
}
|
||||
|
||||
|
||||
val result = dialog.showAndWait()
|
||||
result match
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
|
||||
private def doFenExport(): Unit =
|
||||
doExport(FenExporter, "FEN")
|
||||
@@ -289,12 +301,51 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
private def doPgnImport(): Unit =
|
||||
doImport(PgnParser, "PGN")
|
||||
|
||||
private def doJsonExport(): Unit =
|
||||
val fileChooser = new FileChooser {
|
||||
title = "Export Game as JSON"
|
||||
initialFileName = "chess_game.json"
|
||||
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||
}
|
||||
|
||||
Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile =>
|
||||
val result = FileSystemGameService.saveGameToFile(
|
||||
engine.context,
|
||||
selectedFile.toPath,
|
||||
JsonExporter,
|
||||
)
|
||||
result match
|
||||
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
||||
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
|
||||
}
|
||||
|
||||
private def doJsonImport(): Unit =
|
||||
val fileChooser = new FileChooser {
|
||||
title = "Import Game from JSON"
|
||||
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||
}
|
||||
|
||||
Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile =>
|
||||
val result = FileSystemGameService.loadGameFromFile(
|
||||
selectedFile.toPath,
|
||||
JsonParser,
|
||||
)
|
||||
result match
|
||||
case Right(gameContext) =>
|
||||
engine.loadPosition(gameContext)
|
||||
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ Error: $err")
|
||||
}
|
||||
|
||||
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
|
||||
val exported = exporter.exportGameContext(engine.context)
|
||||
showCopyDialog(s"$formatName Export", exported)
|
||||
}
|
||||
|
||||
private def doImport(importer: GameContextImport, formatName: String): Unit = {
|
||||
private def doImport(importer: GameContextImport, formatName: String): Unit =
|
||||
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
||||
importer.importGameContext(input) match
|
||||
case Right(gameContext) =>
|
||||
@@ -303,7 +354,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ $formatName Error: $err")
|
||||
}
|
||||
}
|
||||
|
||||
private def showCopyDialog(title: String, content: String): Unit =
|
||||
val area = new javafx.scene.control.TextArea(content)
|
||||
@@ -312,7 +362,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
area.setPrefRowCount(4)
|
||||
val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
|
||||
alert.setTitle(title)
|
||||
alert.setHeaderText(null)
|
||||
alert.setHeaderText("")
|
||||
alert.getDialogPane.setContent(area)
|
||||
alert.getDialogPane.setPrefWidth(500)
|
||||
alert.initOwner(stage.delegate)
|
||||
@@ -327,12 +377,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
dialog.getDialogPane.setContent(area)
|
||||
dialog.getDialogPane.getButtonTypes.addAll(
|
||||
javafx.scene.control.ButtonType.OK,
|
||||
javafx.scene.control.ButtonType.CANCEL
|
||||
javafx.scene.control.ButtonType.CANCEL,
|
||||
)
|
||||
dialog.setResultConverter { bt =>
|
||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else ""
|
||||
}
|
||||
dialog.initOwner(stage.delegate)
|
||||
val result = dialog.showAndWait()
|
||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
if result.isPresent && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
@@ -1,63 +1,57 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
|
||||
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
|
||||
import javafx.stage.Stage as JFXStage
|
||||
import scalafx.application.Platform
|
||||
import scalafx.scene.Scene
|
||||
import scalafx.stage.Stage
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
|
||||
/** ScalaFX GUI Application for Chess.
|
||||
* This is launched from Main alongside the TUI.
|
||||
* Both subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
|
||||
* GameEngine via Observer pattern.
|
||||
*/
|
||||
class ChessGUIApp extends JFXApplication:
|
||||
|
||||
|
||||
override def start(primaryStage: JFXStage): Unit =
|
||||
val engine = ChessGUILauncher.getEngine
|
||||
val stage = new Stage(primaryStage)
|
||||
|
||||
val stage = new Stage(primaryStage)
|
||||
|
||||
stage.title = "Chess"
|
||||
stage.width = 700
|
||||
stage.height = 1000
|
||||
stage.resizable = false
|
||||
|
||||
val boardView = new ChessBoardView(stage, engine)
|
||||
|
||||
val boardView = new ChessBoardView(stage, engine)
|
||||
val guiObserver = new GUIObserver(boardView)
|
||||
|
||||
|
||||
// Subscribe GUI observer to engine
|
||||
engine.subscribe(guiObserver)
|
||||
|
||||
|
||||
stage.scene = new Scene {
|
||||
root = boardView
|
||||
// Load CSS if available
|
||||
try {
|
||||
val cssUrl = getClass.getResource("/styles.css")
|
||||
if cssUrl != null then
|
||||
stylesheets.add(cssUrl.toExternalForm)
|
||||
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
|
||||
} catch {
|
||||
case _: Exception => // CSS is optional
|
||||
}
|
||||
}
|
||||
|
||||
stage.onCloseRequest = _ => {
|
||||
|
||||
stage.onCloseRequest = _ =>
|
||||
// Unsubscribe when window closes
|
||||
engine.unsubscribe(guiObserver)
|
||||
}
|
||||
|
||||
|
||||
stage.show()
|
||||
|
||||
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
|
||||
object ChessGUILauncher:
|
||||
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
|
||||
|
||||
def getEngine: GameEngine = engine
|
||||
|
||||
private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]()
|
||||
|
||||
def getEngine: GameEngine = engineRef.get()
|
||||
|
||||
def launch(eng: GameEngine): Unit =
|
||||
engine = eng
|
||||
val guiThread = new Thread(() => {
|
||||
JFXApplication.launch(classOf[ChessGUIApp])
|
||||
})
|
||||
engineRef.set(eng)
|
||||
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
|
||||
guiThread.setDaemon(false)
|
||||
guiThread.setName("ScalaFX-GUI-Thread")
|
||||
guiThread.start()
|
||||
|
||||
@@ -3,13 +3,13 @@ package de.nowchess.ui.gui
|
||||
import scalafx.application.Platform
|
||||
import scalafx.scene.control.Alert
|
||||
import scalafx.scene.control.Alert.AlertType
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
||||
import de.nowchess.chess.observer.{GameEvent, Observer, *}
|
||||
import de.nowchess.api.board.Board
|
||||
import de.nowchess.api.game.DrawReason
|
||||
|
||||
/** GUI Observer that implements the Observer pattern.
|
||||
* Receives game events from GameEngine and updates the ScalaFX UI.
|
||||
* All UI updates must be done on the JavaFX Application Thread.
|
||||
*/
|
||||
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
|
||||
* All UI updates must be done on the JavaFX Application Thread.
|
||||
*/
|
||||
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
@@ -30,9 +30,14 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
|
||||
|
||||
case e: StalemateEvent =>
|
||||
case e: DrawEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
|
||||
val msg = e.reason match
|
||||
case DrawReason.Stalemate => "Stalemate! The game is a draw."
|
||||
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
|
||||
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
|
||||
case DrawReason.Agreement => "Draw by agreement."
|
||||
showAlert(AlertType.Information, "Game Over", msg)
|
||||
|
||||
case e: InvalidMoveEvent =>
|
||||
boardView.showMessage(s"⚠️ ${e.reason}")
|
||||
@@ -44,12 +49,8 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
case e: PromotionRequiredEvent =>
|
||||
boardView.showPromotionDialog(e.from, e.to)
|
||||
|
||||
case e: DrawClaimedEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.")
|
||||
|
||||
case e: FiftyMoveRuleAvailableEvent =>
|
||||
boardView.showMessage("50-move rule available! The game is a draw.")
|
||||
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
|
||||
|
||||
case e: MoveUndoneEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
@@ -60,8 +61,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
if e.capturedPiece.isDefined then
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
||||
else
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||
boardView.updateUndoRedoButtons()
|
||||
|
||||
case e: PgnLoadedEvent =>
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scalafx.scene.image.{Image, ImageView}
|
||||
import de.nowchess.api.board.{Piece, PieceType, Color}
|
||||
import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||
|
||||
/** Utility object for loading chess piece sprites. */
|
||||
object PieceSprites:
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||
|
||||
/** Load a piece sprite image from resources.
|
||||
* Sprites are cached for performance.
|
||||
*/
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
|
||||
|
||||
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||
*/
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
|
||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
||||
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
}
|
||||
}
|
||||
|
||||
private def loadImage(key: String): Image =
|
||||
|
||||
private def loadImage(key: String): Option[Image] =
|
||||
val path = s"/sprites/pieces/$key.png"
|
||||
val stream = getClass.getResourceAsStream(path)
|
||||
if stream == null then
|
||||
throw new RuntimeException(s"Could not load sprite: $path")
|
||||
new Image(stream)
|
||||
|
||||
Option(getClass.getResourceAsStream(path)).map(new Image(_))
|
||||
|
||||
/** Get square colors for the board using theme. */
|
||||
object SquareColors:
|
||||
val White = "#F3C8A0" // Warm light beige
|
||||
val Black = "#BA6D4B" // Warm terracotta
|
||||
val Selected = "#C19EF5" // Purple highlight
|
||||
val White = "#F3C8A0" // Warm light beige
|
||||
val Black = "#BA6D4B" // Warm terracotta
|
||||
val Selected = "#C19EF5" // Purple highlight
|
||||
val ValidMove = "#E1EAA9" // Light yellow-green
|
||||
val Border = "#5A2C28" // Dark brown border
|
||||
val Border = "#5A2C28" // Dark brown border
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package de.nowchess.ui.terminal
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.ui.utils.Renderer
|
||||
|
||||
/** Terminal UI that implements Observer pattern.
|
||||
* Subscribes to GameEngine and receives state change events.
|
||||
* Handles all I/O and user interaction in the terminal.
|
||||
*/
|
||||
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
|
||||
* I/O and user interaction in the terminal.
|
||||
*/
|
||||
class TerminalUI(engine: GameEngine) extends Observer:
|
||||
private var running = true
|
||||
private var awaitingPromotion = false
|
||||
private val running = new AtomicBoolean(true)
|
||||
private val awaitingPromotion = new AtomicBoolean(false)
|
||||
|
||||
/** Called by GameEngine whenever a game event occurs. */
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
@@ -44,8 +45,13 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
println()
|
||||
print(Renderer.render(e.context.board))
|
||||
|
||||
case e: StalemateEvent =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
case e: DrawEvent =>
|
||||
val msg = e.reason match
|
||||
case DrawReason.Stalemate => "Stalemate! The game is a draw."
|
||||
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
|
||||
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
|
||||
case DrawReason.Agreement => "Draw by agreement."
|
||||
println(msg)
|
||||
println()
|
||||
print(Renderer.render(e.context.board))
|
||||
|
||||
@@ -60,13 +66,9 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
|
||||
case _: PromotionRequiredEvent =>
|
||||
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||
synchronized { awaitingPromotion = true }
|
||||
case _: DrawClaimedEvent =>
|
||||
println("Draw claimed! The game is a draw.")
|
||||
println()
|
||||
print(Renderer.render(engine.board))
|
||||
awaitingPromotion.set(true)
|
||||
case _: FiftyMoveRuleAvailableEvent =>
|
||||
println("50-move rule available! The game is a draw.")
|
||||
println("50-move rule is now available — type 'draw' to claim.")
|
||||
|
||||
case e: PgnLoadedEvent =>
|
||||
println("PGN loaded successfully.")
|
||||
@@ -85,22 +87,22 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
printPrompt(engine.turn)
|
||||
|
||||
// Game loop
|
||||
while running do
|
||||
while running.get() do
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
synchronized {
|
||||
if awaitingPromotion then
|
||||
if awaitingPromotion.get() then
|
||||
input.toLowerCase match
|
||||
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
|
||||
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
|
||||
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
|
||||
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
|
||||
case "q" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Queen)
|
||||
case "r" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Rook)
|
||||
case "b" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Bishop)
|
||||
case "n" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ =>
|
||||
println("Invalid choice. Enter q, r, b, or n.")
|
||||
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||
else
|
||||
input.toLowerCase match
|
||||
case "quit" | "q" =>
|
||||
running = false
|
||||
running.set(false)
|
||||
println("Game over. Goodbye!")
|
||||
case "" =>
|
||||
printPrompt(engine.turn)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user