Compare commits

..

14 Commits

Author SHA1 Message Date
shahdlala66 fa5f64f5ca fix: used external lib
Build & Test (NowChessSystems) TeamCity build failed
2026-04-08 11:13:28 +02:00
TeamCity 6d242ff88e ci: bump version with Build-35 2026-04-08 11:11:28 +02:00
lq64 b271b24df3 feat: NCS-31 FastParse FEN (#22)
Summary

  - Added fastparse_3:3.0.2 dependency to modules/io
  - Implemented FenParserFastParse as a second alternative FEN parser using FastParse, with the same public API as
  FenParser and FenParserCombinators
  - Parsers are built bottom-up using (using P[Any]) Scala 3 syntax with NoWhitespace.* to prevent implicit whitespace
  skipping; rank sum validation uses Pass/Fail inside .flatMap
  - Added FenParserFastParseTest mirroring FenParserCombinatorsTest to prove behavioural equivalence across all three
  implementations

  Test plan

  - All existing tests pass — FenParser, FenParserCombinators, and all other modules untouched
  - FenParserFastParseTest covers all cases: valid FEN, invalid color, invalid castling, invalid board shapes, en
  passant, rank overflow, round-trip via FenExporter
  - All parser logic branches genuinely covered — known scoverage gap documented in docs/unresolved.md (FastParse inline
   macro generates synthetic proxy methods that scoverage instruments but that never execute at runtime)

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #22
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-08 11:11:27 +02:00
TeamCity 9199711d6d ci: bump version with Build-34 2026-04-08 11:07:59 +02:00
Janis 32923b1939 fix: NCS-32 Queenside Castle doesn't care about pieces in the way (#23)
Reviewed-on: #23
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-08 11:07:59 +02:00
TeamCity 3492244247 ci: bump version with Build-33 2026-04-08 11:07:59 +02:00
lq64 61224e1162 feat: NCS-30 FEN Parser using ParserCombinators (#21)
Summary

  - Added scala-parser-combinators_3:2.4.0 dependency to modules/io
  - Implemented FenParserCombinators as an alternative FEN parser using RegexParsers, with the same public API as the
  existing FenParser
  - Parsers are built bottom-up: piece characters → rank tokens → rank → board, composed with explicit field separators
  into a full FEN parser
  - Added FenParserCombinatorsTest mirroring the existing FenParserTest to prove behavioural equivalence

  Test plan

  - All existing tests pass — FenParser and all other modules untouched
  - FenParserCombinatorsTest covers all cases: valid FEN, invalid color, invalid castling, invalid board shapes, en
  passant, round-trip via FenExporter
  - 100% line/branch/method coverage on FenParserCombinators

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #21
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-08 11:07:59 +02:00
Janis 735b467dfb refactor: NCS-24 update CLAUDE.md for improved structure and clarity (#20)
Reviewed-on: #20
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
2026-04-08 11:07:23 +02:00
TeamCity 9fea10a4dc ci: bump version with Build-31 2026-04-08 11:07:23 +02:00
lq64 cfa4429d49 refactor: NCS-19 Currying (#18)
Summary

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

  Test plan

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

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #18
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-08 11:07:23 +02:00
shahdlala66 b4bf447453 test: 100% coverage
Build & Test (NowChessSystems) TeamCity build failed
2026-04-08 09:09:56 +02:00
shahdlala66 f6a6c8376a test: added tests coverage
Build & Test (NowChessSystems) TeamCity build failed
2026-04-08 08:26:06 +02:00
shahdlala66 ef9bbcfe85 fix: used external lib
Build & Test (NowChessSystems) TeamCity build failed
2026-04-07 15:21:48 +02:00
shahdlala66 33c0260b75 feat: I/O json export import, tests should be 100%
Build & Test (NowChessSystems) TeamCity build finished
2026-04-06 21:57:09 +02:00
108 changed files with 1821 additions and 3708 deletions
-203
View File
@@ -1,203 +0,0 @@
# 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_
-37
View File
@@ -1,37 +0,0 @@
# 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
-150
View File
@@ -1,150 +0,0 @@
# 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
-44
View File
@@ -1,44 +0,0 @@
# 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)_
-5
View File
@@ -1,5 +0,0 @@
# 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
-19
View File
@@ -1,19 +0,0 @@
# 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_
-41
View File
@@ -1,41 +0,0 @@
# 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
-2
View File
@@ -38,8 +38,6 @@ bin/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
graphify-out/
.graphify_*.json
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
-15
View File
@@ -1,15 +0,0 @@
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
-8
View File
@@ -1,8 +0,0 @@
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
-21
View File
@@ -1,21 +0,0 @@
# 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.
-45
View File
@@ -37,11 +37,6 @@ Try to stick to these commands for consistency.
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt. - **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 ## 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. - **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.
@@ -53,43 +48,3 @@ 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. - **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. - 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
+6 -28
View File
@@ -1,8 +1,6 @@
plugins { plugins {
id("org.sonarqube") version "7.2.3.7755" id("org.sonarqube") version "7.2.3.7755"
id("org.scoverage") version "8.1" apply false id("org.scoverage") version "8.1" apply false
id("com.diffplug.spotless") version "8.4.0" apply false
id("io.github.cosmicsilence.scalafix") version "0.2.6" apply false
} }
group = "de.nowchess" group = "de.nowchess"
@@ -35,34 +33,14 @@ val versions = mapOf(
"SCALAFX" to "21.0.0-R32", "SCALAFX" to "21.0.0-R32",
"JAVAFX" to "21.0.1", "JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4", "JUNIT_BOM" to "5.13.4",
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2", "JACKSON" to "2.17.2",
"JACKSON_SCALA" to "2.17.2" "JACKSON_SCALA" to "2.17.2"
"SCALA_PARSER_COMBINATORS" to "2.4.0"
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2"
"JACKSON" to "2.17.2",
"JACKSON_SCALA" to "2.17.2",
"SCALA_PARSER_COMBINATORS" to "2.4.0"
) )
extra["VERSIONS"] = versions extra["VERSIONS"] = versions
subprojects {
apply(plugin = "com.diffplug.spotless")
pluginManager.withPlugin("scala") {
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
scala {
scalafmt().configFile(rootProject.file(".scalafmt.conf"))
}
}
apply(plugin = "io.github.cosmicsilence.scalafix")
configure<io.github.cosmicsilence.scalafix.ScalafixExtension> {
configFile.set(rootProject.file(".scalafix.conf"))
}
// Disable SemanticDB config for the scoverage source set — it sets -sourceroot to
// the root project dir, which conflicts with scoverage's own -sourceroot and causes
// reportTestScoverage to fail with "No source root found".
tasks.matching { it.name in setOf("configSemanticDBScoverage", "checkScalafixScoverage", "checkScalafixTest") }.configureEach {
enabled = false
}
}
}
Executable → Regular
View File
-776
View File
@@ -1,776 +0,0 @@
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
-13
View File
@@ -21,16 +21,3 @@
### Features ### 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-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
## (2026-04-12)
### Features
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
## (2026-04-14)
### Features
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
@@ -21,14 +21,8 @@ object Board:
val initial: Board = val initial: Board =
val backRank: Vector[PieceType] = Vector( val backRank: Vector[PieceType] = Vector(
PieceType.Rook, PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
PieceType.Knight, PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
val entries = for val entries = for
fileIdx <- 0 until 8 fileIdx <- 0 until 8
@@ -36,7 +30,7 @@ object Board:
(Color.White, Rank.R1, backRank(fileIdx)), (Color.White, Rank.R1, backRank(fileIdx)),
(Color.White, Rank.R2, PieceType.Pawn), (Color.White, Rank.R2, PieceType.Pawn),
(Color.Black, Rank.R8, backRank(fileIdx)), (Color.Black, Rank.R8, backRank(fileIdx)),
(Color.Black, Rank.R7, PieceType.Pawn), (Color.Black, Rank.R7, PieceType.Pawn)
) )
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType) yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
Board(entries.toMap) Board(entries.toMap)
@@ -1,47 +1,49 @@
package de.nowchess.api.board package de.nowchess.api.board
/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and /**
* direction. * Unified castling rights tracker for all four sides.
* Tracks whether castling is still available for each side and direction.
* *
* @param whiteKingSide * @param whiteKingSide White's king-side castling (0-0) still legally available
* White's king-side castling (0-0) still legally available * @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
* @param whiteQueenSide * @param blackKingSide Black's king-side castling (0-0) still legally available
* White's queen-side castling (0-0-0) still legally available * @param blackQueenSide Black'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( final case class CastlingRights(
whiteKingSide: Boolean, whiteKingSide: Boolean,
whiteQueenSide: Boolean, whiteQueenSide: Boolean,
blackKingSide: Boolean, blackKingSide: Boolean,
blackQueenSide: Boolean, blackQueenSide: Boolean
): ):
/** Check if either side has any castling rights remaining. /**
* Check if either side has any castling rights remaining.
*/ */
def hasAnyRights: Boolean = def hasAnyRights: Boolean =
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide 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 def hasRights(color: Color): Boolean = color match
case Color.White => whiteKingSide || whiteQueenSide case Color.White => whiteKingSide || whiteQueenSide
case Color.Black => blackKingSide || blackQueenSide 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 def revokeColor(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false) case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
case Color.Black => copy(blackKingSide = false, blackQueenSide = 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 def revokeKingSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false) case Color.White => copy(whiteKingSide = false)
case Color.Black => copy(blackKingSide = false) case Color.Black => copy(blackKingSide = false)
/** Revoke a specific castling right. /**
* Revoke a specific castling right.
*/ */
def revokeQueenSide(color: Color): CastlingRights = color match def revokeQueenSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteQueenSide = false) case Color.White => copy(whiteQueenSide = false)
@@ -53,7 +55,7 @@ object CastlingRights:
whiteKingSide = false, whiteKingSide = false,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = false, blackQueenSide = false
) )
/** All castling rights available. */ /** All castling rights available. */
@@ -61,7 +63,7 @@ object CastlingRights:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = true, whiteQueenSide = true,
blackKingSide = true, blackKingSide = true,
blackQueenSide = true, blackQueenSide = true
) )
/** Standard starting position castling rights (both sides can castle both ways). */ /** Standard starting position castling rights (both sides can castle both ways). */
@@ -1,21 +1,24 @@
package de.nowchess.api.board package de.nowchess.api.board
/** A file (column) on the chess board, ah. Ordinal values 07 correspond to ah. /**
* A file (column) on the chess board, ah.
* Ordinal values 07 correspond to ah.
*/ */
enum File: enum File:
case A, B, C, D, E, F, G, H case A, B, C, D, E, F, G, H
/** A rank (row) on the chess board, 18. Ordinal values 07 correspond to ranks 18. /**
* A rank (row) on the chess board, 18.
* Ordinal values 07 correspond to ranks 18.
*/ */
enum Rank: enum Rank:
case R1, R2, R3, R4, R5, R6, R7, R8 case R1, R2, R3, R4, R5, R6, R7, R8
/** A unique square on the board, identified by its file and rank. /**
* A unique square on the board, identified by its file and rank.
* *
* @param file * @param file the column, ah
* the column, ah * @param rank the row, 18
* @param rank
* the row, 18
*/ */
final case class Square(file: File, rank: Rank): final case class Square(file: File, rank: Rank):
/** Algebraic notation string, e.g. "e4". */ /** Algebraic notation string, e.g. "e4". */
@@ -23,8 +26,8 @@ final case class Square(file: File, rank: Rank):
s"${file.toString.toLowerCase}${rank.ordinal + 1}" s"${file.toString.toLowerCase}${rank.ordinal + 1}"
object Square: object Square:
/** Parse a square from algebraic notation (e.g. "e4"). 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] = def fromAlgebraic(s: String): Option[Square] =
if s.length != 2 then None if s.length != 2 then None
else else
@@ -32,7 +35,9 @@ object Square:
val rankChar = s.charAt(1) val rankChar = s.charAt(1)
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString)) val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
val rankOpt = val rankOpt =
rankChar.toString.toIntOption.flatMap(n => 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) for f <- fileOpt; r <- rankOpt yield Square(f, r)
val all: IndexedSeq[Square] = val all: IndexedSeq[Square] =
@@ -41,9 +46,8 @@ object Square:
f <- File.values.toIndexedSeq f <- File.values.toIndexedSeq
yield Square(f, r) yield Square(f, r)
/** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board /** Compute a target square by offsetting file and rank.
* (0-7 range). * Returns None if the resulting square is outside the board (0-7 range). */
*/
extension (sq: Square) extension (sq: Square)
def offset(fileDelta: Int, rankDelta: Int): Option[Square] = def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
val newFileOrd = sq.file.ordinal + fileDelta val newFileOrd = sq.file.ordinal + fileDelta
@@ -1,9 +0,0 @@
package de.nowchess.api.game
/** Reason why a game ended in a draw. */
enum DrawReason:
case Stalemate
case InsufficientMaterial
case FiftyMoveRule
case ThreefoldRepetition
case Agreement
@@ -1,9 +1,10 @@
package de.nowchess.api.game package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, Square} import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
import de.nowchess.api.move.Move 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( case class GameContext(
board: Board, board: Board,
@@ -11,9 +12,7 @@ case class GameContext(
castlingRights: CastlingRights, castlingRights: CastlingRights,
enPassantSquare: Option[Square], enPassantSquare: Option[Square],
halfMoveClock: Int, halfMoveClock: Int,
moves: List[Move], moves: List[Move]
result: Option[GameResult] = None,
initialBoard: Board = Board.initial,
): ):
/** Create new context with updated board. */ /** Create new context with updated board. */
def withBoard(newBoard: Board): GameContext = copy(board = newBoard) def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
@@ -33,9 +32,6 @@ case class GameContext(
/** Create new context with move appended to history. */ /** Create new context with move appended to history. */
def withMove(move: Move): GameContext = copy(moves = moves :+ move) 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: object GameContext:
/** Initial position: white to move, all castling rights, no en passant. */ /** Initial position: white to move, all castling rights, no en passant. */
def initial: GameContext = GameContext( def initial: GameContext = GameContext(
@@ -44,5 +40,5 @@ object GameContext:
castlingRights = CastlingRights.Initial, castlingRights = CastlingRights.Initial,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty, moves = List.empty
) )
@@ -1,8 +0,0 @@
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,30 +10,24 @@ enum PromotionPiece:
enum MoveType: enum MoveType:
/** A normal move or capture with no special rule. */ /** A normal move or capture with no special rule. */
case Normal(isCapture: Boolean = false) case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */ /** Kingside castling (O-O). */
case CastleKingside case CastleKingside
/** Queenside castling (O-O-O). */ /** Queenside castling (O-O-O). */
case CastleQueenside case CastleQueenside
/** En-passant pawn capture. */ /** En-passant pawn capture. */
case EnPassant case EnPassant
/** Pawn promotion; carries the chosen promotion piece. */ /** Pawn promotion; carries the chosen promotion piece. */
case Promotion(piece: PromotionPiece) case Promotion(piece: PromotionPiece)
/** A half-move (ply) in a chess game. /**
* A half-move (ply) in a chess game.
* *
* @param from * @param from origin square
* origin square * @param to destination square
* @param to * @param moveType special semantics; defaults to Normal
* destination square
* @param moveType
* special semantics; defaults to Normal
*/ */
final case class Move( final case class Move(
from: Square, from: Square,
to: Square, to: Square,
moveType: MoveType = MoveType.Normal(), moveType: MoveType = MoveType.Normal()
) )
@@ -1,8 +1,10 @@
package de.nowchess.api.player package de.nowchess.api.player
/** An opaque player identifier. /**
* An opaque player identifier.
* *
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time. * Wraps a plain String so that IDs are not accidentally interchanged with
* other String values at compile time.
*/ */
opaque type PlayerId = String opaque type PlayerId = String
@@ -10,17 +12,16 @@ object PlayerId:
def apply(value: String): PlayerId = value def apply(value: String): PlayerId = value
extension (id: PlayerId) def value: String = id extension (id: PlayerId) def value: String = id
/** The minimal cross-service identity stub for a player. /**
* The minimal cross-service identity stub for a player.
* *
* Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs * Full profile data (email, rating history, etc.) lives in the user-management
* is held here. * service. Only what every service needs is held here.
* *
* @param id * @param id unique identifier
* unique identifier * @param displayName human-readable name shown in the UI
* @param displayName
* human-readable name shown in the UI
*/ */
final case class PlayerInfo( final case class PlayerInfo(
id: PlayerId, id: PlayerId,
displayName: String, displayName: String
) )
@@ -1,11 +1,12 @@
package de.nowchess.api.response package de.nowchess.api.response
/** A standardised envelope for every API response. /**
* A standardised envelope for every API response.
* *
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively. * Success and failure are modelled as subtypes so that callers
* can pattern-match exhaustively.
* *
* @tparam A * @tparam A the payload type for a successful response
* the payload type for a successful response
*/ */
sealed trait ApiResponse[+A] sealed trait ApiResponse[+A]
@@ -19,49 +20,43 @@ object ApiResponse:
/** Convenience constructor for a single-error failure. */ /** Convenience constructor for a single-error failure. */
def error(err: ApiError): Failure = Failure(List(err)) def error(err: ApiError): Failure = Failure(List(err))
/** A structured error descriptor. /**
* A structured error descriptor.
* *
* @param code * @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND") * @param message human-readable explanation
* @param message * @param field optional field name when the error relates to a specific input
* human-readable explanation
* @param field
* optional field name when the error relates to a specific input
*/ */
final case class ApiError( final case class ApiError(
code: String, code: String,
message: String, message: String,
field: Option[String] = None, field: Option[String] = None
) )
/** Pagination metadata for list responses. /**
* Pagination metadata for list responses.
* *
* @param page * @param page current 0-based page index
* current 0-based page index * @param pageSize number of items per page
* @param pageSize * @param totalItems total number of items across all pages
* number of items per page
* @param totalItems
* total number of items across all pages
*/ */
final case class Pagination( final case class Pagination(
page: Int, page: Int,
pageSize: Int, pageSize: Int,
totalItems: Long, totalItems: Long
): ):
def totalPages: Int = def totalPages: Int =
if pageSize <= 0 then 0 if pageSize <= 0 then 0
else Math.ceil(totalItems.toDouble / pageSize).toInt else Math.ceil(totalItems.toDouble / pageSize).toInt
/** A paginated list response envelope. /**
* A paginated list response envelope.
* *
* @param items * @param items the items on the current page
* the items on the current page * @param pagination pagination metadata
* @param pagination * @tparam A the item type
* pagination metadata
* @tparam A
* the item type
*/ */
final case class PagedResponse[A]( final case class PagedResponse[A](
items: List[A], items: List[A],
pagination: Pagination, pagination: Pagination
) )
@@ -51,14 +51,8 @@ class BoardTest extends AnyFunSuite with Matchers:
test("initial board white back rank") { test("initial board white back rank") {
val expectedBackRank = Vector( val expectedBackRank = Vector(
PieceType.Rook, PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
PieceType.Knight, PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
File.values.zipWithIndex.foreach { (file, i) => File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
@@ -68,14 +62,8 @@ class BoardTest extends AnyFunSuite with Matchers:
test("initial board black back rank") { test("initial board black back rank") {
val expectedBackRank = Vector( val expectedBackRank = Vector(
PieceType.Rook, PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
PieceType.Knight, PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
File.values.zipWithIndex.foreach { (file, i) => File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
@@ -88,7 +76,8 @@ class BoardTest extends AnyFunSuite with Matchers:
for for
rank <- emptyRanks rank <- emptyRanks
file <- File.values 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") { test("updated adds and replaces piece at squares") {
@@ -116,3 +105,4 @@ class BoardTest extends AnyFunSuite with Matchers:
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn) moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
moved.pieceAt(e2) shouldBe None moved.pieceAt(e2) shouldBe None
} }
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true, blackQueenSide = true
) )
rights.hasAnyRights shouldBe true rights.hasAnyRights shouldBe true
@@ -54,3 +54,4 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black) val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
blackQueenSideRevoked.blackKingSide shouldBe true blackQueenSideRevoked.blackKingSide shouldBe true
blackQueenSideRevoked.blackQueenSide shouldBe false blackQueenSideRevoked.blackQueenSide shouldBe false
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
test("Color values expose opposite and label consistently"): test("Color values expose opposite and label consistently"):
val cases = List( val cases = List(
(Color.White, Color.Black, "White"), (Color.White, Color.Black, "White"),
(Color.Black, Color.White, "Black"), (Color.Black, Color.White, "Black")
) )
cases.foreach { (color, opposite, label) => cases.foreach { (color, opposite, label) =>
@@ -24,7 +24,7 @@ class PieceTest extends AnyFunSuite with Matchers:
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop), Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook), Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen), Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
Piece.BlackKing -> Piece(Color.Black, PieceType.King), Piece.BlackKing -> Piece(Color.Black, PieceType.King)
) )
expected.foreach { case (actual, wanted) => expected.foreach { case (actual, wanted) =>
@@ -12,7 +12,7 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
PieceType.Bishop -> "Bishop", PieceType.Bishop -> "Bishop",
PieceType.Rook -> "Rook", PieceType.Rook -> "Rook",
PieceType.Queen -> "Queen", PieceType.Queen -> "Queen",
PieceType.King -> "King", PieceType.King -> "King"
) )
expectedLabels.foreach { (pieceType, expectedLabel) => expectedLabels.foreach { (pieceType, expectedLabel) =>
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
"a1" -> Square(File.A, Rank.R1), "a1" -> Square(File.A, Rank.R1),
"e4" -> Square(File.E, Rank.R4), "e4" -> Square(File.E, Rank.R4),
"h8" -> Square(File.H, Rank.R8), "h8" -> Square(File.H, Rank.R8),
"E4" -> Square(File.E, Rank.R4), "E4" -> Square(File.E, Rank.R4)
) )
expected.foreach { case (raw, sq) => expected.foreach { case (raw, sq) =>
Square.fromAlgebraic(raw) shouldBe Some(sq) Square.fromAlgebraic(raw) shouldBe Some(sq)
@@ -34,3 +34,4 @@ class SquareTest extends AnyFunSuite with Matchers:
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
Square(File.H, Rank.R8).offset(0, 1) shouldBe None Square(File.H, Rank.R8).offset(0, 1) shouldBe None
} }
@@ -2,7 +2,6 @@ package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square} import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.api.game.{DrawReason, GameResult}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -17,7 +16,6 @@ class GameContextTest extends AnyFunSuite with Matchers:
initial.enPassantSquare shouldBe None initial.enPassantSquare shouldBe None
initial.halfMoveClock shouldBe 0 initial.halfMoveClock shouldBe 0
initial.moves shouldBe List.empty initial.moves shouldBe List.empty
initial.result shouldBe None
test("withBoard updates only board"): test("withBoard updates only board"):
val square = Square(File.E, Rank.R4) val square = Square(File.E, Rank.R4)
@@ -36,7 +34,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true, blackQueenSide = true
) )
val square = Some(Square(File.E, Rank.R3)) val square = Some(Square(File.E, Rank.R3))
val updatedTurn = initial.withTurn(Color.Black) val updatedTurn = initial.withTurn(Color.Black)
@@ -60,14 +58,3 @@ class GameContextTest extends AnyFunSuite with Matchers:
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
GameContext.initial.withMove(move).moves shouldBe List(move) 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.Queen),
MoveType.Promotion(PromotionPiece.Rook), MoveType.Promotion(PromotionPiece.Rook),
MoveType.Promotion(PromotionPiece.Bishop), MoveType.Promotion(PromotionPiece.Bishop),
MoveType.Promotion(PromotionPiece.Knight), MoveType.Promotion(PromotionPiece.Knight)
) )
moveTypes.foreach { moveType => moveTypes.foreach { moveType =>
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=5 MINOR=3
PATCH=0 PATCH=0
-49
View File
@@ -211,52 +211,3 @@
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150)) * correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189)) * update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762)) * update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-12)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-14)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
@@ -1,10 +1,10 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Piece, Square} import de.nowchess.api.board.{Square, Piece}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state /** Marker trait for all commands that can be executed and undone.
* transitions. * Commands encapsulate user actions and game state transitions.
*/ */
trait Command: trait Command:
/** Execute the command and return true if successful, false otherwise. */ /** Execute the command and return true if successful, false otherwise. */
@@ -16,14 +16,15 @@ trait Command:
/** A human-readable description of this command. */ /** A human-readable description of this command. */
def description: String def description: String
/** Command to move a piece from one square to another. 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( case class MoveCommand(
from: Square, from: Square,
to: Square, to: Square,
moveResult: Option[MoveResult] = None, moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None, previousContext: Option[GameContext] = None,
notation: String = "", notation: String = ""
) extends Command: ) extends Command:
override def execute(): Boolean = override def execute(): Boolean =
@@ -49,7 +50,7 @@ case class QuitCommand() extends Command:
/** Command to reset the board to initial position. */ /** Command to reset the board to initial position. */
case class ResetCommand( case class ResetCommand(
previousContext: Option[GameContext] = None, previousContext: Option[GameContext] = None
) extends Command: ) extends Command:
override def execute(): Boolean = true override def execute(): Boolean = true
@@ -3,19 +3,21 @@ package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */ /** Manages command execution and history for undo/redo support. */
class CommandInvoker: class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]() private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentIndex = -1 private var currentIndex = -1
/** Execute a command and add it to history. 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 { def execute(command: Command): Boolean = synchronized {
if command.execute() then if command.execute() then
// Remove any commands after current index (redo stack is discarded) // Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1) while currentIndex < executedCommands.size - 1 do
executedCommands.remove(executedCommands.size - 1)
executedCommands += command executedCommands += command
currentIndex += 1 currentIndex += 1
true true
else false else
false
} }
/** Undo the last executed command if possible. */ /** Undo the last executed command if possible. */
@@ -25,8 +27,10 @@ class CommandInvoker:
if command.undo() then if command.undo() then
currentIndex -= 1 currentIndex -= 1
true true
else false else
else false false
else
false
} }
/** Redo the next command in history if available. */ /** Redo the next command in history if available. */
@@ -36,8 +40,10 @@ class CommandInvoker:
if command.execute() then if command.execute() then
currentIndex += 1 currentIndex += 1
true true
else false else
else false false
else
false
} }
/** Get the history of all executed commands. */ /** Get the history of all executed commands. */
@@ -4,25 +4,21 @@ import de.nowchess.api.board.{File, Rank, Square}
object Parser: object Parser:
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected /** Parses coordinate notation such as "e2e4" or "g1f3".
* format. * Returns None for any input that does not match the expected format.
*/ */
def parseMove(input: String): Option[(Square, Square)] = def parseMove(input: String): Option[(Square, Square)] =
val trimmed = input.trim.toLowerCase val trimmed = input.trim.toLowerCase
Option Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
.when(trimmed.length == 4)(trimmed)
.flatMap: s =>
for for
from <- parseSquare(s.substring(0, 2)) from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4)) to <- parseSquare(s.substring(2, 4))
yield (from, to) yield (from, to)
private def parseSquare(s: String): Option[Square] = private def parseSquare(s: String): Option[Square] =
Option Option.when(s.length == 2)(s).flatMap: sq =>
.when(s.length == 2)(s)
.flatMap: sq =>
val fileIdx = sq(0) - 'a' val fileIdx = sq(0) - 'a'
val rankIdx = sq(1) - '1' val rankIdx = sq(1) - '1'
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx)), Square(File.values(fileIdx), Rank.values(rankIdx))
) )
@@ -2,54 +2,48 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square} import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{DrawReason, GameContext, GameResult} import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextExport, GameContextImport} import de.nowchess.io.{GameContextImport, GameContextExport}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules 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 /** Pure game engine that manages game state and notifies observers of state changes.
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents. * All rule queries delegate to the injected RuleSet.
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/ */
class GameEngine( class GameEngine(
val initialContext: GameContext = GameContext.initial, val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules, val ruleSet: RuleSet = DefaultRules
) extends Observable: ) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection private var currentContext: GameContext = initialContext
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
initialContext.copy(initialBoard = initialContext.board)
else
initialContext
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
private val invoker = new CommandInvoker() private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext) private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingPromotion: Option[PendingPromotion] = None private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */ /** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined) def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
// Synchronized accessors for current state // Synchronized accessors for current state
def board: Board = synchronized(currentContext.board) def board: Board = synchronized { currentContext.board }
def turn: Color = synchronized(currentContext.turn) def turn: Color = synchronized { currentContext.turn }
def context: GameContext = synchronized(currentContext) def context: GameContext = synchronized { currentContext }
/** Check if undo is available. */ /** Check if undo is available. */
def canUndo: Boolean = synchronized(invoker.canUndo) def canUndo: Boolean = synchronized { invoker.canUndo }
/** Check if redo is available. */ /** Check if redo is available. */
def canRedo: Boolean = synchronized(invoker.canRedo) def canRedo: Boolean = synchronized { invoker.canRedo }
/** Get the command history for inspection (testing/debugging). */ /** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history) def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via /** Process a raw move input string and update game state if valid.
* GameEvent. * Notifies all observers of the outcome via GameEvent.
*/ */
def processUserInput(rawInput: String): Unit = synchronized { def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase val trimmed = rawInput.trim.toLowerCase
@@ -65,20 +59,13 @@ class GameEngine(
case "draw" => case "draw" =>
if currentContext.halfMoveClock >= 100 then if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear() invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule)) notifyObservers(DrawClaimedEvent(currentContext))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else else
notifyObservers( notifyObservers(InvalidMoveEvent(
InvalidMoveEvent(
currentContext, currentContext,
"Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.", "Draw cannot be claimed: the 50-move rule has not been triggered."
), ))
)
case "" => case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command.")) notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
@@ -86,12 +73,10 @@ class GameEngine(
case moveInput => case moveInput =>
Parser.parseMove(moveInput) match Parser.parseMove(moveInput) match
case None => case None =>
notifyObservers( notifyObservers(InvalidMoveEvent(
InvalidMoveEvent(
currentContext, currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.", s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
), ))
)
case Some((from, to)) => case Some((from, to)) =>
handleParsedMove(from, to) handleParsedMove(from, to)
} }
@@ -123,7 +108,8 @@ class GameEngine(
to.rank.ordinal == promoRank 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 { def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match pendingPromotion match
@@ -134,18 +120,22 @@ class GameEngine(
val move = Move(pending.from, pending.to, MoveType.Promotion(piece)) val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal // Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from) val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then executeMove(move) if legal.contains(move) then
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion.")) executeMove(move)
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
} }
/** Undo the last move. */ /** Undo the last move. */
def undo(): Unit = synchronized(performUndo()) def undo(): Unit = synchronized { performUndo() }
/** Redo the last undone move. */ /** 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 /** Load a game using the provided importer.
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success. * 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 { def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match importer.importGameContext(input) match
@@ -163,26 +153,31 @@ class GameEngine(
invoker.clear() invoker.clear()
if ctx.moves.isEmpty then if ctx.moves.isEmpty then
currentContext = ctx.copy(initialBoard = ctx.board) currentContext = ctx
Right(()) Right(())
else replayMoves(ctx.moves, savedContext) else
replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] = private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) => var error: Option[String] = None
acc.flatMap(_ => applyReplayMove(move)) moves.foreach: move =>
} if error.isEmpty then
result.left.foreach(_ => currentContext = savedContext)
result
private def applyReplayMove(move: Move): Either[String, Unit] =
handleParsedMove(move.from, move.to) handleParsedMove(move.from, move.to)
move.moveType match
case MoveType.Promotion(pp) if pendingPromotion.isDefined => move.moveType match {
case MoveType.Promotion(pp) =>
if pendingPromotion.isDefined then
completePromotion(pp) completePromotion(pp)
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
}
error match
case Some(err) =>
currentContext = savedContext
Left(err)
case None =>
Right(()) 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. */ /** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized { def exportGame(exporter: GameContextExport): String = synchronized {
@@ -191,11 +186,7 @@ class GameEngine(
/** Load an arbitrary board position, clearing all history and undo/redo state. */ /** Load an arbitrary board position, clearing all history and undo/redo state. */
def loadPosition(newContext: GameContext): Unit = synchronized { def loadPosition(newContext: GameContext): Unit = synchronized {
val contextWithInitialBoard = if newContext.moves.isEmpty then currentContext = newContext
newContext.copy(initialBoard = newContext.board)
else
newContext
currentContext = contextWithInitialBoard
pendingPromotion = None pendingPromotion = None
invoker.clear() invoker.clear()
notifyObservers(BoardResetEvent(currentContext)) notifyObservers(BoardResetEvent(currentContext))
@@ -220,37 +211,32 @@ class GameEngine(
to = move.to, to = move.to,
moveResult = Some(MoveResult.Successful(nextContext, captured)), moveResult = Some(MoveResult.Successful(nextContext, captured)),
previousContext = Some(contextBefore), previousContext = Some(contextBefore),
notation = translateMoveToNotation(move, contextBefore.board), notation = translateMoveToNotation(move, contextBefore.board)
) )
invoker.execute(cmd) invoker.execute(cmd)
currentContext = nextContext currentContext = nextContext
notifyObservers( notifyObservers(MoveExecutedEvent(
MoveExecutedEvent(
currentContext, currentContext,
move.from.toString, move.from.toString,
move.to.toString, move.to.toString,
captured.map(c => s"${c.color.label} ${c.pieceType.label}"), captured.map(c => s"${c.color.label} ${c.pieceType.label}")
), ))
)
if ruleSet.isCheckmate(currentContext) then if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
notifyObservers(CheckmateEvent(currentContext, winner)) notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear() invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isStalemate(currentContext) then else if ruleSet.isStalemate(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate))) notifyObservers(StalemateEvent(currentContext))
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear() invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then currentContext = GameContext.initial
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial))) else if ruleSet.isCheck(currentContext) then
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial)) notifyObservers(CheckDetectedEvent(currentContext))
invoker.clear()
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) if currentContext.halfMoveClock >= 100 then
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext)) notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match move.moveType match
@@ -309,7 +295,8 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _) moveCmd.previousContext.foreach(currentContext = _)
invoker.undo() invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation)) notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo.")) else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit = private def performRedo(): Unit =
if invoker.canRedo then if invoker.canRedo then
@@ -320,13 +307,12 @@ class GameEngine(
currentContext = nextCtx currentContext = nextCtx
invoker.redo() invoker.redo()
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}") val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers( notifyObservers(MoveRedoneEvent(
MoveRedoneEvent(
currentContext, currentContext,
moveCmd.notation, moveCmd.notation,
moveCmd.from.toString, moveCmd.from.toString,
moveCmd.to.toString, moveCmd.to.toString,
capturedDesc, capturedDesc
), ))
) else
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo.")) notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -1,9 +1,10 @@
package de.nowchess.chess.observer package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square} import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.{DrawReason, GameContext} import de.nowchess.api.game.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: sealed trait GameEvent:
def context: GameContext def context: GameContext
@@ -13,58 +14,57 @@ case class MoveExecutedEvent(
context: GameContext, context: GameContext,
fromSquare: String, fromSquare: String,
toSquare: String, toSquare: String,
capturedPiece: Option[String], capturedPiece: Option[String]
) extends GameEvent ) extends GameEvent
/** Fired when the current player is in check. */ /** Fired when the current player is in check. */
case class CheckDetectedEvent( case class CheckDetectedEvent(
context: GameContext, context: GameContext
) extends GameEvent ) extends GameEvent
/** Fired when the game reaches checkmate. */ /** Fired when the game reaches checkmate. */
case class CheckmateEvent( case class CheckmateEvent(
context: GameContext, context: GameContext,
winner: Color, winner: Color
) extends GameEvent ) extends GameEvent
/** Fired when the game ends in a draw. */ /** Fired when the game reaches stalemate. */
case class DrawEvent( case class StalemateEvent(
context: GameContext, context: GameContext
reason: DrawReason,
) extends GameEvent ) extends GameEvent
/** Fired when a move is invalid. */ /** Fired when a move is invalid. */
case class InvalidMoveEvent( case class InvalidMoveEvent(
context: GameContext, context: GameContext,
reason: String, reason: String
) extends GameEvent ) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ /** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent( case class PromotionRequiredEvent(
context: GameContext, context: GameContext,
from: Square, from: Square,
to: Square, to: Square
) extends GameEvent ) extends GameEvent
/** Fired when the board is reset. */ /** Fired when the board is reset. */
case class BoardResetEvent( case class BoardResetEvent(
context: GameContext, context: GameContext
) extends GameEvent ) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */ /** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent( case class FiftyMoveRuleAvailableEvent(
context: GameContext, context: GameContext
) extends GameEvent ) extends GameEvent
/** Fired after any move where the same position occurs for the third time — threefold repetition is now claimable. */ /** Fired when a player successfully claims a draw under the 50-move rule. */
case class ThreefoldRepetitionAvailableEvent( case class DrawClaimedEvent(
context: GameContext, context: GameContext
) extends GameEvent ) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */ /** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent( case class MoveUndoneEvent(
context: GameContext, context: GameContext,
pgnNotation: String, pgnNotation: String
) extends GameEvent ) extends GameEvent
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */ /** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
@@ -73,12 +73,12 @@ case class MoveRedoneEvent(
pgnNotation: String, pgnNotation: String,
fromSquare: String, fromSquare: String,
toSquare: String, toSquare: String,
capturedPiece: Option[String], capturedPiece: Option[String]
) extends GameEvent ) extends GameEvent
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */ /** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent( case class PgnLoadedEvent(
context: GameContext, context: GameContext
) extends GameEvent ) extends GameEvent
/** Observer trait: implement to receive game state updates. */ /** Observer trait: implement to receive game state updates. */
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -14,14 +14,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
override def undo(): Boolean = false override def undo(): Boolean = false
override def description: String = "Failing command" override def description: String = "Failing command"
private class ConditionalFailCommand( private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
initialShouldFailOnUndo: Boolean = false, override def execute(): Boolean = !shouldFailOnExecute
initialShouldFailOnExecute: Boolean = false, override def undo(): Boolean = !shouldFailOnUndo
) extends Command:
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
override def execute(): Boolean = !shouldFailOnExecute.get()
override def undo(): Boolean = !shouldFailOnUndo.get()
override def description: String = "Conditional fail" override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
@@ -29,7 +24,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
from = from, from = from,
to = to, to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None, 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"): test("execute rejects failing commands and keeps history unchanged"):
@@ -68,7 +63,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true) val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true invoker.canUndo shouldBe true
invoker.undo() shouldBe false invoker.undo() shouldBe false
@@ -104,7 +99,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
invoker.execute(redoFailCmd) invoker.execute(redoFailCmd)
invoker.undo() invoker.undo()
invoker.canRedo shouldBe true invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute.set(true) redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
} }
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -14,7 +14,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
from = from, from = from,
to = to, to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial), previousContext = Some(GameContext.initial)
) )
test("execute appends commands and updates index"): test("execute appends commands and updates index"):
@@ -8,6 +8,7 @@ class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand properties and behavior"): test("QuitCommand properties and behavior"):
val cmd = QuitCommand() val cmd = QuitCommand()
cmd shouldNot be(null)
cmd.execute() shouldBe true cmd.execute() shouldBe true
cmd.undo() shouldBe false cmd.undo() shouldBe false
cmd.description shouldBe "Quit game" cmd.description shouldBe "Quit game"
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val executable = MoveCommand( val executable = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None))
) )
executable.execute() shouldBe true executable.execute() shouldBe true
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial), previousContext = Some(GameContext.initial)
) )
undoable.undo() shouldBe true undoable.undo() shouldBe true
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val result = MoveResult.Successful(GameContext.initial, None) val result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy( val cmd2 = cmd1.copy(
moveResult = Some(result), moveResult = Some(result),
previousContext = Some(GameContext.initial), previousContext = Some(GameContext.initial)
) )
cmd1.moveResult shouldBe None cmd1.moveResult shouldBe None
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None, previousContext = None
) )
val eq2 = MoveCommand( val eq2 = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None, previousContext = None
) )
eq1 shouldBe eq2 eq1 shouldBe eq2
@@ -31,7 +31,7 @@ object EngineTestHelpers:
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean = def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
_events.exists(ct.runtimeClass.isInstance(_)) _events.exists(ct.runtimeClass.isInstance(_))
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] = def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
_events.collectFirst { case e: T => e } _events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
override def onGameEvent(event: GameEvent): Unit = override def onGameEvent(event: GameEvent): Unit =
_events += event _events += event
@@ -1,9 +1,8 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.Color import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.DrawReason import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -26,9 +25,13 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) // Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent] observer.events.last shouldBe a[CheckmateEvent]
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get val event = observer.events.last.asInstanceOf[CheckmateEvent]
event.winner shouldBe Color.Black 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"): test("GameEngine handles check detection"):
val engine = new GameEngine() val engine = new GameEngine()
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
@@ -57,25 +60,16 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "e2e3", "a7a5",
"a7a5", "d1h5", "a8a6",
"d1h5", "h5a5", "h7h5",
"a8a6", "h2h4", "a6h6",
"h5a5", "a5c7", "f7f6",
"h7h5", "c7d7", "e8f7",
"h2h4", "d7b7", "d8d3",
"a6h6", "b7b8", "d3h7",
"a5c7", "b8c8", "f7g6",
"f7f6", "c8e6"
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
"c8e6",
) )
moves.dropRight(1).foreach(engine.processUserInput) moves.dropRight(1).foreach(engine.processUserInput)
@@ -83,9 +77,12 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
observer.events.clear() observer.events.clear()
engine.processUserInput(moves.last) engine.processUserInput(moves.last)
val drawEvents = observer.events.collect { case e: DrawEvent => e } val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
drawEvents.size shouldBe 1 stalemateEvents.size shouldBe 1
drawEvents.head.reason shouldBe DrawReason.Stalemate
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer: private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]() val events = mutable.ListBuffer[GameEvent]()
@@ -38,7 +38,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.processUserInput("undo") engine.processUserInput("undo")
engine.processUserInput("redo") engine.processUserInput("redo")
events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3 events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"): test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -69,7 +69,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.context shouldBe target engine.context shouldBe target
engine.commandHistory shouldBe empty engine.commandHistory shouldBe empty
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
test("redo event includes captured piece description when replaying a capture"): test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -98,7 +98,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move) def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
val engine = new GameEngine(ruleSet = permissiveRules) val engine = new GameEngine(ruleSet = permissiveRules)
@@ -120,7 +119,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves) val engine = new GameEngine(ruleSet = noLegalMoves)
@@ -153,6 +151,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1") engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
engine.context shouldBe saved engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"): test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine() val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false) val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
@@ -175,3 +174,5 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.observerCount shouldBe 1 engine.observerCount shouldBe 1
engine.unsubscribe(observer) engine.unsubscribe(observer)
engine.observerCount shouldBe 0 engine.observerCount shouldBe 0
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent} import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
import de.nowchess.io.pgn.PgnParser import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.io.pgn.PgnExporter import de.nowchess.io.pgn.PgnExporter
@@ -31,7 +31,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
whiteKingSide = false, whiteKingSide = false,
whiteQueenSide = true, whiteQueenSide = true,
blackKingSide = false, blackKingSide = false,
blackQueenSide = false, blackQueenSide = false
) )
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White castles queenside: e1c1 // White castles queenside: e1c1
engine.processUserInput("e1c1") engine.processUserInput("e1c1")
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.clear() events.clear()
engine.undo() engine.undo()
@@ -68,7 +68,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White pawn on e5 captures en passant to d6 // White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6") engine.processUserInput("e5d6")
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
// Verify the captured pawn was found (computeCaptured EnPassant branch) // Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
@@ -84,8 +84,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── Bishop underpromotion notation (line 230) ────────────────────── // ── Bishop underpromotion notation (line 230) ──────────────────────
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"): test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
// 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/k7/7K").get
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
.withTurn(Color.White) .withTurn(Color.White)
@@ -106,8 +105,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── King normal move notation (line 246) ─────────────────────────── // ── King normal move notation (line 246) ───────────────────────────
test("undo after king move emits MoveUndoneEvent with K notation"): test("undo after king move emits MoveUndoneEvent with K notation"):
// White king on e1, white rook on h1 — K+R vs K ensures sufficient material after the king move // White king on e1, no castling rights, black king far away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
.withTurn(Color.White) .withTurn(Color.White)
@@ -118,7 +117,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// King moves e1 -> f1 // King moves e1 -> f1
engine.processUserInput("e1f1") engine.processUserInput("e1f1")
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.clear() events.clear()
engine.undo() engine.undo()
@@ -1,7 +1,6 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.{DrawReason, GameResult}
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -23,7 +22,6 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4") engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true observer.hasEvent[CheckmateEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
test("checkmate with white winner"): test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
@@ -43,76 +41,54 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val evt = observer.getEvent[CheckmateEvent] val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White evt.get.winner shouldBe Color.White
engine.context.result shouldBe Some(GameResult.Win(Color.White))
// ── Stalemate ─────────────────────────────────────────────────── // ── Stalemate ───────────────────────────────────────────────────
test("stalemate ends game with DrawEvent(Stalemate)"): test("stalemate ends game with StalemateEvent"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "e2e3", "a7a5",
"a7a5", "d1h5", "a8a6",
"d1h5", "h5a5", "h7h5",
"a8a6", "h2h4", "a6h6",
"h5a5", "a5c7", "f7f6",
"h7h5", "c7d7", "e8f7",
"h2h4", "d7b7", "d8d3",
"a6h6", "b7b8", "d3h7",
"a5c7", "b8c8", "f7g6"
"f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
) )
moves.foreach(engine.processUserInput) moves.foreach(engine.processUserInput)
observer.clear() observer.clear()
engine.processUserInput("c8e6") engine.processUserInput("c8e6")
val evt = observer.getEvent[DrawEvent] observer.hasEvent[StalemateEvent] shouldBe true
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.Stalemate
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.Stalemate))
test("stalemate board is not reset after draw"): test("stalemate when king has no moves and no pieces"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "e2e3", "a7a5",
"a7a5", "d1h5", "a8a6",
"d1h5", "h5a5", "h7h5",
"a8a6", "h2h4", "a6h6",
"h5a5", "a5c7", "f7f6",
"h7h5", "c7d7", "e8f7",
"h2h4", "d7b7", "d8d3",
"a6h6", "b7b8", "d3h7",
"a5c7", "b8c8", "f7g6",
"f7f6", "c8e6"
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
"c8e6",
) )
moves.foreach(engine.processUserInput) moves.foreach(engine.processUserInput)
observer.hasEvent[DrawEvent] shouldBe true observer.hasEvent[StalemateEvent] shouldBe true
engine.turn shouldBe Color.Black engine.turn shouldBe Color.White
// ── Check detection ──────────────────────────────────────────── // ── Check detection ────────────────────────────────────────────
@@ -136,8 +112,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// White has K+N+Q so the position is not insufficient material after Nd4f5 EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/4K3 w - - 0 1")
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/3QK3 w - - 0 1")
observer.clear() observer.clear()
engine.processUserInput("d4f5") engine.processUserInput("d4f5")
@@ -189,10 +164,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw") engine.processUserInput("draw")
val evt = observer.getEvent[DrawEvent] observer.hasEvent[DrawClaimedEvent] shouldBe true
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"): test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
@@ -202,94 +174,3 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw") engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true 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))
// ── Threefold Repetition ──────────────────────────────────────────
test("draw command rejected when neither 50-move rule nor threefold repetition available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.clear()
engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true
test("threefold repetition fires ThreefoldRepetitionAvailableEvent after 8-move shuffle"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Both knights shuffle home: initial position occurs 3 times on move 8 (Ng8)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
observer.clear()
engine.processUserInput("f6g8") // 3rd occurrence of initial position
observer.hasEvent[ThreefoldRepetitionAvailableEvent] shouldBe true
engine.context.result shouldBe None // claimable, not automatic
test("draw claim via threefold repetition ends game with DrawEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8") // threefold now available
observer.clear()
engine.processUserInput("draw")
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.ThreefoldRepetition
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
test("loadPosition with non-empty moves preserves context as-is"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Build a context that already has a move in its history
val move = de.nowchess.api.move.Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
)
val ctxWithMove = de.nowchess.api.game.GameContext.initial.withMove(move)
engine.loadPosition(ctxWithMove)
observer.hasEvent[BoardResetEvent] shouldBe true
@@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7)) events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
} }
@@ -60,7 +60,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) 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.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
} }
test("completePromotion with rook underpromotion") { test("completePromotion with rook underpromotion") {
@@ -80,7 +80,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be (false)
} }
@@ -92,7 +92,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
} }
test("completePromotion results in Moved when promotion doesn't give check") { test("completePromotion results in Moved when promotion doesn't give check") {
@@ -105,8 +105,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.isPendingPromotion should be (false) 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.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
events.collect { case e: MoveExecutedEvent => e } should not be empty events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
} }
test("completePromotion results in Checkmate when promotion delivers checkmate") { test("completePromotion results in Checkmate when promotion delivers checkmate") {
@@ -118,7 +118,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be (false)
events.exists { case _: CheckmateEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
} }
test("completePromotion results in Stalemate when promotion creates stalemate") { test("completePromotion results in Stalemate when promotion creates stalemate") {
@@ -130,7 +130,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Knight) engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be (false)
events.exists { case _: DrawEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
} }
test("completePromotion with black pawn promotion results in Moved") { test("completePromotion with black pawn promotion results in Moved") {
@@ -143,8 +143,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be (false)
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen))) 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.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
} }
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
@@ -173,8 +173,6 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
DefaultRules.isInsufficientMaterial(context) DefaultRules.isInsufficientMaterial(context)
def isFiftyMoveRule(context: GameContext): Boolean = def isFiftyMoveRule(context: GameContext): Boolean =
DefaultRules.isFiftyMoveRule(context) DefaultRules.isFiftyMoveRule(context)
def isThreefoldRepetition(context: GameContext): Boolean =
DefaultRules.isThreefoldRepetition(context)
def applyMove(context: GameContext)(move: Move): GameContext = def applyMove(context: GameContext)(move: Move): GameContext =
DefaultRules.applyMove(context)(move) DefaultRules.applyMove(context)(move)
@@ -193,7 +191,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be (false)
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true) events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last 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 package de.nowchess.chess.engine
import de.nowchess.api.board.{Color, File, Piece, Rank, Square} import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
@@ -122,7 +122,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw") engine.processUserInput("draw")
observer.hasEvent[DrawEvent] shouldBe true observer.hasEvent[DrawClaimedEvent] shouldBe true
// Initial position has no draw available // Initial position has no draw available
observer.clear() observer.clear()
@@ -175,8 +175,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/8 w - - 0 1")
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Bishop) engine.completePromotion(PromotionPiece.Bishop)
observer.clear() observer.clear()
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=13 MINOR=11
PATCH=0 PATCH=0
-36
View File
@@ -11,39 +11,3 @@
* 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-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)) * 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))
## (2026-04-14)
### Features
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-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))
-1
View File
@@ -19,7 +19,6 @@ scala {
scoverage { scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!) scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedFiles.set(listOf(".*FenParserFastParse.*"))
} }
tasks.withType<ScalaCompile> { tasks.withType<ScalaCompile> {
@@ -7,7 +7,8 @@ import scala.util.Try
/** Service for persisting and loading game states to/from disk. /** 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. * Abstracts file I/O operations away from the UI layer.
* Handles both reading and writing game files.
*/ */
trait GameFileService: trait GameFileService:
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
@@ -24,7 +25,7 @@ object FileSystemGameService extends GameFileService:
() ()
}.fold( }.fold(
ex => Left(s"Failed to save file: ${ex.getMessage}"), ex => Left(s"Failed to save file: ${ex.getMessage}"),
_ => Right(()), _ => Right(())
) )
/** Load a game context from a file using the specified importer. */ /** Load a game context from a file using the specified importer. */
@@ -34,5 +35,5 @@ object FileSystemGameService extends GameFileService:
importer.importGameContext(json) importer.importGameContext(json)
}.fold( }.fold(
ex => Left(s"Failed to load file: ${ex.getMessage}"), ex => Left(s"Failed to load file: ${ex.getMessage}"),
result => result, result => result
) )
@@ -15,15 +15,21 @@ object FenExporter extends GameContextExport:
/** Build the FEN representation for a single rank. */ /** Build the FEN representation for a single rank. */
private def buildRankString(board: Board, rank: Rank): String = private def buildRankString(board: Board, rank: Rank): String =
val rankSquares = File.values.map(file => Square(file, rank)) val rankSquares = File.values.map(file => Square(file, rank))
val (result, emptyCount) = rankSquares.foldLeft(("", 0)): val rankChars = scala.collection.mutable.ListBuffer[Char]()
case ((acc, empty), square) => var emptyCount = 0
for square <- rankSquares do
board.pieceAt(square) match board.pieceAt(square) match
case Some(piece) => case Some(piece) =>
val flushed = if empty > 0 then acc + empty.toString else acc if emptyCount > 0 then
(flushed + pieceToFenChar(piece), 0) rankChars += emptyCount.toString.charAt(0)
emptyCount = 0
rankChars += pieceToFenChar(piece)
case None => case None =>
(acc, empty + 1) emptyCount += 1
if emptyCount > 0 then result + emptyCount.toString else result
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
/** Convert a GameContext to a complete FEN string. */ /** Convert a GameContext to a complete FEN string. */
def gameContextToFen(context: GameContext): String = def gameContextToFen(context: GameContext): String =
@@ -55,3 +61,4 @@ object FenExporter extends GameContextExport:
case PieceType.Queen => 'q' case PieceType.Queen => 'q'
case PieceType.King => 'k' case PieceType.King => 'k'
if piece.color == Color.White then base.toUpper else base if piece.color == Color.White then base.toUpper else base
@@ -6,11 +6,12 @@ import de.nowchess.io.GameContextImport
object FenParser extends 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] = def parseFen(fen: String): Either[String, GameContext] =
val parts = fen.trim.split("\\s+") 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 else
for for
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position") board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
@@ -26,7 +27,7 @@ object FenParser extends GameContextImport:
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassant, enPassantSquare = enPassant,
halfMoveClock = halfMoveClock, halfMoveClock = halfMoveClock,
moves = List.empty, moves = List.empty
) )
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
@@ -40,26 +41,25 @@ object FenParser extends GameContextImport:
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */ /** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
private def parseCastling(s: String): Option[CastlingRights] = 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 else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
Some( Some(CastlingRights(
CastlingRights(
whiteKingSide = s.contains('K'), whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'), whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'), blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'), blackQueenSide = s.contains('q')
), ))
) else
else None None
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */ /** Parse en passant target square ("-" for none, or algebraic like "e3"). */
private def parseEnPassant(s: String): Option[Option[Square]] = private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None) if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_)) 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 /** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
* is invalid. * Returns None if the format is invalid. */
*/
def parseBoard(fen: String): Option[Board] = def parseBoard(fen: String): Option[Board] =
val rankStrings = fen.split("/", -1) val rankStrings = fen.split("/", -1)
if rankStrings.length != 8 then None if rankStrings.length != 8 then None
@@ -73,22 +73,28 @@ object FenParser extends GameContextImport:
parsePieceRank(rankStr, rank).map(squares => acc :+ squares) parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
parsedRanks.map(ranks => Board(ranks.flatten.toMap)) 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 /** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
* rank string contains invalid characters or the wrong number of files. * 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)]] = private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])): var fileIdx = 0
case ((idx, true, acc), _) => (idx, true, acc) val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
case ((idx, false, acc), c) => var failed = false
if idx > 7 then (idx, true, acc)
else if c.isDigit then (idx + c.asDigit, false, acc) for c <- rankStr if !failed do
if fileIdx > 7 then
failed = true
else if c.isDigit then
fileIdx += c.asDigit
else else
charToPiece(c) match charToPiece(c) match
case None => (idx, true, acc) case None => failed = true
case Some(piece) => case Some(piece) =>
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece)) val file = File.values(fileIdx)
squares += (Square(file, rank) -> piece)
fileIdx += 1
if failed || fileIdx != 8 then None if failed || fileIdx != 8 then None
else Some(squares) else Some(squares.toList)
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */ /** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
private def charToPiece(c: Char): Option[Piece] = private def charToPiece(c: Char): Option[Piece] =
@@ -102,3 +108,4 @@ object FenParser extends GameContextImport:
case 'k' => Some(PieceType.King) case 'k' => Some(PieceType.King)
case _ => None case _ => None
pieceTypeOpt.map(pt => Piece(color, pt)) pieceTypeOpt.map(pt => Piece(color, pt))
@@ -29,9 +29,8 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken) 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 /** Parse rank string for a given Rank, producing (Square, Piece) pairs.
* placement exceeds board bounds. * Fails if total file count != 8 or any piece placement exceeds board bounds. */
*/
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] = private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
rankTokens >> { tokens => rankTokens >> { tokens =>
buildSquares(rank, tokens) match buildSquares(rank, tokens) match
@@ -52,7 +51,8 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
(rankSep ~> rankParser(Rank.R4)) ~ (rankSep ~> rankParser(Rank.R4)) ~
(rankSep ~> rankParser(Rank.R3)) ~ (rankSep ~> rankParser(Rank.R3)) ~
(rankSep ~> rankParser(Rank.R2)) ~ (rankSep ~> rankParser(Rank.R2)) ~
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 => (rankSep ~> rankParser(Rank.R1)) ^^ {
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
} }
@@ -73,7 +73,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
whiteKingSide = s.contains('K'), whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'), whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'), blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'), blackQueenSide = s.contains('q')
) )
} }
@@ -100,7 +100,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
castlingRights = castling, castlingRights = castling,
enPassantSquare = ep, enPassantSquare = ep,
halfMoveClock = halfMove, halfMoveClock = halfMove,
moves = List.empty, moves = List.empty
) )
} }
@@ -66,7 +66,7 @@ object FenParserFastParse extends GameContextImport:
whiteKingSide = s.contains('K'), whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'), whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'), blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'), blackQueenSide = s.contains('q')
) )
} }
@@ -97,7 +97,7 @@ object FenParserFastParse extends GameContextImport:
castlingRights = castling, castlingRights = castling,
enPassantSquare = ep, enPassantSquare = ep,
halfMoveClock = halfMove, halfMoveClock = halfMove,
moves = List.empty, moves = List.empty
) )
} }
@@ -14,12 +14,11 @@ private[fen] object FenParserSupport:
'n' -> PieceType.Knight, 'n' -> PieceType.Knight,
'b' -> PieceType.Bishop, 'b' -> PieceType.Bishop,
'q' -> PieceType.Queen, 'q' -> PieceType.Queen,
'k' -> PieceType.King, 'k' -> PieceType.King
) )
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] = def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
tokens tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
case (None, _) => None case (None, _) => None
case (Some((acc, fileIdx)), PieceToken(piece)) => case (Some((acc, fileIdx)), PieceToken(piece)) =>
if fileIdx > 7 then None if fileIdx > 7 then None
@@ -8,7 +8,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextExport import de.nowchess.io.GameContextExport
import de.nowchess.io.pgn.PgnExporter import de.nowchess.io.pgn.PgnExporter
import java.time.{LocalDate, ZoneId, ZonedDateTime} import java.time.{LocalDate, ZonedDateTime, ZoneId}
/** Exports a GameContext to a comprehensive JSON format using Jackson. /** Exports a GameContext to a comprehensive JSON format using Jackson.
* *
@@ -42,10 +42,9 @@ object JsonExporter extends GameContextExport:
formatJson(mapper.writeValueAsString(record)) formatJson(mapper.writeValueAsString(record))
private def buildGameRecord(context: GameContext): JsonGameRecord = private def buildGameRecord(context: GameContext): JsonGameRecord =
val pgn = val pgn = try {
try
Some(PgnExporter.exportGameContext(context)) Some(PgnExporter.exportGameContext(context))
catch { } catch {
case _: Exception => None case _: Exception => None
} }
JsonGameRecord( JsonGameRecord(
@@ -54,7 +53,7 @@ object JsonExporter extends GameContextExport:
moveHistory = pgn, moveHistory = pgn,
moves = Some(buildMoves(context.moves)), moves = Some(buildMoves(context.moves)),
capturedPieces = Some(buildCapturedPieces(context.board)), capturedPieces = Some(buildCapturedPieces(context.board)),
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString), timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
) )
private def buildMetadata(): JsonMetadata = private def buildMetadata(): JsonMetadata =
@@ -62,7 +61,7 @@ object JsonExporter extends GameContextExport:
event = Some("Game"), event = Some("Game"),
players = Some(Map("white" -> "White Player", "black" -> "Black Player")), players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
date = Some(LocalDate.now().toString), date = Some(LocalDate.now().toString),
result = Some("*"), result = Some("*")
) )
private def buildGameState(context: GameContext): JsonGameState = private def buildGameState(context: GameContext): JsonGameState =
@@ -71,7 +70,7 @@ object JsonExporter extends GameContextExport:
turn = Some(context.turn.label), turn = Some(context.turn.label),
castlingRights = Some(buildCastlingRights(context.castlingRights)), castlingRights = Some(buildCastlingRights(context.castlingRights)),
enPassantSquare = context.enPassantSquare.map(_.toString), enPassantSquare = context.enPassantSquare.map(_.toString),
halfMoveClock = Some(context.halfMoveClock), halfMoveClock = Some(context.halfMoveClock)
) )
private def buildBoardPieces(board: Board): List[JsonPiece] = private def buildBoardPieces(board: Board): List[JsonPiece] =
@@ -84,7 +83,7 @@ object JsonExporter extends GameContextExport:
Some(rights.whiteKingSide), Some(rights.whiteKingSide),
Some(rights.whiteQueenSide), Some(rights.whiteQueenSide),
Some(rights.blackKingSide), Some(rights.blackKingSide),
Some(rights.blackQueenSide), Some(rights.blackQueenSide)
) )
private def buildMoves(moves: List[Move]): List[JsonMove] = private def buildMoves(moves: List[Move]): List[JsonMove] =
@@ -137,3 +136,4 @@ object JsonExporter extends GameContextExport:
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
(blackCaptured, whiteCaptured) (blackCaptured, whiteCaptured)
@@ -4,20 +4,20 @@ case class JsonMetadata(
event: Option[String] = None, event: Option[String] = None,
players: Option[Map[String, String]] = None, players: Option[Map[String, String]] = None,
date: Option[String] = None, date: Option[String] = None,
result: Option[String] = None, result: Option[String] = None
) )
case class JsonPiece( case class JsonPiece(
square: Option[String] = None, square: Option[String] = None,
color: Option[String] = None, color: Option[String] = None,
piece: Option[String] = None, piece: Option[String] = None
) )
case class JsonCastlingRights( case class JsonCastlingRights(
whiteKingSide: Option[Boolean] = None, whiteKingSide: Option[Boolean] = None,
whiteQueenSide: Option[Boolean] = None, whiteQueenSide: Option[Boolean] = None,
blackKingSide: Option[Boolean] = None, blackKingSide: Option[Boolean] = None,
blackQueenSide: Option[Boolean] = None, blackQueenSide: Option[Boolean] = None
) )
case class JsonGameState( case class JsonGameState(
@@ -25,24 +25,24 @@ case class JsonGameState(
turn: Option[String] = None, turn: Option[String] = None,
castlingRights: Option[JsonCastlingRights] = None, castlingRights: Option[JsonCastlingRights] = None,
enPassantSquare: Option[String] = None, enPassantSquare: Option[String] = None,
halfMoveClock: Option[Int] = None, halfMoveClock: Option[Int] = None
) )
case class JsonCapturedPieces( case class JsonCapturedPieces(
byWhite: Option[List[String]] = None, byWhite: Option[List[String]] = None,
byBlack: Option[List[String]] = None, byBlack: Option[List[String]] = None
) )
case class JsonMoveType( case class JsonMoveType(
`type`: Option[String] = None, `type`: Option[String] = None,
isCapture: Option[Boolean] = None, isCapture: Option[Boolean] = None,
promotionPiece: Option[String] = None, promotionPiece: Option[String] = None
) )
case class JsonMove( case class JsonMove(
from: Option[String] = None, from: Option[String] = None,
to: Option[String] = None, to: Option[String] = None,
`type`: Option[JsonMoveType] = None, `type`: Option[JsonMoveType] = None
) )
case class JsonGameRecord( case class JsonGameRecord(
@@ -51,5 +51,5 @@ case class JsonGameRecord(
moveHistory: Option[String] = None, moveHistory: Option[String] = None,
moves: Option[List[JsonMove]] = None, moves: Option[List[JsonMove]] = None,
capturedPieces: Option[JsonCapturedPieces] = None, capturedPieces: Option[JsonCapturedPieces] = None,
timestamp: Option[String] = None, timestamp: Option[String] = None
) )
@@ -1,6 +1,6 @@
package de.nowchess.io.json package de.nowchess.io.json
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper} import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
@@ -27,8 +27,8 @@ object JsonParser extends GameContextImport:
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
.map(e => "JSON parsing error: " + e.getMessage) .left.map(e => "JSON parsing error: " + e.getMessage)
.flatMap { data => .flatMap { data =>
val gs = data.gameState.getOrElse(JsonGameState()) val gs = data.gameState.getOrElse(JsonGameState())
val rawBoard = gs.board.getOrElse(Nil) val rawBoard = gs.board.getOrElse(Nil)
@@ -49,7 +49,7 @@ object JsonParser extends GameContextImport:
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassantSquare, enPassantSquare = enPassantSquare,
halfMoveClock = rawHmc, halfMoveClock = rawHmc,
moves = moves, moves = moves
) )
} }
@@ -86,7 +86,7 @@ object JsonParser extends GameContextImport:
cr.whiteKingSide.getOrElse(false), cr.whiteKingSide.getOrElse(false),
cr.whiteQueenSide.getOrElse(false), cr.whiteQueenSide.getOrElse(false),
cr.blackKingSide.getOrElse(false), cr.blackKingSide.getOrElse(false),
cr.blackQueenSide.getOrElse(false), cr.blackQueenSide.getOrElse(false)
) )
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] = private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
@@ -14,24 +14,25 @@ object PgnExporter extends GameContextExport:
"Event" -> "?", "Event" -> "?",
"White" -> "?", "White" -> "?",
"Black" -> "?", "Black" -> "?",
"Result" -> "*", "Result" -> "*"
) )
exportGame(headers, context.moves) exportGame(headers, context.moves)
/** Export a game with headers and moves to PGN format. */ /** Export a game with headers and moves to PGN format. */
def exportGame(headers: Map[String, String], moves: List[Move]): String = def exportGame(headers: Map[String, String], moves: List[Move]): String =
val headerLines = headers val headerLines = headers.map { case (key, value) =>
.map { case (key, value) =>
s"""[$key "$value"]""" s"""[$key "$value"]"""
} }.mkString("\n")
.mkString("\n")
val moveText = val moveText = if moves.isEmpty then ""
if moves.isEmpty then ""
else else
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)) var ctx = GameContext.initial
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) } val sanMoves = moves.map { move =>
val algebraic = moveToAlgebraic(move, ctx.board)
ctx = DefaultRules.applyMove(ctx)(move)
algebraic
}
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2) val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
@@ -75,3 +76,5 @@ object PgnExporter extends GameContextExport:
case PieceType.Rook => s"R$capStr$dest" case PieceType.Rook => s"R$capStr$dest"
case PieceType.Queen => s"Q$capStr$dest" case PieceType.Queen => s"Q$capStr$dest"
case PieceType.King => s"K$capStr$dest" case PieceType.King => s"K$capStr$dest"
@@ -9,14 +9,14 @@ import de.nowchess.rules.sets.DefaultRules
/** A parsed PGN game containing headers and the resolved move list. */ /** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame( case class PgnGame(
headers: Map[String, String], headers: Map[String, String],
moves: List[Move], moves: List[Move]
) )
object PgnParser extends GameContextImport: object PgnParser extends GameContextImport:
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position. /** Strictly validate a PGN text.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. * 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] = 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 (headerLines, rest) = lines.span(_.startsWith("["))
@@ -24,18 +24,16 @@ object PgnParser extends GameContextImport:
val moveText = rest.mkString(" ") val moveText = rest.mkString(" ")
validateMovesText(moveText).map(moves => PgnGame(headers, moves)) 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 /** Import a PGN text into a GameContext by validating and replaying all moves.
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an * Returns Right(GameContext) with all moves applied and .moves populated.
* issue. * Returns Left(error message) if validation fails or move replay encounters an issue. */
*/
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
validatePgn(input).flatMap { game => validatePgn(input).flatMap { game =>
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))) 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 /** Parse a complete PGN text into a PgnGame with headers and moves.
* are silently skipped. * Always succeeds (returns Some); malformed tokens are silently skipped. */
*/
def parsePgn(pgn: String): Option[PgnGame] = 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 (headerLines, rest) = lines.span(_.startsWith("["))
@@ -53,7 +51,7 @@ object PgnParser extends GameContextImport:
private def parseMovesText(moveText: String): List[Move] = private def parseMovesText(moveText: String): List[Move] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty) val tokens = moveText.split("\\s+").filter(_.nonEmpty)
val (_, _, moves) = tokens.foldLeft( val (_, _, moves) = tokens.foldLeft(
(GameContext.initial, Color.White, List.empty[Move]), (GameContext.initial, Color.White, List.empty[Move])
): ):
case (state @ (ctx, color, acc), token) => case (state @ (ctx, color, acc), token) =>
if isMoveNumberOrResult(token) then state if isMoveNumberOrResult(token) then state
@@ -100,9 +98,7 @@ object PgnParser extends GameContextImport:
if clean.length < 2 then None if clean.length < 2 then None
else else
val destStr = clean.takeRight(2) val destStr = clean.takeRight(2)
Square Square.fromAlgebraic(destStr).flatMap: toSquare =>
.fromAlgebraic(destStr)
.flatMap: toSquare =>
val disambig = clean.dropRight(2) val disambig = clean.dropRight(2)
val requiredPieceType: Option[PieceType] = val requiredPieceType: Option[PieceType] =
@@ -120,11 +116,9 @@ object PgnParser extends GameContextImport:
val allLegal = DefaultRules.allLegalMoves(ctx) val allLegal = DefaultRules.allLegalMoves(ctx)
val candidates = allLegal.filter { move => val candidates = allLegal.filter { move =>
move.to == toSquare && move.to == toSquare &&
ctx.board ctx.board.pieceAt(move.from).exists(p =>
.pieceAt(move.from)
.exists(p =>
p.color == color && p.color == color &&
requiredPieceType.forall(_ == p.pieceType), requiredPieceType.forall(_ == p.pieceType)
) && ) &&
(hint.isEmpty || matchesHint(move.from, hint)) && (hint.isEmpty || matchesHint(move.from, hint)) &&
promotionMatches(move, promotion) promotionMatches(move, promotion)
@@ -137,13 +131,12 @@ object PgnParser extends GameContextImport:
hint.forall(c => hint.forall(c =>
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) 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 if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true, else true
) )
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean = private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
promotion match promotion match
case None => case None => move.moveType match
move.moveType match
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
case _ => false case _ => false
case Some(pp) => move.moveType == MoveType.Promotion(pp) case Some(pp) => move.moveType == MoveType.Promotion(pp)
@@ -175,10 +168,8 @@ object PgnParser extends GameContextImport:
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */ /** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
private def validateMovesText(moveText: String): Either[String, List[Move]] = private def validateMovesText(moveText: String): Either[String, List[Move]] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty) val tokens = moveText.split("\\s+").filter(_.nonEmpty)
tokens tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
.foldLeft( case (acc, token) =>
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
) { case (acc, token) =>
acc.flatMap { case (ctx, color, moves) => acc.flatMap { case (ctx, color, moves) =>
if isMoveNumberOrResult(token) then Right((ctx, color, moves)) if isMoveNumberOrResult(token) then Right((ctx, color, moves))
else else
@@ -188,5 +179,6 @@ object PgnParser extends GameContextImport:
val nextCtx = DefaultRules.applyMove(ctx)(move) val nextCtx = DefaultRules.applyMove(ctx)(move)
Right((nextCtx, color.opposite, moves :+ move)) Right((nextCtx, color.opposite, moves :+ move))
} }
} }.map(_._3)
.map(_._3)
@@ -1,7 +1,7 @@
package de.nowchess.io package de.nowchess.io
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser} import de.nowchess.io.json.{JsonExporter, JsonParser}
import java.nio.file.{Files, Paths} import java.nio.file.{Files, Paths}
@@ -20,7 +20,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(result.isRight) assert(result.isRight)
assert(Files.exists(tmpFile)) assert(Files.exists(tmpFile))
assert(Files.size(tmpFile) > 0) assert(Files.size(tmpFile) > 0)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: reads JSON file successfully") { test("loadGameFromFile: reads JSON file successfully") {
@@ -37,7 +38,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(result.isRight) assert(result.isRight)
val loaded = result.getOrElse(GameContext.initial) val loaded = result.getOrElse(GameContext.initial)
assert(loaded == originalContext) assert(loaded == originalContext)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: returns error on missing file") { test("loadGameFromFile: returns error on missing file") {
@@ -63,7 +65,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(loadResult.isRight) assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2) assert(loaded.moves.length == 2)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("saveGameToFile: overwrites existing file") { test("saveGameToFile: overwrites existing file") {
@@ -83,7 +86,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(loadResult.isRight) assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 1) assert(loaded.moves.length == 1)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: handles invalid JSON in file") { test("loadGameFromFile: handles invalid JSON in file") {
@@ -93,7 +97,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(result.isLeft) assert(result.isLeft)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("round-trip: save and load preserves game state") { test("round-trip: save and load preserves game state") {
@@ -113,7 +118,8 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2) assert(loaded.moves.length == 2)
assert(loaded.halfMoveClock == 3) assert(loaded.halfMoveClock == 3)
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
test("saveGameToFile: handles exporter that throws exception") { test("saveGameToFile: handles exporter that throws exception") {
@@ -122,11 +128,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val context = GameContext.initial val context = GameContext.initial
val faultyExporter = new GameContextExport { val faultyExporter = new GameContextExport {
def exportGameContext(c: GameContext): String = def exportGameContext(c: GameContext): String =
throw new RuntimeException("Export failed") // scalafix:ok DisableSyntax.throw throw new RuntimeException("Export failed")
} }
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter) val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft) assert(result.isLeft)
assert(result.left.toOption.get.contains("Failed to save file")) assert(result.left.toOption.get.contains("Failed to save file"))
finally Files.deleteIfExists(tmpFile) finally
Files.deleteIfExists(tmpFile)
} }
@@ -14,12 +14,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights: CastlingRights, castlingRights: CastlingRights,
enPassantSquare: Option[Square], enPassantSquare: Option[Square],
halfMoveClock: Int, halfMoveClock: Int,
moveCount: Int, moveCount: Int
): GameContext = ): GameContext =
val board = FenParser val board = FenParser.parseBoard(piecePlacement).getOrElse(
.parseBoard(piecePlacement) fail(s"Invalid test board FEN: $piecePlacement")
.getOrElse(
fail(s"Invalid test board FEN: $piecePlacement"),
) )
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3)) val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
GameContext( GameContext(
@@ -28,7 +26,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassantSquare, enPassantSquare = enPassantSquare,
halfMoveClock = halfMoveClock, halfMoveClock = halfMoveClock,
moves = List.fill(moveCount)(dummyMove), moves = List.fill(moveCount)(dummyMove)
) )
test("exportGameContextToFen handles initial and typical developed position"): test("exportGameContextToFen handles initial and typical developed position"):
@@ -41,7 +39,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.E, Rank.R3)), enPassantSquare = Some(Square(File.E, Rank.R3)),
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0, moveCount = 0
) )
FenExporter.gameContextToFen(gameContext) shouldBe FenExporter.gameContextToFen(gameContext) shouldBe
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
@@ -53,7 +51,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.None, castlingRights = CastlingRights.None,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0, moveCount = 0
) )
FenExporter.gameContextToFen(noCastling) shouldBe FenExporter.gameContextToFen(noCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
@@ -65,11 +63,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true, blackQueenSide = true
), ),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 5, halfMoveClock = 5,
moveCount = 4, moveCount = 4
) )
FenExporter.gameContextToFen(partialCastling) shouldBe FenExporter.gameContextToFen(partialCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
@@ -80,7 +78,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.C, Rank.R6)), enPassantSquare = Some(Square(File.C, Rank.R6)),
halfMoveClock = 2, halfMoveClock = 2,
moveCount = 4, moveCount = 4
) )
FenExporter.gameContextToFen(withEnPassant) shouldBe FenExporter.gameContextToFen(withEnPassant) shouldBe
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
@@ -92,7 +90,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 42, halfMoveClock = 42,
moves = List.empty, moves = List.empty
) )
val fen = FenExporter.gameContextToFen(gameContext) val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match FenParser.parseFen(fen) match
@@ -103,3 +101,4 @@ class FenExporterTest extends AnyFunSuite with Matchers:
val ctx = GameContext.initial val ctx = GameContext.initial
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx) FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
@@ -11,48 +11,30 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
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" val partial = "8/8/4k3/8/4K3/8/8/8"
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some( FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
Some(Piece.WhitePawn), FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
)
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(empty).map(_.pieces.size) shouldBe Some(0)
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some( FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
Some(Piece.BlackKing),
)
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial) FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParserCombinators FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0, ctx.halfMoveClock shouldBe 0
) )
FenParserCombinators FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
) )
FenParserCombinators FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false, ctx.castlingRights.blackQueenSide shouldBe false
) )
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
@@ -20,33 +20,21 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParserFastParse FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0, ctx.halfMoveClock shouldBe 0
) )
FenParserFastParse FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
) )
FenParserFastParse FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false, ctx.castlingRights.blackQueenSide shouldBe false
) )
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
@@ -68,107 +56,3 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
test("parseBoard rejects ranks that overflow via multiple tokens"): test("parseBoard rejects ranks that overflow via multiple tokens"):
FenParserFastParse.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None FenParserFastParse.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
FenParserFastParse.parseBoard("8pp/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
@@ -20,33 +20,21 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParser FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0, ctx.halfMoveClock shouldBe 0
) )
FenParser FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
) )
FenParser FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false, ctx.castlingRights.blackQueenSide shouldBe false
) )
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
@@ -65,9 +53,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("8/8/8/8/8/8/8/7X") 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
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -13,10 +13,10 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
(PromotionPiece.Queen, "queen"), (PromotionPiece.Queen, "queen"),
(PromotionPiece.Rook, "rook"), (PromotionPiece.Rook, "rook"),
(PromotionPiece.Bishop, "bishop"), (PromotionPiece.Bishop, "bishop"),
(PromotionPiece.Knight, "knight"), (PromotionPiece.Knight, "knight")
) )
for (piece, expectedName) <- promotions do for ((piece, expectedName) <- promotions) do
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece)) 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 // Empty boards can cause issues in PgnExporter, using initial
val ctx = GameContext.initial.copy(moves = List(move)) val ctx = GameContext.initial.copy(moves = List(move))
@@ -63,15 +63,6 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
} catch { case _: Exception => } } 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") { test("export en passant move manually") {
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val ctx = GameContext.initial.copy(moves = List(move)) val ctx = GameContext.initial.copy(moves = List(move))
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some("White"), Some("White"),
Some(JsonCastlingRights()), Some(JsonCastlingRights()),
Some("e3"), Some("e3"),
Some(5), Some(5)
) )
assert(gs.board.contains(Nil)) assert(gs.board.contains(Nil))
assert(gs.halfMoveClock.contains(5)) assert(gs.halfMoveClock.contains(5))
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some(""), Some(""),
Some(Nil), Some(Nil),
Some(JsonCapturedPieces()), Some(JsonCapturedPieces()),
Some("2026-04-08T00:00:00Z"), Some("2026-04-08T00:00:00Z")
) )
assert(record.metadata.nonEmpty) assert(record.metadata.nonEmpty)
assert(record.timestamp.nonEmpty) assert(record.timestamp.nonEmpty)
@@ -124,12 +124,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
assert(result.isRight) assert(result.isRight)
val ctx = result.toOption.get val ctx = result.toOption.get
assert(ctx.board.pieces.size == 6) assert(ctx.board.pieces.size == 6)
assert( 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)
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") { test("parse with all castling rights false") {
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square} import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -68,8 +68,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
} }
test("importGameContext: handles missing fields with defaults") { test("importGameContext: handles missing fields with defaults") {
val json = 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\"}"
"{\"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) val result = JsonParser.importGameContext(json)
assert(result.isRight) assert(result.isRight)
@@ -19,19 +19,13 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
test("exportGame renders castling grouping and result markers"): test("exportGame renders castling grouping and result markers"):
PgnExporter.exportGame( PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
Map("Event" -> "Test"), PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
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( val seq = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), 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.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) val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
grouped should include("1. e4 c5") grouped should include("1. e4 c5")
@@ -46,21 +40,20 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
PromotionPiece.Queen -> "=Q", PromotionPiece.Queen -> "=Q",
PromotionPiece.Rook -> "=R", PromotionPiece.Rook -> "=R",
PromotionPiece.Bishop -> "=B", PromotionPiece.Bishop -> "=B",
PromotionPiece.Knight -> "=N", PromotionPiece.Knight -> "=N"
).foreach { (piece, suffix) => ).foreach { (piece, suffix) =>
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece)) 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") PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
} }
val normal = val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.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 include("e4")
normal should not include "=" normal should not include "="
test("exportGameContext preserves moves and default headers"): test("exportGameContext preserves moves and default headers"):
val moves = List( val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), 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)) val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
withMoves.contains("e4") shouldBe true withMoves.contains("e4") shouldBe true
@@ -85,7 +78,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
Move(sq("c7"), sq("c6")), Move(sq("c7"), sq("c6")),
Move(sq("d1"), sq("d7"), MoveType.Normal(true)), Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
Move(sq("d8"), 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) val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
@@ -112,3 +105,4 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include("exf8=Q") pgn should include("exf8=Q")
pawnCapturePgn should include("exd3") pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q") quietPromotionPgn should include("e8=Q")
@@ -30,91 +30,59 @@ class PgnParserTest extends AnyFunSuite with Matchers:
capture.map(_.moves.length) shouldBe Some(3) capture.map(_.moves.length) shouldBe Some(3)
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5) capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
val whiteKs = PgnParser val whiteKs = PgnParser.parsePgn("""[Event "Test"]
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""") 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
.get
.moves
.last
whiteKs.moveType shouldBe MoveType.CastleKingside whiteKs.moveType shouldBe MoveType.CastleKingside
whiteKs.from shouldBe Square(File.E, Rank.R1) whiteKs.from shouldBe Square(File.E, Rank.R1)
whiteKs.to shouldBe Square(File.G, Rank.R1) whiteKs.to shouldBe Square(File.G, Rank.R1)
val whiteQs = PgnParser val whiteQs = PgnParser.parsePgn("""[Event "Test"]
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""") 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
.get
.moves
.last
whiteQs.moveType shouldBe MoveType.CastleQueenside whiteQs.moveType shouldBe MoveType.CastleQueenside
whiteQs.from shouldBe Square(File.E, Rank.R1) whiteQs.from shouldBe Square(File.E, Rank.R1)
whiteQs.to shouldBe Square(File.C, Rank.R1) whiteQs.to shouldBe Square(File.C, Rank.R1)
val blackKs = PgnParser val blackKs = PgnParser.parsePgn("""[Event "Test"]
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""") 1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
.get
.moves
.last
blackKs.moveType shouldBe MoveType.CastleKingside blackKs.moveType shouldBe MoveType.CastleKingside
blackKs.from shouldBe Square(File.E, Rank.R8) blackKs.from shouldBe Square(File.E, Rank.R8)
val blackQs = PgnParser val blackQs = PgnParser.parsePgn("""[Event "Test"]
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""") 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
.get
.moves
.last
blackQs.moveType shouldBe MoveType.CastleQueenside blackQs.moveType shouldBe MoveType.CastleQueenside
blackQs.from shouldBe Square(File.E, Rank.R8) blackQs.from shouldBe Square(File.E, Rank.R8)
blackQs.to shouldBe Square(File.C, Rank.R8) blackQs.to shouldBe Square(File.C, Rank.R8)
PgnParser PgnParser.parsePgn("""[Event "Test"]
.parsePgn("""[Event "Test"]
1. e4 e5 1-0""") 1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
.map(_.moves.length) shouldBe Some(2) PgnParser.parsePgn("""[Event "Test"]
PgnParser
.parsePgn("""[Event "Test"]
1. e4 INVALID e5""") 1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
.map(_.moves.length) shouldBe Some(2)
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"): test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
val board = Board.initial val board = Board.initial
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square( PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
File.E, PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
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( val rookPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, 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.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( val rankPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> 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.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 PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White) PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
.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 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)
@@ -124,22 +92,10 @@ class PgnParserTest extends AnyFunSuite with Matchers:
test("parseAlgebraicMove handles all promotion targets"): test("parseAlgebraicMove handles all promotion targets"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
PgnParser PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White) PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
.get PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
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"): test("importGameContext accepts valid and empty PGN"):
val pgn = """[Event "Test"] val pgn = """[Event "Test"]
@@ -172,3 +128,4 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val parsed = PgnParser.parsePgn("1. e4 ??? e5") val parsed = PgnParser.parsePgn("1. e4 ??? e5")
parsed.map(_.moves.size) shouldBe Some(2) parsed.map(_.moves.size) shouldBe Some(2)
@@ -37,20 +37,17 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside) qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
test("validatePgn rejects impossible illegal and garbage tokens"): test("validatePgn rejects impossible illegal and garbage tokens"):
PgnParser PgnParser.validatePgn("""[Event "Test"]
.validatePgn("""[Event "Test"]
1. Qd4 1. Qd4
""").isLeft shouldBe true """).isLeft shouldBe true
PgnParser PgnParser.validatePgn("""[Event "Test"]
.validatePgn("""[Event "Test"]
1. O-O 1. O-O
""").isLeft shouldBe true """).isLeft shouldBe true
PgnParser PgnParser.validatePgn("""[Event "Test"]
.validatePgn("""[Event "Test"]
1. e4 GARBAGE e5 1. e4 GARBAGE e5
""").isLeft shouldBe true """).isLeft shouldBe true
@@ -58,3 +55,4 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
test("validatePgn accepts empty move text and minimal valid header"): 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 \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=7 MINOR=2
PATCH=0 PATCH=0
-34
View File
@@ -10,37 +10,3 @@
### Bug Fixes ### 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)) * 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))
## (2026-04-14)
### Features
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-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,8 +4,8 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.board.Square import de.nowchess.api.board.Square
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a /** Extension point for chess rule variants (standard, Chess960, etc.).
* GameContext, return the answer. * All rule queries are stateless: given a GameContext, return the answer.
*/ */
trait RuleSet: trait RuleSet:
/** All pseudo-legal moves for the piece on `square` (ignores check). */ /** All pseudo-legal moves for the piece on `square` (ignores check). */
@@ -32,10 +32,8 @@ trait RuleSet:
/** True if halfMoveClock >= 100 (50-move rule). */ /** True if halfMoveClock >= 100 (50-move rule). */
def isFiftyMoveRule(context: GameContext): Boolean def isFiftyMoveRule(context: GameContext): Boolean
/** True if the same position has occurred 3 times (including current position). */ /** Apply a legal move to produce the next game context.
def isThreefoldRepetition(context: GameContext): Boolean * 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 def applyMove(context: GameContext)(move: Move): GameContext
@@ -7,18 +7,11 @@ import de.nowchess.rules.RuleSet
import scala.annotation.tailrec 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: object DefaultRules extends RuleSet:
/** Represents a position for threefold repetition (board state + turn + castling + en passant). */
private case class Position(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
)
// ── Direction vectors ────────────────────────────────────────────── // ── 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 BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
@@ -36,8 +29,7 @@ object DefaultRules extends RuleSet:
override def candidateMoves(context: GameContext)(square: Square): List[Move] = override def candidateMoves(context: GameContext)(square: Square): List[Move] =
context.board.pieceAt(square).fold(List.empty[Move]) { piece => context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
if piece.color != context.turn then List.empty[Move] if piece.color != context.turn then List.empty[Move]
else else piece.pieceType match
piece.pieceType match
case PieceType.Pawn => pawnCandidates(context, square, piece.color) case PieceType.Pawn => pawnCandidates(context, square, piece.color)
case PieceType.Knight => knightCandidates(context, square, piece.color) case PieceType.Knight => knightCandidates(context, square, piece.color)
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
@@ -70,53 +62,13 @@ object DefaultRules extends RuleSet:
override def isFiftyMoveRule(context: GameContext): Boolean = override def isFiftyMoveRule(context: GameContext): Boolean =
context.halfMoveClock >= 100 context.halfMoveClock >= 100
override def isThreefoldRepetition(context: GameContext): Boolean =
val currentPosition = Position(
board = context.board,
turn = context.turn,
castlingRights = context.castlingRights,
enPassantSquare = context.enPassantSquare,
)
countPositionOccurrences(context, currentPosition) >= 3
private def countPositionOccurrences(context: GameContext, targetPosition: Position): Int =
try
var count = 0
var tempCtx = GameContext(
board = context.initialBoard,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
initialBoard = context.initialBoard,
)
var tempPos = Position(tempCtx.board, tempCtx.turn, tempCtx.castlingRights, tempCtx.enPassantSquare)
if tempPos == targetPosition then count += 1
for move <- context.moves do
tempCtx = applyMove(tempCtx)(move)
tempPos = Position(
board = tempCtx.board,
turn = tempCtx.turn,
castlingRights = tempCtx.castlingRights,
enPassantSquare = tempCtx.enPassantSquare,
)
if tempPos == targetPosition then count += 1
count
catch
case _: Exception =>
// If replay fails, conservatively count only the current position (never triggers a draw)
1
// ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves( private def slidingMoves(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color,
dirs: List[(Int, Int)], dirs: List[(Int, Int)]
): List[Move] = ): List[Move] =
dirs.flatMap(dir => castRay(context.board, from, color, dir)) dirs.flatMap(dir => castRay(context.board, from, color, dir))
@@ -124,7 +76,7 @@ object DefaultRules extends RuleSet:
board: Board, board: Board,
from: Square, from: Square,
color: Color, color: Color,
dir: (Int, Int), dir: (Int, Int)
): List[Move] = ): List[Move] =
@tailrec @tailrec
def loop(sq: Square, acc: List[Move]): List[Move] = def loop(sq: Square, acc: List[Move]): List[Move] =
@@ -142,7 +94,7 @@ object DefaultRules extends RuleSet:
private def knightCandidates( private def knightCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color
): List[Move] = ): List[Move] =
KnightJumps.flatMap { (df, dr) => KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
@@ -158,7 +110,7 @@ object DefaultRules extends RuleSet:
private def kingCandidates( private def kingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color
): List[Move] = ): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) => val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
@@ -177,13 +129,13 @@ object DefaultRules extends RuleSet:
kingToAlg: String, kingToAlg: String,
middleAlg: String, middleAlg: String,
rookFromAlg: String, rookFromAlg: String,
moveType: MoveType, moveType: MoveType
) )
private def castlingCandidates( private def castlingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color
): List[Move] = ): List[Move] =
color match color match
case Color.White => whiteCastles(context, from) case Color.White => whiteCastles(context, from)
@@ -194,18 +146,10 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove( addCastleMove(context, moves, context.castlingRights.whiteKingSide,
context, CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
moves, addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
context.castlingRights.whiteKingSide, CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.whiteQueenSide,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
)
moves.toList moves.toList
private def blackCastles(context: GameContext, from: Square): List[Move] = private def blackCastles(context: GameContext, from: Square): List[Move] =
@@ -213,18 +157,10 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove( addCastleMove(context, moves, context.castlingRights.blackKingSide,
context, CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
moves, addCastleMove(context, moves, context.castlingRights.blackQueenSide,
context.castlingRights.blackKingSide, CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.blackQueenSide,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
)
moves.toList moves.toList
private def queensideBSquare(kingToAlg: String): List[String] = private def queensideBSquare(kingToAlg: String): List[String] =
@@ -237,7 +173,7 @@ object DefaultRules extends RuleSet:
context: GameContext, context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move], moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean, castlingRight: Boolean,
castlingMove: CastlingMove, castlingMove: CastlingMove
): Unit = ): Unit =
if castlingRight then if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg)) val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
@@ -257,7 +193,8 @@ object DefaultRules extends RuleSet:
!isAttackedBy(context.board, km, color.opposite) && !isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, 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 = private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
squares.forall(sq => board.pieceAt(sq).isEmpty) squares.forall(sq => board.pieceAt(sq).isEmpty)
@@ -267,24 +204,20 @@ object DefaultRules extends RuleSet:
private def pawnCandidates( private def pawnCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color
): List[Move] = ): List[Move] =
val fwd = pawnForward(color) val fwd = pawnForward(color)
val startRank = pawnStartRank(color) val startRank = pawnStartRank(color)
val promoRank = pawnPromoRank(color) val promoRank = pawnPromoRank(color)
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty) val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
val double = Option val double = Option.when(from.rank.ordinal == startRank) {
.when(from.rank.ordinal == startRank) {
from.offset(0, fwd).flatMap { mid => from.offset(0, fwd).flatMap { mid =>
Option Option.when(context.board.pieceAt(mid).isEmpty) {
.when(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}.flatten
} }
.flatten }.flatten
}
}
.flatten
val diagonalCaptures = List(-1, 1).flatMap { df => val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to => from.offset(df, fwd).flatMap { to =>
@@ -303,10 +236,8 @@ object DefaultRules extends RuleSet:
def toMoves(dest: Square, isCapture: Boolean): List[Move] = def toMoves(dest: Square, isCapture: Boolean): List[Move] =
if dest.rank.ordinal == promoRank then if dest.rank.ordinal == promoRank then
List( List(
PromotionPiece.Queen, PromotionPiece.Queen, PromotionPiece.Rook,
PromotionPiece.Rook, PromotionPiece.Bishop, PromotionPiece.Knight
PromotionPiece.Bishop,
PromotionPiece.Knight,
).map(pt => Move(from, dest, MoveType.Promotion(pt))) ).map(pt => Move(from, dest, MoveType.Promotion(pt)))
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture))) else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
@@ -318,7 +249,9 @@ object DefaultRules extends RuleSet:
// ── Check detection ──────────────────────────────────────────────── // ── Check detection ────────────────────────────────────────────────
private def kingSquare(board: Board, color: Color): Option[Square] = 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 = private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
Square.all.exists { sq => Square.all.exists { sq =>
@@ -333,12 +266,12 @@ object DefaultRules extends RuleSet:
case PieceType.Pawn => case PieceType.Pawn =>
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target) from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
case PieceType.Knight => 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.Bishop => rayReaches(board, from, BishopDirs, target)
case PieceType.Rook => rayReaches(board, from, RookDirs, target) case PieceType.Rook => rayReaches(board, from, RookDirs, target)
case PieceType.Queen => rayReaches(board, from, QueenDirs, target) case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
case PieceType.King => 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 = private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
dirs.exists { dir => dirs.exists { dir =>
@@ -389,13 +322,14 @@ object DefaultRules extends RuleSet:
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board = private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) = val (kingFrom, kingTo, rookFrom, rookTo) =
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) if kingside then
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank)) (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 king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook)) val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
board board
.removed(kingFrom) .removed(kingFrom).removed(rookFrom)
.removed(rookFrom)
.updated(kingTo, king) .updated(kingTo, king)
.updated(rookTo, rook) .updated(rookTo, rook)
@@ -423,25 +357,19 @@ object DefaultRules extends RuleSet:
val blackKingsideRook = Square(File.H, Rank.R8) val blackKingsideRook = Square(File.H, Rank.R8)
val blackQueensideRook = Square(File.A, Rank.R8) val blackQueensideRook = Square(File.A, Rank.R8)
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights var r = rights
if isKingMove then r = r.revokeColor(color)
val afterRookMove = else if isRookMove then
if !isRookMove then afterKingMove if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
else if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
move.from match if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
case `whiteKingsideRook` => afterKingMove.revokeKingSide(Color.White) if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
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 // Also revoke if a rook is captured
move.to match if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White) if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White) if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
case `blackKingsideRook` => afterRookMove.revokeKingSide(Color.Black) if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
case `blackQueensideRook` => afterRookMove.revokeQueenSide(Color.Black) r
case _ => afterRookMove
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] = private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
val piece = board.pieceAt(move.from) val piece = board.pieceAt(move.from)
@@ -455,14 +383,12 @@ object DefaultRules extends RuleSet:
// ── Insufficient material ────────────────────────────────────────── // ── Insufficient material ──────────────────────────────────────────
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
private def insufficientMaterial(board: Board): Boolean = private def insufficientMaterial(board: Board): Boolean =
val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King } val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
nonKings match pieces match
case Nil => true case Nil => true
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => 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 } => case List(p1, p2)
// All non-king pieces are bishops: draw only if they all share the same square color if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1 && p1.color != p2.color => true
case _ => false case _ => false
@@ -40,11 +40,6 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
DefaultRules.isInsufficientMaterial(context) shouldBe true 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"): test("isInsufficientMaterial returns false for king and rook versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1") val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
@@ -195,46 +190,25 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove can revoke both white castling rights when both rooks are captured"): test("applyMove can revoke both white castling rights when both rooks are captured"):
val context = GameContext( val context = GameContext(
board = board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
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, turn = Color.Black,
castlingRights = CastlingRights(true, true, false, false), castlingRights = CastlingRights(true, true, false, false),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty, moves = List.empty
) )
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true))) val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
afterH1Capture.castlingRights.whiteKingSide shouldBe false afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false afterH1Capture.castlingRights.whiteQueenSide shouldBe false
test("isInsufficientMaterial returns true for two same-square-color bishops (one each side)"): test("isInsufficientMaterial returns true for opposite color bishops only"):
// 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") val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true 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"): test("candidateMoves for rook includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1") val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
@@ -305,7 +279,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
PromotionPiece.Queen, PromotionPiece.Queen,
PromotionPiece.Rook, PromotionPiece.Rook,
PromotionPiece.Bishop, PromotionPiece.Bishop,
PromotionPiece.Knight, PromotionPiece.Knight
) )
test("applyMove promotion supports queen rook and bishop targets"): test("applyMove promotion supports queen rook and bishop targets"):
@@ -319,13 +293,8 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook)) rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop)) 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 package de.nowchess.rule
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType} import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
@@ -32,7 +32,7 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) 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 })) val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
test("pawn cannot move backward"): test("pawn cannot move backward"):
@@ -111,23 +111,20 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("castling queenside is illegal when knight blocks on b8"): test("castling queenside is illegal when knight blocks on b8"):
// Black king e8, black rook a8, black knight b8 (blocks queenside path) // Black king e8, black rook a8, black knight b8 (blocks queenside path)
val board = Board( val board = Board(Map(
Map(
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook), Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight), Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
), ))
)
val context = GameContext( val context = GameContext(
board = board, board = board,
turn = Color.Black, turn = Color.Black,
castlingRights = castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty, moves = List.empty
) )
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
@@ -175,48 +172,3 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// White is in check; only moves that block or move the king are legal // White is in check; only moves that block or move the king are legal
moves.nonEmpty shouldBe true moves.nonEmpty shouldBe true
// ── Threefold Repetition ─────────────────────────────────────────
test("threefold repetition returns false for initial position with no moves"):
val context = GameContext.initial
rules.isThreefoldRepetition(context) shouldBe false
test("threefold repetition returns false after single move"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val ctx1 = rules.applyMove(context)(move1)
rules.isThreefoldRepetition(ctx1) shouldBe false
test("threefold repetition detects repeated position after back-and-forth moves"):
// Both knights shuffle back and forth: initial position (White to move) occurs 3 times
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val nf3 = Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3))
val nf6 = Move(Square(File.G, Rank.R8), Square(File.F, Rank.R6))
val ng1 = Move(Square(File.F, Rank.R3), Square(File.G, Rank.R1))
val ng8 = Move(Square(File.F, Rank.R6), Square(File.G, Rank.R8))
// After 8 moves the starting position (White to move, both knights home) has occurred 3 times
val ctx = List(nf3, nf6, ng1, ng8, nf3, nf6, ng1, ng8)
.foldLeft(context)(rules.applyMove(_)(_))
rules.isThreefoldRepetition(ctx) shouldBe true
test("threefold repetition catch block returns false for inconsistent context"):
// A context whose moves cannot be replayed from initialBoard (forces the catch path)
val m = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6)) // e5e6, no pawn there in initial board
val brokenCtx = GameContext(
board = Board.initial,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List(m),
initialBoard = Board.initial,
)
// Replay will fail catch returns 1 1 >= 3 is false
rules.isThreefoldRepetition(brokenCtx) shouldBe false
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=2 MINOR=0
PATCH=0 PATCH=4
-30
View File
@@ -49,33 +49,3 @@
* 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-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-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-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
## (2026-04-12)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-12)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-14)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
+203
View File
@@ -0,0 +1,203 @@
{
"metadata": {
"event": "Game",
"players": {
"white": "White Player",
"black": "Black Player"
},
"date": "2026-04-07",
"result": "*"
},
"gameState": {
"board": [
{
"square": "d1",
"color": "White",
"piece": "Queen"
},
{
"square": "f1",
"color": "White",
"piece": "Bishop"
},
{
"square": "c7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "g2",
"color": "White",
"piece": "Pawn"
},
{
"square": "f2",
"color": "White",
"piece": "Pawn"
},
{
"square": "b1",
"color": "White",
"piece": "Knight"
},
{
"square": "e2",
"color": "White",
"piece": "Pawn"
},
{
"square": "f7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "d8",
"color": "Black",
"piece": "Queen"
},
{
"square": "a2",
"color": "White",
"piece": "Pawn"
},
{
"square": "f3",
"color": "White",
"piece": "Knight"
},
{
"square": "g8",
"color": "Black",
"piece": "Knight"
},
{
"square": "e7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "c8",
"color": "Black",
"piece": "Bishop"
},
{
"square": "h2",
"color": "White",
"piece": "Pawn"
},
{
"square": "d2",
"color": "White",
"piece": "Pawn"
},
{
"square": "g7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "a1",
"color": "White",
"piece": "Rook"
},
{
"square": "h7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "b8",
"color": "Black",
"piece": "Knight"
},
{
"square": "c2",
"color": "White",
"piece": "Pawn"
},
{
"square": "a8",
"color": "Black",
"piece": "Rook"
},
{
"square": "f8",
"color": "Black",
"piece": "Bishop"
},
{
"square": "c1",
"color": "White",
"piece": "Bishop"
},
{
"square": "b7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "a7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "e1",
"color": "White",
"piece": "King"
},
{
"square": "d7",
"color": "Black",
"piece": "Pawn"
},
{
"square": "b2",
"color": "White",
"piece": "Pawn"
},
{
"square": "h8",
"color": "Black",
"piece": "Rook"
},
{
"square": "e8",
"color": "Black",
"piece": "King"
},
{
"square": "h1",
"color": "White",
"piece": "Rook"
}
],
"turn": "Black",
"castlingRights": {
"whiteKingSide": true,
"whiteQueenSide": true,
"blackKingSide": true,
"blackQueenSide": true
},
"enPassantSquare": null,
"halfMoveClock": 1
},
"moveHistory": "[Event \"?\"]\n[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n1. Nf3 *",
"moves": [
{
"from": "g1",
"to": "f3",
"type": {
"type": "normal",
"isCapture": false,
"promotionPiece": null
}
}
],
"capturedPieces": {
"byWhite": [],
"byBlack": [
"Knight"
]
},
"timestamp": "2026-04-07T12:53:26.346013008Z[UTC]"
}
@@ -4,8 +4,8 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher 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 /** Application entry point - starts both GUI and Terminal UI for the chess game.
* GameEngine via Observer pattern. * Both views subscribe to the same GameEngine via Observer pattern.
*/ */
object Main: object Main:
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
@@ -18,3 +18,4 @@ object Main:
// Create and start the terminal UI (blocks on main thread) // Create and start the terminal UI (blocks on main thread)
val tui = new TerminalUI(engine) val tui = new TerminalUI(engine)
tui.start() tui.start()
@@ -1,6 +1,6 @@
package de.nowchess.ui.gui package de.nowchess.ui.gui
import java.util.concurrent.atomic.AtomicReference import scala.compiletime.uninitialized
import scalafx.Includes.* import scalafx.Includes.*
import scalafx.application.Platform import scalafx.application.Platform
import scalafx.geometry.{Insets, Pos} import scalafx.geometry.{Insets, Pos}
@@ -17,13 +17,14 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.json.{JsonExporter, JsonParser} import de.nowchess.io.json.{JsonExporter, JsonParser}
import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService} import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService}
import java.nio.file.Paths import java.nio.file.Paths
import scalafx.stage.FileChooser import scalafx.stage.FileChooser
import scalafx.stage.FileChooser.ExtensionFilter import scalafx.stage.FileChooser.ExtensionFilter
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user /** ScalaFX chess board view that displays the game state.
* interactions (clicks) and sends moves to GameEngine. * 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: class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
@@ -36,23 +37,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
padding = Insets(10) padding = Insets(10)
} }
private val currentBoard = new AtomicReference[Board](engine.board) private var currentBoard: Board = engine.board
private val currentTurn = new AtomicReference[Color](engine.turn) private var currentTurn: Color = engine.turn
private val selectedSquare = new AtomicReference[Option[Square]](None) private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private val undoButton: Button = new Button("Undo") { private var undoButton: Button = uninitialized
font = Font.font(comicSansFontFamily, 12) private var redoButton: Button = uninitialized
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 // Initialize UI
initializeBoard() initializeBoard()
@@ -67,7 +58,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 24) font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;" style = "-fx-font-weight: bold;"
}, },
messageLabel, messageLabel
) )
} }
@@ -87,13 +78,29 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
spacing = 10 spacing = 10
alignment = Pos.Center alignment = Pos.Center
children = Seq( children = Seq(
undoButton, {
redoButton, 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
},
new Button("Reset") { new Button("Reset") {
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset() onAction = _ => engine.reset()
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;" style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
}, }
) )
}, },
new HBox { new HBox {
@@ -119,7 +126,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnImport() onAction = _ => doPgnImport()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;" style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
}, }
) )
}, },
new HBox { new HBox {
@@ -135,9 +142,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonImport() onAction = _ => doJsonImport()
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;" style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
}, }
) )
}, }
) )
} }
@@ -155,7 +162,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
squareViews((rank, file)) = square squareViews((rank, file)) = square
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
updateBoard(currentBoard.get(), currentTurn.get()) updateBoard(currentBoard, currentTurn)
private def createSquare(rank: Int, file: Int): StackPane = private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0 val isWhite = (rank + file) % 2 == 0
@@ -178,19 +185,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
square square
private def handleSquareClick(rank: Int, file: Int): Unit = private def handleSquareClick(rank: Int, file: Int): Unit =
if !engine.isPendingPromotion then if engine.isPendingPromotion then
return // Don't allow moves during promotion
val clickedSquare = Square(File.values(file), Rank.values(rank)) val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare.get() match selectedSquare match
case None => case None =>
// First click - select piece if it belongs to current player // First click - select piece if it belongs to current player
currentBoard.get().pieceAt(clickedSquare).foreach { piece => currentBoard.pieceAt(clickedSquare).foreach { piece =>
if piece.color == currentTurn.get() then if piece.color == currentTurn then
selectedSquare.set(Some(clickedSquare)) selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected) highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to } .collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq => legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
@@ -201,18 +209,18 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
// Second click - attempt move // Second click - attempt move
if clickedSquare == fromSquare then if clickedSquare == fromSquare then
// Deselect // Deselect
selectedSquare.set(None) selectedSquare = None
updateBoard(currentBoard.get(), currentTurn.get()) updateBoard(currentBoard, currentTurn)
else else
// Try to move // Try to move
val moveStr = s"${fromSquare}$clickedSquare" val moveStr = s"${fromSquare}$clickedSquare"
engine.processUserInput(moveStr) engine.processUserInput(moveStr)
selectedSquare.set(None) selectedSquare = None
def updateBoard(board: Board, turn: Color): Unit = def updateBoard(board: Board, turn: Color): Unit =
currentBoard.set(board) currentBoard = board
currentTurn.set(turn) currentTurn = turn
selectedSquare.set(None) selectedSquare = None
// Update all squares // Update all squares
for for
@@ -234,9 +242,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square) val pieceOption = board.pieceAt(square)
val children: Seq[scalafx.scene.Node] = pieceOption match val children = pieceOption match
case Some(piece) => case Some(piece) =>
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None => case None =>
Seq(bgRect) Seq(bgRect)
@@ -246,8 +254,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateUndoRedoButtons() updateUndoRedoButtons()
def updateUndoRedoButtons(): Unit = def updateUndoRedoButtons(): Unit =
undoButton.disable = !engine.canUndo if undoButton != null then undoButton.disable = !engine.canUndo
redoButton.disable = !engine.canRedo if redoButton != null then redoButton.disable = !engine.canRedo
private def highlightSquare(rank: Int, file: Int, color: String): Unit = private def highlightSquare(rank: Int, file: Int, color: String): Unit =
squareViews.get((rank, file)).foreach { stackPane => squareViews.get((rank, file)).foreach { stackPane =>
@@ -260,13 +268,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
} }
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.get().pieceAt(square) val pieceOption = currentBoard.pieceAt(square)
stackPane.children = (pieceOption match stackPane.children = pieceOption match
case Some(piece) => case Some(piece) =>
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None => case None =>
Seq(bgRect)): Seq[scalafx.scene.Node] Seq(bgRect)
} }
def showMessage(msg: String): Unit = def showMessage(msg: String): Unit =
@@ -309,16 +317,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
extensionFilters.add(new ExtensionFilter("All files", "*.*")) extensionFilters.add(new ExtensionFilter("All files", "*.*"))
} }
Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile => val selectedFile = fileChooser.showSaveDialog(stage)
if selectedFile != null then
val result = FileSystemGameService.saveGameToFile( val result = FileSystemGameService.saveGameToFile(
engine.context, engine.context,
selectedFile.toPath, selectedFile.toPath,
JsonExporter, JsonExporter
) )
result match result match
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}") case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
case Left(err) => showMessage(s"⚠️ Error saving file: $err") case Left(err) => showMessage(s"⚠️ Error saving file: $err")
}
private def doJsonImport(): Unit = private def doJsonImport(): Unit =
val fileChooser = new FileChooser { val fileChooser = new FileChooser {
@@ -327,10 +335,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
extensionFilters.add(new ExtensionFilter("All files", "*.*")) extensionFilters.add(new ExtensionFilter("All files", "*.*"))
} }
Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile => val selectedFile = fileChooser.showOpenDialog(stage)
if selectedFile != null then
val result = FileSystemGameService.loadGameFromFile( val result = FileSystemGameService.loadGameFromFile(
selectedFile.toPath, selectedFile.toPath,
JsonParser, JsonParser
) )
result match result match
case Right(gameContext) => case Right(gameContext) =>
@@ -338,14 +347,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
showMessage(s"✓ Game loaded from: ${selectedFile.getName}") showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
case Left(err) => case Left(err) =>
showMessage(s"⚠️ Error: $err") showMessage(s"⚠️ Error: $err")
}
private def doExport(exporter: GameContextExport, formatName: String): Unit = { private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context) val exported = exporter.exportGameContext(engine.context)
showCopyDialog(s"$formatName Export", exported) 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 => showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match importer.importGameContext(input) match
case Right(gameContext) => case Right(gameContext) =>
@@ -354,6 +362,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case Left(err) => case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err") showMessage(s"⚠️ $formatName Error: $err")
} }
}
private def showCopyDialog(title: String, content: String): Unit = private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content) val area = new javafx.scene.control.TextArea(content)
@@ -362,7 +371,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
area.setPrefRowCount(4) area.setPrefRowCount(4)
val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION) val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
alert.setTitle(title) alert.setTitle(title)
alert.setHeaderText("") alert.setHeaderText(null)
alert.getDialogPane.setContent(area) alert.getDialogPane.setContent(area)
alert.getDialogPane.setPrefWidth(500) alert.getDialogPane.setPrefWidth(500)
alert.initOwner(stage.delegate) alert.initOwner(stage.delegate)
@@ -377,11 +386,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.getDialogPane.setContent(area) dialog.getDialogPane.setContent(area)
dialog.getDialogPane.getButtonTypes.addAll( dialog.getDialogPane.getButtonTypes.addAll(
javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.OK,
javafx.scene.control.ButtonType.CANCEL, javafx.scene.control.ButtonType.CANCEL
) )
dialog.setResultConverter { bt => dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else "" if bt == javafx.scene.control.ButtonType.OK then area.getText else null
} }
dialog.initOwner(stage.delegate) dialog.initOwner(stage.delegate)
val result = dialog.showAndWait() val result = dialog.showAndWait()
if result.isPresent && result.get.nonEmpty then Some(result.get) else None if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
@@ -1,14 +1,15 @@
package de.nowchess.ui.gui package de.nowchess.ui.gui
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform} import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
import javafx.stage.Stage as JFXStage import javafx.stage.Stage as JFXStage
import scalafx.application.Platform import scalafx.application.Platform
import scalafx.scene.Scene import scalafx.scene.Scene
import scalafx.stage.Stage import scalafx.stage.Stage
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same /** ScalaFX GUI Application for Chess.
* GameEngine via Observer pattern. * This is launched from Main alongside the TUI.
* Both subscribe to the same GameEngine via Observer pattern.
*/ */
class ChessGUIApp extends JFXApplication: class ChessGUIApp extends JFXApplication:
@@ -31,27 +32,32 @@ class ChessGUIApp extends JFXApplication:
root = boardView root = boardView
// Load CSS if available // Load CSS if available
try { try {
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm)) val cssUrl = getClass.getResource("/styles.css")
if cssUrl != null then
stylesheets.add(cssUrl.toExternalForm)
} catch { } catch {
case _: Exception => // CSS is optional case _: Exception => // CSS is optional
} }
} }
stage.onCloseRequest = _ => stage.onCloseRequest = _ => {
// Unsubscribe when window closes // Unsubscribe when window closes
engine.unsubscribe(guiObserver) engine.unsubscribe(guiObserver)
}
stage.show() stage.show()
/** Launcher object that holds the engine reference and launches GUI in separate thread. */ /** Launcher object that holds the engine reference and launches GUI in separate thread. */
object ChessGUILauncher: object ChessGUILauncher:
private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]() @volatile private var engine: GameEngine = scala.compiletime.uninitialized
def getEngine: GameEngine = engineRef.get() def getEngine: GameEngine = engine
def launch(eng: GameEngine): Unit = def launch(eng: GameEngine): Unit =
engineRef.set(eng) engine = eng
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp])) val guiThread = new Thread(() => {
JFXApplication.launch(classOf[ChessGUIApp])
})
guiThread.setDaemon(false) guiThread.setDaemon(false)
guiThread.setName("ScalaFX-GUI-Thread") guiThread.setName("ScalaFX-GUI-Thread")
guiThread.start() guiThread.start()
@@ -3,11 +3,11 @@ package de.nowchess.ui.gui
import scalafx.application.Platform import scalafx.application.Platform
import scalafx.scene.control.Alert import scalafx.scene.control.Alert
import scalafx.scene.control.Alert.AlertType import scalafx.scene.control.Alert.AlertType
import de.nowchess.chess.observer.{GameEvent, Observer, *} import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.api.board.Board 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. /** 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. * All UI updates must be done on the JavaFX Application Thread.
*/ */
class GUIObserver(private val boardView: ChessBoardView) extends Observer: class GUIObserver(private val boardView: ChessBoardView) extends Observer:
@@ -30,15 +30,9 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.") showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
case e: DrawEvent => case e: StalemateEvent =>
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
val msg = e.reason match showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement."
showAlert(AlertType.Information, "Game Over", msg)
case e: InvalidMoveEvent => case e: InvalidMoveEvent =>
boardView.showMessage(s"⚠️ ${e.reason}") boardView.showMessage(s"⚠️ ${e.reason}")
@@ -50,11 +44,12 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
case e: PromotionRequiredEvent => case e: PromotionRequiredEvent =>
boardView.showPromotionDialog(e.from, e.to) boardView.showPromotionDialog(e.from, e.to)
case e: FiftyMoveRuleAvailableEvent => case e: DrawClaimedEvent =>
boardView.showMessage("50-move rule is now available — type 'draw' to claim.") boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.")
case e: ThreefoldRepetitionAvailableEvent => case e: FiftyMoveRuleAvailableEvent =>
boardView.showMessage("Threefold repetition is now available — type 'draw' to claim.") boardView.showMessage("50-move rule available! The game is a draw.")
case e: MoveUndoneEvent => case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
@@ -65,7 +60,8 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}") 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() boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent => case e: PgnLoadedEvent =>
@@ -1,29 +1,33 @@
package de.nowchess.ui.gui package de.nowchess.ui.gui
import scalafx.scene.image.{Image, ImageView} import scalafx.scene.image.{Image, ImageView}
import de.nowchess.api.board.{Color, Piece, PieceType} import de.nowchess.api.board.{Piece, PieceType, Color}
/** Utility object for loading chess piece sprites. */ /** Utility object for loading chess piece sprites. */
object PieceSprites: object PieceSprites:
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]() private val spriteCache = scala.collection.mutable.Map[String, Image]()
/** Load a piece sprite image from resources. Sprites are cached for performance. /** Load a piece sprite image from resources.
* Sprites are cached for performance.
*/ */
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] = def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image => val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) { new ImageView(image) {
fitWidth = size fitWidth = size
fitHeight = size fitHeight = size
preserveRatio = true preserveRatio = true
smooth = true smooth = true
} }
}
private def loadImage(key: String): Option[Image] = private def loadImage(key: String): Image =
val path = s"/sprites/pieces/$key.png" val path = s"/sprites/pieces/$key.png"
Option(getClass.getResourceAsStream(path)).map(new Image(_)) val stream = getClass.getResourceAsStream(path)
if stream == null then
throw new RuntimeException(s"Could not load sprite: $path")
new Image(stream)
/** Get square colors for the board using theme. */ /** Get square colors for the board using theme. */
object SquareColors: object SquareColors:
@@ -1,19 +1,18 @@
package de.nowchess.ui.terminal package de.nowchess.ui.terminal
import java.util.concurrent.atomic.AtomicBoolean
import scala.io.StdIn import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all /** Terminal UI that implements Observer pattern.
* I/O and user interaction in the terminal. * Subscribes to GameEngine and receives state change events.
* Handles all I/O and user interaction in the terminal.
*/ */
class TerminalUI(engine: GameEngine) extends Observer: class TerminalUI(engine: GameEngine) extends Observer:
private val running = new AtomicBoolean(true) private var running = true
private val awaitingPromotion = new AtomicBoolean(false) private var awaitingPromotion = false
/** Called by GameEngine whenever a game event occurs. */ /** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit = override def onGameEvent(event: GameEvent): Unit =
@@ -45,14 +44,8 @@ class TerminalUI(engine: GameEngine) extends Observer:
println() println()
print(Renderer.render(e.context.board)) print(Renderer.render(e.context.board))
case e: DrawEvent => case e: StalemateEvent =>
val msg = e.reason match println("Stalemate! The game is a draw.")
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement."
println(msg)
println() println()
print(Renderer.render(e.context.board)) print(Renderer.render(e.context.board))
@@ -67,12 +60,13 @@ class TerminalUI(engine: GameEngine) extends Observer:
case _: PromotionRequiredEvent => case _: PromotionRequiredEvent =>
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
awaitingPromotion.set(true) synchronized { awaitingPromotion = true }
case _: DrawClaimedEvent =>
println("Draw claimed! The game is a draw.")
println()
print(Renderer.render(engine.board))
case _: FiftyMoveRuleAvailableEvent => case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule is now available — type 'draw' to claim.") println("50-move rule available! The game is a draw.")
case _: ThreefoldRepetitionAvailableEvent =>
println("Threefold repetition is now available — type 'draw' to claim.")
case e: PgnLoadedEvent => case e: PgnLoadedEvent =>
println("PGN loaded successfully.") println("PGN loaded successfully.")
@@ -91,22 +85,22 @@ class TerminalUI(engine: GameEngine) extends Observer:
printPrompt(engine.turn) printPrompt(engine.turn)
// Game loop // Game loop
while running.get() do while running do
val input = Option(StdIn.readLine()).getOrElse("quit").trim val input = Option(StdIn.readLine()).getOrElse("quit").trim
synchronized { synchronized {
if awaitingPromotion.get() then if awaitingPromotion then
input.toLowerCase match input.toLowerCase match
case "q" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Queen) case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
case "r" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Rook) case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
case "b" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Bishop) case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
case "n" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Knight) case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
case _ => case _ =>
println("Invalid choice. Enter q, r, b, or n.") println("Invalid choice. Enter q, r, b, or n.")
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
else else
input.toLowerCase match input.toLowerCase match
case "quit" | "q" => case "quit" | "q" =>
running.set(false) running = false
println("Game over. Goodbye!") println("Game over. Goodbye!")
case "" => case "" =>
printPrompt(engine.turn) printPrompt(engine.turn)
@@ -11,8 +11,7 @@ object Renderer:
private val AnsiBlackPiece = "\u001b[30m" // black text private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String = def render(board: Board): String =
val rows = (0 until 8).reverse val rows = (0 until 8).reverse.map { rank =>
.map { rank =>
val cells = (0 until 8).map { file => val cells = (0 until 8).map { file =>
val sq = Square(File.values(file), Rank.values(rank)) val sq = Square(File.values(file), Rank.values(rank))
val isLightSq = (file + rank) % 2 != 0 val isLightSq = (file + rank) % 2 != 0
@@ -25,6 +24,5 @@ object Renderer:
s"$bgColor $AnsiReset" s"$bgColor $AnsiReset"
}.mkString }.mkString
s"${rank + 1} $cells ${rank + 1}" s"${rank + 1} $cells ${rank + 1}"
} }.mkString("\n")
.mkString("\n")
s" a b c d e f g h\n$rows\n a b c d e f g h\n" s" a b c d e f g h\n$rows\n a b c d e f g h\n"

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