Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbcafd2869 | |||
| 3ecb2c9d66 | |||
| 9ad11fb97a | |||
| e158b0a7f0 |
+3
-1
@@ -34,7 +34,9 @@ val versions = mapOf(
|
|||||||
"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",
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
"FASTPARSE" to "3.0.2"
|
"FASTPARSE" to "3.0.2",
|
||||||
|
"JACKSON" to "2.17.2",
|
||||||
|
"JACKSON_SCALA" to "2.17.2"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,776 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: NowChess API
|
||||||
|
description: |
|
||||||
|
REST API for the NowChess application. Designed to feel familiar to users
|
||||||
|
of the [lichess API](https://lichess.org/api).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
Most endpoints require a Bearer token:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
Authentication is reserved for future implementation — endpoints are currently
|
||||||
|
open unless noted otherwise.
|
||||||
|
|
||||||
|
## Move notation
|
||||||
|
Moves are expressed in **UCI notation**: `{from}{to}[promotion]`
|
||||||
|
- Normal move: `e2e4`
|
||||||
|
- Capture: `d5e6`
|
||||||
|
- Promotion: `e7e8q` (q=queen, r=rook, b=bishop, n=knight)
|
||||||
|
- Castling: `e1g1` (kingside white), `e1c1` (queenside white)
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
Endpoints that support streaming return **NDJSON** (newline-delimited JSON).
|
||||||
|
Request them with:
|
||||||
|
```
|
||||||
|
Accept: application/x-ndjson
|
||||||
|
```
|
||||||
|
Each line of the response is a complete JSON object. Empty lines are
|
||||||
|
keep-alive heartbeats.
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
Requests that exceed the rate limit receive `429 Too Many Requests`.
|
||||||
|
Honour the `Retry-After` response header and wait before retrying.
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: NowChess
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Local development server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: game
|
||||||
|
description: Create and manage chess games
|
||||||
|
- name: move
|
||||||
|
description: Make moves and navigate game history
|
||||||
|
- name: draw
|
||||||
|
description: Draw offers and claims
|
||||||
|
- name: import
|
||||||
|
description: Load a game from FEN or PGN
|
||||||
|
- name: export
|
||||||
|
description: Export a game as FEN or PGN
|
||||||
|
|
||||||
|
paths:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Game lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game:
|
||||||
|
post:
|
||||||
|
operationId: createGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Create a new game
|
||||||
|
description: |
|
||||||
|
Creates a new chess game starting from the initial position.
|
||||||
|
Returns the full game state including the generated `gameId`.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateGameRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}:
|
||||||
|
get:
|
||||||
|
operationId: getGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Get game state
|
||||||
|
description: Returns the full current state of a game.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current game state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/stream:
|
||||||
|
get:
|
||||||
|
operationId: streamGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Stream game events
|
||||||
|
description: |
|
||||||
|
Opens a persistent NDJSON stream for a game. The first object sent is
|
||||||
|
a `gameFull` event containing the complete game state. Subsequent
|
||||||
|
objects are `gameState` events sent whenever the game changes (move
|
||||||
|
made, draw offered, game over, etc.).
|
||||||
|
|
||||||
|
Empty lines are heartbeats to keep the connection alive.
|
||||||
|
|
||||||
|
Connect with:
|
||||||
|
```
|
||||||
|
Accept: application/x-ndjson
|
||||||
|
```
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: NDJSON event stream
|
||||||
|
content:
|
||||||
|
application/x-ndjson:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/GameFullEvent'
|
||||||
|
- $ref: '#/components/schemas/GameStateEvent'
|
||||||
|
- $ref: '#/components/schemas/ErrorEvent'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/resign:
|
||||||
|
post:
|
||||||
|
operationId: resignGame
|
||||||
|
tags: [game]
|
||||||
|
summary: Resign the game
|
||||||
|
description: The active player resigns. The game ends immediately.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Resignation accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Move-making
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/move/{uci}:
|
||||||
|
post:
|
||||||
|
operationId: makeMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Make a move
|
||||||
|
description: |
|
||||||
|
Submit a move in UCI notation. The move must be legal for the side
|
||||||
|
currently to move.
|
||||||
|
|
||||||
|
For promotion moves include the target piece as the fifth character:
|
||||||
|
`e7e8q`, `a2a1r`, etc.
|
||||||
|
|
||||||
|
If the move results in a pawn reaching the back rank and no promotion
|
||||||
|
character is supplied, the game enters `promotionPending` status and
|
||||||
|
the move is not yet applied — resubmit with the promotion character.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: uci
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Move in UCI notation (e.g. `e2e4`, `e7e8q`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-h][1-8][a-h][1-8][qrbn]?$'
|
||||||
|
example: e2e4
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move applied — returns updated game state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/moves:
|
||||||
|
get:
|
||||||
|
operationId: getLegalMoves
|
||||||
|
tags: [move]
|
||||||
|
summary: Get legal moves
|
||||||
|
description: |
|
||||||
|
Returns all legal moves for the side currently to move.
|
||||||
|
Optionally filter to moves originating from a single square.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: square
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Filter to moves from this square (e.g. `e2`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-h][1-8]$'
|
||||||
|
example: e2
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of legal moves
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LegalMovesResponse'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/undo:
|
||||||
|
post:
|
||||||
|
operationId: undoMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Undo the last move
|
||||||
|
description: Reverts the most recent move. Returns the updated game state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move undone
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
description: No moves to undo
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/redo:
|
||||||
|
post:
|
||||||
|
operationId: redoMove
|
||||||
|
tags: [move]
|
||||||
|
summary: Redo a previously undone move
|
||||||
|
description: Re-applies the next move in the undo stack. Returns the updated game state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Move redone
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
'400':
|
||||||
|
description: No moves to redo
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Draw handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/draw/{action}:
|
||||||
|
post:
|
||||||
|
operationId: drawAction
|
||||||
|
tags: [draw]
|
||||||
|
summary: Offer, accept, decline, or claim a draw
|
||||||
|
description: |
|
||||||
|
Perform a draw-related action:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `offer` | Offer a draw to the opponent |
|
||||||
|
| `accept` | Accept the opponent's draw offer |
|
||||||
|
| `decline` | Decline the opponent's draw offer |
|
||||||
|
| `claim` | Claim a draw under the fifty-move rule (only valid when `status` is `fiftyMoveAvailable`) |
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
- name: action
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [offer, accept, decline, claim]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Action accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/import/fen:
|
||||||
|
post:
|
||||||
|
operationId: importFen
|
||||||
|
tags: [import]
|
||||||
|
summary: Load a position from FEN
|
||||||
|
description: |
|
||||||
|
Creates a new game from a FEN string. The game starts at the position
|
||||||
|
described by the FEN; move history prior to that position is not
|
||||||
|
available.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ImportFenRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created from FEN
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/import/pgn:
|
||||||
|
post:
|
||||||
|
operationId: importPgn
|
||||||
|
tags: [import]
|
||||||
|
summary: Load a game from PGN
|
||||||
|
description: |
|
||||||
|
Creates a new game by replaying all moves in a PGN string. The game
|
||||||
|
starts at the position after the final move in the PGN; undo is
|
||||||
|
available for every replayed move.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ImportPgnRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Game created from PGN
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/export/fen:
|
||||||
|
get:
|
||||||
|
operationId: exportFen
|
||||||
|
tags: [export]
|
||||||
|
summary: Export current position as FEN
|
||||||
|
description: Returns the FEN string representing the current board position.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: FEN string
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
/api/board/game/{gameId}/export/pgn:
|
||||||
|
get:
|
||||||
|
operationId: exportPgn
|
||||||
|
tags: [export]
|
||||||
|
summary: Export game as PGN
|
||||||
|
description: Returns the full PGN for the game including headers and move text.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/gameId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: PGN text
|
||||||
|
content:
|
||||||
|
application/x-chess-pgn:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: |
|
||||||
|
[Event "NowChess game"]
|
||||||
|
[White "Player1"]
|
||||||
|
[Black "Player2"]
|
||||||
|
[Result "*"]
|
||||||
|
|
||||||
|
1. e4 e5 2. Nf3 *
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Components
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
components:
|
||||||
|
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: 'Personal access token — `Authorization: Bearer <token>`'
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
gameId:
|
||||||
|
name: gameId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 8-character alphanumeric game ID (e.g. `Qa7FJNk2`)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Za-z0-9]{8}$'
|
||||||
|
example: Qa7FJNk2
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid input
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
Unauthorized:
|
||||||
|
description: Missing or invalid authentication token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
NotFound:
|
||||||
|
description: Game not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
TooManyRequests:
|
||||||
|
description: Rate limit exceeded — see `Retry-After` header
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
description: Seconds to wait before retrying
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Requests
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CreateGameRequest:
|
||||||
|
type: object
|
||||||
|
description: Parameters for creating a new game. All fields are optional.
|
||||||
|
properties:
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
|
||||||
|
ImportFenRequest:
|
||||||
|
type: object
|
||||||
|
required: [fen]
|
||||||
|
properties:
|
||||||
|
fen:
|
||||||
|
type: string
|
||||||
|
description: Complete FEN string (6 fields)
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
|
||||||
|
ImportPgnRequest:
|
||||||
|
type: object
|
||||||
|
required: [pgn]
|
||||||
|
properties:
|
||||||
|
pgn:
|
||||||
|
type: string
|
||||||
|
description: PGN text (headers and move list)
|
||||||
|
example: "1. e4 e5 2. Nf3 Nc6 *"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Game state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GameFull:
|
||||||
|
type: object
|
||||||
|
description: Complete game information including players and current state.
|
||||||
|
required: [gameId, white, black, state]
|
||||||
|
properties:
|
||||||
|
gameId:
|
||||||
|
type: string
|
||||||
|
description: Unique 8-character game identifier
|
||||||
|
example: Qa7FJNk2
|
||||||
|
white:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
black:
|
||||||
|
$ref: '#/components/schemas/PlayerInfo'
|
||||||
|
state:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
|
||||||
|
GameState:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
The current game state. Included in `GameFull` and returned by move
|
||||||
|
endpoints and stream events.
|
||||||
|
required: [fen, pgn, turn, status, moves, undoAvailable, redoAvailable]
|
||||||
|
properties:
|
||||||
|
fen:
|
||||||
|
type: string
|
||||||
|
description: FEN string for the current position
|
||||||
|
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
|
||||||
|
pgn:
|
||||||
|
type: string
|
||||||
|
description: PGN move text for the full game so far
|
||||||
|
example: "1. e4"
|
||||||
|
turn:
|
||||||
|
type: string
|
||||||
|
enum: [white, black]
|
||||||
|
description: The side to move
|
||||||
|
status:
|
||||||
|
$ref: '#/components/schemas/GameStatus'
|
||||||
|
winner:
|
||||||
|
type: string
|
||||||
|
enum: [white, black]
|
||||||
|
description: Set when `status` is `checkmate` or `resign`
|
||||||
|
nullable: true
|
||||||
|
moves:
|
||||||
|
type: array
|
||||||
|
description: All moves played so far, in UCI notation
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: [e2e4, e7e5, g1f3]
|
||||||
|
undoAvailable:
|
||||||
|
type: boolean
|
||||||
|
description: Whether `POST /undo` is currently valid
|
||||||
|
redoAvailable:
|
||||||
|
type: boolean
|
||||||
|
description: Whether `POST /redo` is currently valid
|
||||||
|
|
||||||
|
GameStatus:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Current game status:
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `started` | Game in progress, no special condition |
|
||||||
|
| `check` | Side to move is in check |
|
||||||
|
| `checkmate` | Side to move is checkmated — game over |
|
||||||
|
| `stalemate` | Side to move has no legal moves, not in check — game over (draw) |
|
||||||
|
| `resign` | A player resigned — game over |
|
||||||
|
| `draw` | Draw agreed or claimed — game over |
|
||||||
|
| `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
|
||||||
|
| `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
|
||||||
|
| `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection |
|
||||||
|
| `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
|
||||||
|
enum:
|
||||||
|
- started
|
||||||
|
- check
|
||||||
|
- checkmate
|
||||||
|
- stalemate
|
||||||
|
- resign
|
||||||
|
- draw
|
||||||
|
- drawOffered
|
||||||
|
- fiftyMoveAvailable
|
||||||
|
- promotionPending
|
||||||
|
- insufficientMaterial
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Moves
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LegalMovesResponse:
|
||||||
|
type: object
|
||||||
|
required: [moves]
|
||||||
|
properties:
|
||||||
|
moves:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LegalMove'
|
||||||
|
|
||||||
|
LegalMove:
|
||||||
|
type: object
|
||||||
|
required: [from, to, uci, moveType]
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
type: string
|
||||||
|
description: Origin square in algebraic notation
|
||||||
|
example: e2
|
||||||
|
to:
|
||||||
|
type: string
|
||||||
|
description: Destination square in algebraic notation
|
||||||
|
example: e4
|
||||||
|
uci:
|
||||||
|
type: string
|
||||||
|
description: Full move in UCI notation
|
||||||
|
example: e2e4
|
||||||
|
moveType:
|
||||||
|
$ref: '#/components/schemas/MoveType'
|
||||||
|
promotion:
|
||||||
|
type: string
|
||||||
|
enum: [queen, rook, bishop, knight]
|
||||||
|
description: Target piece for promotion moves
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
MoveType:
|
||||||
|
type: string
|
||||||
|
description: Classification of the move
|
||||||
|
enum:
|
||||||
|
- normal
|
||||||
|
- capture
|
||||||
|
- castleKingside
|
||||||
|
- castleQueenside
|
||||||
|
- enPassant
|
||||||
|
- promotion
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Streaming events
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GameFullEvent:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
First event on a game stream. Contains the complete game snapshot.
|
||||||
|
required: [type, game]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [gameFull]
|
||||||
|
game:
|
||||||
|
$ref: '#/components/schemas/GameFull'
|
||||||
|
|
||||||
|
GameStateEvent:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Emitted on a game stream whenever the game state changes (move played,
|
||||||
|
draw offered, game over, etc.).
|
||||||
|
required: [type, state]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [gameState]
|
||||||
|
state:
|
||||||
|
$ref: '#/components/schemas/GameState'
|
||||||
|
|
||||||
|
ErrorEvent:
|
||||||
|
type: object
|
||||||
|
description: Emitted on a game stream when an error occurs.
|
||||||
|
required: [type, error]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [error]
|
||||||
|
error:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Shared types
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PlayerInfo:
|
||||||
|
type: object
|
||||||
|
required: [id, displayName]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Unique player identifier
|
||||||
|
example: player1
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
description: Human-readable display name
|
||||||
|
example: Alice
|
||||||
|
|
||||||
|
OkResponse:
|
||||||
|
type: object
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
enum: [true]
|
||||||
|
|
||||||
|
ApiError:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code
|
||||||
|
example: INVALID_MOVE
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Human-readable error description
|
||||||
|
example: e2e5 is not a legal move
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Request field that caused the error, if applicable
|
||||||
|
example: uci
|
||||||
|
nullable: true
|
||||||
@@ -11,3 +11,15 @@
|
|||||||
|
|
||||||
* 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))
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ dependencies {
|
|||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
|
|
||||||
|
// Jackson for JSON serialization/deserialization
|
||||||
|
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||||
|
implementation("com.fasterxml.jackson.core:jackson-core:${versions["JACKSON"]!!}")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}")
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.nowchess.io
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import java.nio.file.{Files, Path}
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Service for persisting and loading game states to/from disk.
|
||||||
|
*
|
||||||
|
* Abstracts file I/O operations away from the UI layer.
|
||||||
|
* Handles both reading and writing game files.
|
||||||
|
*/
|
||||||
|
trait GameFileService:
|
||||||
|
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||||
|
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||||
|
|
||||||
|
/** Default implementation using the file system. */
|
||||||
|
object FileSystemGameService extends GameFileService:
|
||||||
|
|
||||||
|
/** Save a game context to a file using the specified exporter. */
|
||||||
|
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] =
|
||||||
|
Try {
|
||||||
|
val json = exporter.exportGameContext(context)
|
||||||
|
Files.write(path, json.getBytes(StandardCharsets.UTF_8))
|
||||||
|
()
|
||||||
|
}.fold(
|
||||||
|
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||||
|
_ => Right(())
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Load a game context from a file using the specified importer. */
|
||||||
|
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] =
|
||||||
|
Try {
|
||||||
|
val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
|
||||||
|
importer.importGameContext(json)
|
||||||
|
}.fold(
|
||||||
|
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||||
|
result => result
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.{ObjectMapper, SerializationFeature}
|
||||||
|
import com.fasterxml.jackson.core.util.{DefaultIndenter, DefaultPrettyPrinter}
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.io.GameContextExport
|
||||||
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
|
import java.time.{LocalDate, ZonedDateTime, ZoneId}
|
||||||
|
|
||||||
|
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||||
|
*
|
||||||
|
* The JSON includes:
|
||||||
|
* - Game metadata (players, event, date, result)
|
||||||
|
* - Board state (all pieces and their positions)
|
||||||
|
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||||
|
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||||
|
* - Captured pieces tracking (which pieces have been removed)
|
||||||
|
* - Timestamp for record-keeping
|
||||||
|
*/
|
||||||
|
object JsonExporter extends GameContextExport:
|
||||||
|
private val mapper = createMapper()
|
||||||
|
|
||||||
|
private def createMapper(): ObjectMapper =
|
||||||
|
val mapper = new ObjectMapper()
|
||||||
|
.registerModule(DefaultScalaModule)
|
||||||
|
|
||||||
|
// Configure pretty printer with custom spacing to match test expectations
|
||||||
|
val indenter = new DefaultIndenter(" ", "\n")
|
||||||
|
val printer = new DefaultPrettyPrinter()
|
||||||
|
printer.indentArraysWith(indenter)
|
||||||
|
printer.indentObjectsWith(indenter)
|
||||||
|
|
||||||
|
mapper.setDefaultPrettyPrinter(printer)
|
||||||
|
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
||||||
|
mapper
|
||||||
|
|
||||||
|
def exportGameContext(context: GameContext): String =
|
||||||
|
val record = buildGameRecord(context)
|
||||||
|
formatJson(mapper.writeValueAsString(record))
|
||||||
|
|
||||||
|
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||||
|
val pgn = try {
|
||||||
|
Some(PgnExporter.exportGameContext(context))
|
||||||
|
} catch {
|
||||||
|
case _: Exception => None
|
||||||
|
}
|
||||||
|
JsonGameRecord(
|
||||||
|
metadata = Some(buildMetadata()),
|
||||||
|
gameState = Some(buildGameState(context)),
|
||||||
|
moveHistory = pgn,
|
||||||
|
moves = Some(buildMoves(context.moves)),
|
||||||
|
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||||
|
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def buildMetadata(): JsonMetadata =
|
||||||
|
JsonMetadata(
|
||||||
|
event = Some("Game"),
|
||||||
|
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||||
|
date = Some(LocalDate.now().toString),
|
||||||
|
result = Some("*")
|
||||||
|
)
|
||||||
|
|
||||||
|
private def buildGameState(context: GameContext): JsonGameState =
|
||||||
|
JsonGameState(
|
||||||
|
board = Some(buildBoardPieces(context.board)),
|
||||||
|
turn = Some(context.turn.label),
|
||||||
|
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||||
|
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||||
|
halfMoveClock = Some(context.halfMoveClock)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||||
|
board.pieces.toList.map { case (sq, p) =>
|
||||||
|
JsonPiece(Some(sq.toString), Some(p.color.label), Some(p.pieceType.label))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def buildCastlingRights(rights: CastlingRights): JsonCastlingRights =
|
||||||
|
JsonCastlingRights(
|
||||||
|
Some(rights.whiteKingSide),
|
||||||
|
Some(rights.whiteQueenSide),
|
||||||
|
Some(rights.blackKingSide),
|
||||||
|
Some(rights.blackQueenSide)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||||
|
moves.map { m =>
|
||||||
|
val moveType = convertMoveType(m.moveType)
|
||||||
|
JsonMove(Some(m.from.toString), Some(m.to.toString), moveType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def convertMoveType(moveType: MoveType): Option[JsonMoveType] =
|
||||||
|
val (tpe, isC, pp) = moveType match {
|
||||||
|
case MoveType.Normal(isCapture) =>
|
||||||
|
(Some("normal"), Some(isCapture), None)
|
||||||
|
case MoveType.CastleKingside =>
|
||||||
|
(Some("castleKingside"), None, None)
|
||||||
|
case MoveType.CastleQueenside =>
|
||||||
|
(Some("castleQueenside"), None, None)
|
||||||
|
case MoveType.EnPassant =>
|
||||||
|
(Some("enPassant"), Some(true), None)
|
||||||
|
case MoveType.Promotion(piece) =>
|
||||||
|
val pName = piece match {
|
||||||
|
case PromotionPiece.Queen => "queen"
|
||||||
|
case PromotionPiece.Rook => "rook"
|
||||||
|
case PromotionPiece.Bishop => "bishop"
|
||||||
|
case PromotionPiece.Knight => "knight"
|
||||||
|
}
|
||||||
|
(Some("promotion"), None, Some(pName))
|
||||||
|
}
|
||||||
|
Some(JsonMoveType(tpe, isC, pp))
|
||||||
|
|
||||||
|
private def buildCapturedPieces(board: Board): JsonCapturedPieces =
|
||||||
|
val (byWhite, byBlack) = getCapturedPieces(board)
|
||||||
|
JsonCapturedPieces(Some(byWhite), Some(byBlack))
|
||||||
|
|
||||||
|
private def formatJson(json: String): String =
|
||||||
|
json
|
||||||
|
.replace(" : ", ": ")
|
||||||
|
.replaceAll("\\[\\s*\\]", "[]")
|
||||||
|
.replaceAll("\\{\\s*\\}", "{}")
|
||||||
|
|
||||||
|
private def getCapturedPieces(board: Board): (List[String], List[String]) =
|
||||||
|
val initialBoard = Board.initial
|
||||||
|
val captured = Square.all.flatMap { square =>
|
||||||
|
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||||
|
board.pieceAt(square) match
|
||||||
|
case None => Some(initialPiece)
|
||||||
|
case Some(_) => None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||||
|
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||||
|
(blackCaptured, whiteCaptured)
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
case class JsonMetadata(
|
||||||
|
event: Option[String] = None,
|
||||||
|
players: Option[Map[String, String]] = None,
|
||||||
|
date: Option[String] = None,
|
||||||
|
result: Option[String] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonPiece(
|
||||||
|
square: Option[String] = None,
|
||||||
|
color: Option[String] = None,
|
||||||
|
piece: Option[String] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonCastlingRights(
|
||||||
|
whiteKingSide: Option[Boolean] = None,
|
||||||
|
whiteQueenSide: Option[Boolean] = None,
|
||||||
|
blackKingSide: Option[Boolean] = None,
|
||||||
|
blackQueenSide: Option[Boolean] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonGameState(
|
||||||
|
board: Option[List[JsonPiece]] = None,
|
||||||
|
turn: Option[String] = None,
|
||||||
|
castlingRights: Option[JsonCastlingRights] = None,
|
||||||
|
enPassantSquare: Option[String] = None,
|
||||||
|
halfMoveClock: Option[Int] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonCapturedPieces(
|
||||||
|
byWhite: Option[List[String]] = None,
|
||||||
|
byBlack: Option[List[String]] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonMoveType(
|
||||||
|
`type`: Option[String] = None,
|
||||||
|
isCapture: Option[Boolean] = None,
|
||||||
|
promotionPiece: Option[String] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonMove(
|
||||||
|
from: Option[String] = None,
|
||||||
|
to: Option[String] = None,
|
||||||
|
`type`: Option[JsonMoveType] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
case class JsonGameRecord(
|
||||||
|
metadata: Option[JsonMetadata] = None,
|
||||||
|
gameState: Option[JsonGameState] = None,
|
||||||
|
moveHistory: Option[String] = None,
|
||||||
|
moves: Option[List[JsonMove]] = None,
|
||||||
|
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||||
|
timestamp: Option[String] = None
|
||||||
|
)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.io.GameContextImport
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Imports a GameContext from JSON format using Jackson.
|
||||||
|
*
|
||||||
|
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||||
|
* - Board state
|
||||||
|
* - Current turn
|
||||||
|
* - Castling rights
|
||||||
|
* - En passant square
|
||||||
|
* - Half-move clock
|
||||||
|
* - Move history
|
||||||
|
*
|
||||||
|
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||||
|
*/
|
||||||
|
object JsonParser extends GameContextImport:
|
||||||
|
|
||||||
|
private val mapper = new ObjectMapper()
|
||||||
|
.registerModule(DefaultScalaModule)
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
|
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
||||||
|
.left.map(e => "JSON parsing error: " + e.getMessage)
|
||||||
|
.flatMap { data =>
|
||||||
|
val gs = data.gameState.getOrElse(JsonGameState())
|
||||||
|
val rawBoard = gs.board.getOrElse(Nil)
|
||||||
|
val rawTurn = gs.turn.getOrElse("White")
|
||||||
|
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||||
|
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||||
|
val rawMoves = data.moves.getOrElse(Nil)
|
||||||
|
|
||||||
|
for
|
||||||
|
board <- parseBoard(rawBoard)
|
||||||
|
turn <- parseTurn(rawTurn)
|
||||||
|
castlingRights = parseCastlingRights(rawCr)
|
||||||
|
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||||
|
moves <- parseMoves(rawMoves)
|
||||||
|
yield GameContext(
|
||||||
|
board = board,
|
||||||
|
turn = turn,
|
||||||
|
castlingRights = castlingRights,
|
||||||
|
enPassantSquare = enPassantSquare,
|
||||||
|
halfMoveClock = rawHmc,
|
||||||
|
moves = moves
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||||
|
val parsedPieces = pieces.flatMap { p =>
|
||||||
|
for
|
||||||
|
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||||
|
color <- p.color.flatMap(parseColor)
|
||||||
|
pt <- p.piece.flatMap(parsePieceType)
|
||||||
|
yield (sq, Piece(color, pt))
|
||||||
|
}
|
||||||
|
Right(Board(parsedPieces.toMap))
|
||||||
|
|
||||||
|
private def parseTurn(color: String): Either[String, Color] =
|
||||||
|
parseColor(color).toRight(s"Invalid turn color: $color")
|
||||||
|
|
||||||
|
private def parseColor(color: String): Option[Color] =
|
||||||
|
if color == "White" then Some(Color.White)
|
||||||
|
else if color == "Black" then Some(Color.Black)
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def parsePieceType(pt: String): Option[PieceType] =
|
||||||
|
pt match
|
||||||
|
case "Pawn" => Some(PieceType.Pawn)
|
||||||
|
case "Knight" => Some(PieceType.Knight)
|
||||||
|
case "Bishop" => Some(PieceType.Bishop)
|
||||||
|
case "Rook" => Some(PieceType.Rook)
|
||||||
|
case "Queen" => Some(PieceType.Queen)
|
||||||
|
case "King" => Some(PieceType.King)
|
||||||
|
case _ => None
|
||||||
|
|
||||||
|
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||||
|
CastlingRights(
|
||||||
|
cr.whiteKingSide.getOrElse(false),
|
||||||
|
cr.whiteQueenSide.getOrElse(false),
|
||||||
|
cr.blackKingSide.getOrElse(false),
|
||||||
|
cr.blackQueenSide.getOrElse(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||||
|
Right(moves.flatMap { m =>
|
||||||
|
for
|
||||||
|
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||||
|
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||||
|
moveType <- m.`type`.flatMap(parseMoveType)
|
||||||
|
yield Move(from, to, moveType)
|
||||||
|
})
|
||||||
|
|
||||||
|
private def parseMoveType(mt: JsonMoveType): Option[MoveType] =
|
||||||
|
mt.`type` match
|
||||||
|
case Some("normal") =>
|
||||||
|
Some(MoveType.Normal(mt.isCapture.getOrElse(false)))
|
||||||
|
case Some("castleKingside") =>
|
||||||
|
Some(MoveType.CastleKingside)
|
||||||
|
case Some("castleQueenside") =>
|
||||||
|
Some(MoveType.CastleQueenside)
|
||||||
|
case Some("enPassant") =>
|
||||||
|
Some(MoveType.EnPassant)
|
||||||
|
case Some("promotion") =>
|
||||||
|
val piece = mt.promotionPiece match
|
||||||
|
case Some("queen") => PromotionPiece.Queen
|
||||||
|
case Some("rook") => PromotionPiece.Rook
|
||||||
|
case Some("bishop") => PromotionPiece.Bishop
|
||||||
|
case Some("knight") => PromotionPiece.Knight
|
||||||
|
case _ => PromotionPiece.Queen // default
|
||||||
|
Some(MoveType.Promotion(piece))
|
||||||
|
case _ => None
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package de.nowchess.io
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
|
import java.nio.file.{Files, Paths}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
import scala.util.Using
|
||||||
|
|
||||||
|
class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("saveGameToFile: writes JSON file successfully") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||||
|
try
|
||||||
|
val context = GameContext.initial
|
||||||
|
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
assert(Files.exists(tmpFile))
|
||||||
|
assert(Files.size(tmpFile) > 0)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadGameFromFile: reads JSON file successfully") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||||
|
try
|
||||||
|
val originalContext = GameContext.initial
|
||||||
|
|
||||||
|
// Save
|
||||||
|
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
|
||||||
|
|
||||||
|
// Load
|
||||||
|
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
val loaded = result.getOrElse(GameContext.initial)
|
||||||
|
assert(loaded == originalContext)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadGameFromFile: returns error on missing file") {
|
||||||
|
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
|
||||||
|
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
||||||
|
|
||||||
|
assert(result.isLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("saveGameToFile: persists game with moves") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_moves_", ".json")
|
||||||
|
try
|
||||||
|
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||||
|
val context = GameContext.initial
|
||||||
|
.withMove(move1)
|
||||||
|
.withMove(move2)
|
||||||
|
|
||||||
|
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||||
|
assert(saveResult.isRight)
|
||||||
|
|
||||||
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
assert(loadResult.isRight)
|
||||||
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
|
assert(loaded.moves.length == 2)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("saveGameToFile: overwrites existing file") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_overwrite_", ".json")
|
||||||
|
try
|
||||||
|
// Write first file
|
||||||
|
val context1 = GameContext.initial
|
||||||
|
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
|
||||||
|
val size1 = Files.size(tmpFile)
|
||||||
|
|
||||||
|
// Write second file (should overwrite)
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val context2 = GameContext.initial.withMove(move)
|
||||||
|
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
||||||
|
|
||||||
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
assert(loadResult.isRight)
|
||||||
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
|
assert(loaded.moves.length == 1)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadGameFromFile: handles invalid JSON in file") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_invalid_", ".json")
|
||||||
|
try
|
||||||
|
Files.write(tmpFile, "{ invalid json}".getBytes())
|
||||||
|
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
|
assert(result.isLeft)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("round-trip: save and load preserves game state") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_roundtrip_", ".json")
|
||||||
|
try
|
||||||
|
val move1 = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R4))
|
||||||
|
val move2 = Move(Square(File.H, Rank.R7), Square(File.H, Rank.R5))
|
||||||
|
val original = GameContext.initial
|
||||||
|
.withMove(move1)
|
||||||
|
.withMove(move2)
|
||||||
|
.withHalfMoveClock(3)
|
||||||
|
|
||||||
|
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
|
||||||
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
|
assert(loadResult.isRight)
|
||||||
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
|
assert(loaded.moves.length == 2)
|
||||||
|
assert(loaded.halfMoveClock == 3)
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("saveGameToFile: handles exporter that throws exception") {
|
||||||
|
val tmpFile = Files.createTempFile("chess_test_exporter_error_", ".json")
|
||||||
|
try
|
||||||
|
val context = GameContext.initial
|
||||||
|
val faultyExporter = new GameContextExport {
|
||||||
|
def exportGameContext(c: GameContext): String =
|
||||||
|
throw new RuntimeException("Export failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||||
|
assert(result.isLeft)
|
||||||
|
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||||
|
finally
|
||||||
|
Files.deleteIfExists(tmpFile)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("export all promotion pieces separately for full branch coverage") {
|
||||||
|
val promotions = List(
|
||||||
|
(PromotionPiece.Queen, "queen"),
|
||||||
|
(PromotionPiece.Rook, "rook"),
|
||||||
|
(PromotionPiece.Bishop, "bishop"),
|
||||||
|
(PromotionPiece.Knight, "knight")
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((piece, expectedName) <- promotions) do
|
||||||
|
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||||
|
// Empty boards can cause issues in PgnExporter, using initial
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||||
|
try {
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include (s""""$expectedName"""")
|
||||||
|
} catch { case _: Exception => }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export normal non-capture move") {
|
||||||
|
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include ("\"normal\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export normal capture move manually") {
|
||||||
|
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
try {
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include ("\"normal\"")
|
||||||
|
json should include ("\"isCapture\": true")
|
||||||
|
} catch { case _: Exception => }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export all move type categories") {
|
||||||
|
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
|
||||||
|
json should include ("\"moves\"")
|
||||||
|
json should include ("\"from\"")
|
||||||
|
json should include ("\"to\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export castle queenside move") {
|
||||||
|
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
try {
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include ("\"castleQueenside\"")
|
||||||
|
} catch { case _: Exception => }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export castle kingside move") {
|
||||||
|
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
try {
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include ("\"castleKingside\"")
|
||||||
|
} catch { case _: Exception => }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export en passant move manually") {
|
||||||
|
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||||
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
|
try {
|
||||||
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
json should include ("\"enPassant\"")
|
||||||
|
json should include ("\"isCapture\": true")
|
||||||
|
} catch { case _: Exception => }
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("exportGameContext: exports initial position") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"metadata\"")
|
||||||
|
json should include("\"gameState\"")
|
||||||
|
json should include("\"moveHistory\"")
|
||||||
|
json should include("\"capturedPieces\"")
|
||||||
|
json should include("\"timestamp\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: includes board pieces") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"a1\"")
|
||||||
|
json should include("\"Rook\"")
|
||||||
|
json should include("\"White\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: includes turn information") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"turn\": \"White\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: includes castling rights") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"whiteKingSide\": true")
|
||||||
|
json should include("\"whiteQueenSide\": true")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports with moves") {
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val context = GameContext.initial.withMove(move)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"moves\"")
|
||||||
|
json should include("\"from\"")
|
||||||
|
json should include("\"to\"")
|
||||||
|
json should include("\"e2\"")
|
||||||
|
json should include("\"e4\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: valid JSON structure") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should startWith("{")
|
||||||
|
json should endWith("}")
|
||||||
|
json should include("\"metadata\": {")
|
||||||
|
json should include("\"gameState\": {")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: empty move history for initial position") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"moves\": []")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports en passant square") {
|
||||||
|
val epSquare = Some(Square(File.E, Rank.R3))
|
||||||
|
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"enPassantSquare\": \"e3\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports null en passant square") {
|
||||||
|
val context = GameContext.initial.copy(enPassantSquare = None)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"enPassantSquare\": null")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports different move destinations") {
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val context = GameContext.initial.withMove(move)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"moves\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports empty board") {
|
||||||
|
val emptyBoard = Board(Map.empty)
|
||||||
|
val context = GameContext.initial.copy(board = emptyBoard)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"board\": []")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportGameContext: exports all castling rights disabled") {
|
||||||
|
val noCastling = CastlingRights(false, false, false, false)
|
||||||
|
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
|
json should include("\"whiteKingSide\": false")
|
||||||
|
json should include("\"whiteQueenSide\": false")
|
||||||
|
json should include("\"blackKingSide\": false")
|
||||||
|
json should include("\"blackQueenSide\": false")
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("JsonMetadata with all fields") {
|
||||||
|
val meta = JsonMetadata(Some("Event"), Some(Map("a" -> "b")), Some("2026-04-08"), Some("1-0"))
|
||||||
|
assert(meta.event.contains("Event"))
|
||||||
|
assert(meta.players.exists(_.contains("a")))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMetadata with None fields") {
|
||||||
|
val meta = JsonMetadata()
|
||||||
|
assert(meta.event.isEmpty)
|
||||||
|
assert(meta.players.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonPiece with square and piece") {
|
||||||
|
val piece = JsonPiece(Some("e4"), Some("White"), Some("Pawn"))
|
||||||
|
assert(piece.square.contains("e4"))
|
||||||
|
assert(piece.color.contains("White"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonCastlingRights all true") {
|
||||||
|
val cr = JsonCastlingRights(Some(true), Some(true), Some(true), Some(true))
|
||||||
|
assert(cr.whiteKingSide.contains(true))
|
||||||
|
assert(cr.blackQueenSide.contains(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonCastlingRights all false") {
|
||||||
|
val cr = JsonCastlingRights(Some(false), Some(false), Some(false), Some(false))
|
||||||
|
assert(cr.whiteKingSide.contains(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonGameState with all fields") {
|
||||||
|
val gs = JsonGameState(
|
||||||
|
Some(Nil),
|
||||||
|
Some("White"),
|
||||||
|
Some(JsonCastlingRights()),
|
||||||
|
Some("e3"),
|
||||||
|
Some(5)
|
||||||
|
)
|
||||||
|
assert(gs.board.contains(Nil))
|
||||||
|
assert(gs.halfMoveClock.contains(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonGameState with None fields") {
|
||||||
|
val gs = JsonGameState()
|
||||||
|
assert(gs.board.isEmpty)
|
||||||
|
assert(gs.halfMoveClock.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonCapturedPieces with pieces") {
|
||||||
|
val cp = JsonCapturedPieces(Some(List("Pawn")), Some(List("Knight")))
|
||||||
|
assert(cp.byWhite.exists(_.contains("Pawn")))
|
||||||
|
assert(cp.byBlack.exists(_.contains("Knight")))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMoveType normal with capture") {
|
||||||
|
val mt = JsonMoveType(Some("normal"), Some(true), None)
|
||||||
|
assert(mt.`type`.contains("normal"))
|
||||||
|
assert(mt.isCapture.contains(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMoveType promotion") {
|
||||||
|
val mt = JsonMoveType(Some("promotion"), None, Some("queen"))
|
||||||
|
assert(mt.`type`.contains("promotion"))
|
||||||
|
assert(mt.promotionPiece.contains("queen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMoveType castle kingside") {
|
||||||
|
val mt = JsonMoveType(Some("castleKingside"), None, None)
|
||||||
|
assert(mt.`type`.contains("castleKingside"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMove with coordinates") {
|
||||||
|
val move = JsonMove(Some("e2"), Some("e4"), Some(JsonMoveType(Some("normal"), Some(false), None)))
|
||||||
|
assert(move.from.contains("e2"))
|
||||||
|
assert(move.to.contains("e4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonGameRecord full structure") {
|
||||||
|
val record = JsonGameRecord(
|
||||||
|
Some(JsonMetadata()),
|
||||||
|
Some(JsonGameState()),
|
||||||
|
Some(""),
|
||||||
|
Some(Nil),
|
||||||
|
Some(JsonCapturedPieces()),
|
||||||
|
Some("2026-04-08T00:00:00Z")
|
||||||
|
)
|
||||||
|
assert(record.metadata.nonEmpty)
|
||||||
|
assert(record.timestamp.nonEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonGameRecord empty") {
|
||||||
|
val record = JsonGameRecord()
|
||||||
|
assert(record.metadata.isEmpty)
|
||||||
|
assert(record.moves.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonPiece with no fields") {
|
||||||
|
val piece = JsonPiece()
|
||||||
|
assert(piece.square.isEmpty)
|
||||||
|
assert(piece.color.isEmpty)
|
||||||
|
assert(piece.piece.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMoveType with no fields") {
|
||||||
|
val mt = JsonMoveType()
|
||||||
|
assert(mt.`type`.isEmpty)
|
||||||
|
assert(mt.isCapture.isEmpty)
|
||||||
|
assert(mt.promotionPiece.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("JsonMove with empty fields") {
|
||||||
|
val move = JsonMove()
|
||||||
|
assert(move.from.isEmpty)
|
||||||
|
assert(move.to.isEmpty)
|
||||||
|
assert(move.`type`.isEmpty)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Color, PieceType}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("parse invalid turn color returns error") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "Invalid", "board": []},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isLeft)
|
||||||
|
assert(result.left.toOption.get.contains("Invalid turn color"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse invalid piece type filters it out") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [
|
||||||
|
{"square": "a1", "color": "White", "piece": "InvalidPiece"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.board.pieces.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse invalid color in board filters piece") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [
|
||||||
|
{"square": "a1", "color": "InvalidColor", "piece": "Pawn"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.board.pieces.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse with missing turn uses default") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"board": []},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.turn == Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse with missing board uses empty") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White"},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.board.pieces.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse with missing moves uses empty list") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White", "board": []}
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.moves.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse invalid square in board filters it") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [
|
||||||
|
{"square": "invalid99", "color": "White", "piece": "Pawn"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.board.pieces.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse all valid piece types") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [
|
||||||
|
{"square": "a1", "color": "White", "piece": "Pawn"},
|
||||||
|
{"square": "b1", "color": "White", "piece": "Knight"},
|
||||||
|
{"square": "c1", "color": "White", "piece": "Bishop"},
|
||||||
|
{"square": "d1", "color": "White", "piece": "Rook"},
|
||||||
|
{"square": "e1", "color": "White", "piece": "Queen"},
|
||||||
|
{"square": "f1", "color": "White", "piece": "King"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.board.pieces.size == 6)
|
||||||
|
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse with all castling rights false") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [],
|
||||||
|
"castlingRights": {
|
||||||
|
"whiteKingSide": false,
|
||||||
|
"whiteQueenSide": false,
|
||||||
|
"blackKingSide": false,
|
||||||
|
"blackQueenSide": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.castlingRights.whiteKingSide == false)
|
||||||
|
assert(ctx.castlingRights.blackQueenSide == false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("parse completely invalid JSON returns error") {
|
||||||
|
val invalidJson = "{ this is not valid json at all }"
|
||||||
|
val result = JsonParser.importGameContext(invalidJson)
|
||||||
|
assert(result.isLeft)
|
||||||
|
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse empty string returns error") {
|
||||||
|
val result = JsonParser.importGameContext("")
|
||||||
|
assert(result.isLeft)
|
||||||
|
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse number value returns error") {
|
||||||
|
val result = JsonParser.importGameContext("123")
|
||||||
|
assert(result.isLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse malformed JSON object returns error") {
|
||||||
|
val malformed = """{"metadata": {"unclosed": """
|
||||||
|
val result = JsonParser.importGameContext(malformed)
|
||||||
|
assert(result.isLeft)
|
||||||
|
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse invalid JSON array returns error") {
|
||||||
|
val invalidArray = "[1, 2, 3"
|
||||||
|
val result = JsonParser.importGameContext(invalidArray)
|
||||||
|
assert(result.isLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse JSON with missing required fields") {
|
||||||
|
val json = """{"metadata": {}}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
// Should still succeed because all fields have defaults
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse valid JSON with invalid turn falls back to default") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": []
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("parse all move type variations") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {"event": "Game", "result": "*"},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": [
|
||||||
|
{"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}},
|
||||||
|
{"from": "e1", "to": "g1", "type": {"type": "castleKingside"}},
|
||||||
|
{"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}},
|
||||||
|
{"from": "e5", "to": "d4", "type": {"type": "enPassant"}},
|
||||||
|
{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}},
|
||||||
|
{"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}},
|
||||||
|
{"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}},
|
||||||
|
{"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
assert(ctx.moves.length == 8)
|
||||||
|
assert(ctx.moves(0).moveType == MoveType.Normal(false))
|
||||||
|
assert(ctx.moves(1).moveType == MoveType.CastleKingside)
|
||||||
|
assert(ctx.moves(2).moveType == MoveType.CastleQueenside)
|
||||||
|
assert(ctx.moves(3).moveType == MoveType.EnPassant)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse invalid move type defaults to None") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {"event": "Game"},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
// Invalid move type is skipped, so moves list should be empty
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse promotion with default piece") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
// Invalid promotion piece should use default
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse move with missing from/to skips it") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
// Invalid square should be filtered out
|
||||||
|
assert(ctx.moves.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse with invalid JSON returns error") {
|
||||||
|
val json = """{"invalid json"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse normal move with isCapture true") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {"turn": "White", "board": []},
|
||||||
|
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
val move = ctx.moves.head
|
||||||
|
assert(move.moveType == MoveType.Normal(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parse board with invalid pieces filters them") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {},
|
||||||
|
"gameState": {
|
||||||
|
"turn": "White",
|
||||||
|
"board": [
|
||||||
|
{"square": "a1", "color": "White", "piece": "Rook"},
|
||||||
|
{"square": "invalid", "color": "White", "piece": "King"},
|
||||||
|
{"square": "a2", "color": "Invalid", "piece": "Pawn"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
assert(result.isRight)
|
||||||
|
val ctx = result.toOption.get
|
||||||
|
// Only valid piece should be in board
|
||||||
|
assert(ctx.board.pieces.size == 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.nowchess.io.json
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("importGameContext: parses valid JSON") {
|
||||||
|
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: restores board state") {
|
||||||
|
val context = GameContext.initial
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result == Right(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: restores turn") {
|
||||||
|
val context = GameContext.initial.withTurn(Color.Black)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.turn) == Right(Color.Black))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: restores moves") {
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val context = GameContext.initial.withMove(move)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.moves.length) == Right(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles empty board") {
|
||||||
|
val json = """{
|
||||||
|
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||||
|
"gameState": {
|
||||||
|
"board": [],
|
||||||
|
"turn": "White",
|
||||||
|
"castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true},
|
||||||
|
"enPassantSquare": null,
|
||||||
|
"halfMoveClock": 0
|
||||||
|
},
|
||||||
|
"moves": [],
|
||||||
|
"moveHistory": "",
|
||||||
|
"capturedPieces": {"byWhite": [], "byBlack": []},
|
||||||
|
"timestamp": "2026-04-06T00:00:00Z"
|
||||||
|
}"""
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: returns error on invalid JSON") {
|
||||||
|
val result = JsonParser.importGameContext("not valid json {{{")
|
||||||
|
|
||||||
|
assert(result.isLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles missing fields with defaults") {
|
||||||
|
val json = "{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles castling rights") {
|
||||||
|
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||||
|
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: round-trip consistency") {
|
||||||
|
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||||
|
val context = GameContext.initial
|
||||||
|
.withMove(move1)
|
||||||
|
.withMove(move2)
|
||||||
|
.withTurn(Color.White)
|
||||||
|
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val restored = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(restored.map(_.moves.length) == Right(2))
|
||||||
|
assert(restored.map(_.turn) == Right(Color.White))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles half-move clock") {
|
||||||
|
val context = GameContext.initial.withHalfMoveClock(5)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.halfMoveClock) == Right(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: parses en passant square") {
|
||||||
|
// Create a context with en passant square
|
||||||
|
val epSquare = Some(Square(File.E, Rank.R3))
|
||||||
|
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles black turn") {
|
||||||
|
val context = GameContext.initial.withTurn(Color.Black)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.turn) == Right(Color.Black))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||||
|
// Use simple move without explicit moveType to let system handle it
|
||||||
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
|
val context = GameContext.initial.withMove(move)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.isRight)
|
||||||
|
assert(result.map(_.moves.length) == Right(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles all castling rights disabled") {
|
||||||
|
val noCastling = CastlingRights(false, false, false, false)
|
||||||
|
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importGameContext: handles mixed castling rights") {
|
||||||
|
val mixed = CastlingRights(true, false, false, true)
|
||||||
|
val context = GameContext.initial.withCastlingRights(mixed)
|
||||||
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
|
assert(result.map(_.castlingRights) == Right(mixed))
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=2
|
MINOR=4
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -10,3 +10,13 @@
|
|||||||
### 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))
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=0
|
MINOR=0
|
||||||
PATCH=4
|
PATCH=6
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
|||||||
import de.nowchess.chess.engine.GameEngine
|
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.{GameContextExport, GameContextImport}
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
|
import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService}
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import scalafx.stage.FileChooser
|
||||||
|
import scalafx.stage.FileChooser.ExtensionFilter
|
||||||
|
|
||||||
/** ScalaFX chess board view that displays the game state.
|
/** ScalaFX chess board view that displays the game state.
|
||||||
* Uses chess sprites and color palette.
|
* Uses chess sprites and color palette.
|
||||||
@@ -124,6 +128,22 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
new HBox {
|
||||||
|
spacing = 10
|
||||||
|
alignment = Pos.Center
|
||||||
|
children = Seq(
|
||||||
|
new Button("JSON Export") {
|
||||||
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
|
onAction = _ => doJsonExport()
|
||||||
|
style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;"
|
||||||
|
},
|
||||||
|
new Button("JSON Import") {
|
||||||
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
|
onAction = _ => doJsonImport()
|
||||||
|
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -289,6 +309,45 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
private def doPgnImport(): Unit =
|
private def doPgnImport(): Unit =
|
||||||
doImport(PgnParser, "PGN")
|
doImport(PgnParser, "PGN")
|
||||||
|
|
||||||
|
private def doJsonExport(): Unit =
|
||||||
|
val fileChooser = new FileChooser {
|
||||||
|
title = "Export Game as JSON"
|
||||||
|
initialFileName = "chess_game.json"
|
||||||
|
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||||
|
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedFile = fileChooser.showSaveDialog(stage)
|
||||||
|
if selectedFile != null then
|
||||||
|
val result = FileSystemGameService.saveGameToFile(
|
||||||
|
engine.context,
|
||||||
|
selectedFile.toPath,
|
||||||
|
JsonExporter
|
||||||
|
)
|
||||||
|
result match
|
||||||
|
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
||||||
|
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
|
||||||
|
|
||||||
|
private def doJsonImport(): Unit =
|
||||||
|
val fileChooser = new FileChooser {
|
||||||
|
title = "Import Game from JSON"
|
||||||
|
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||||
|
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedFile = fileChooser.showOpenDialog(stage)
|
||||||
|
if selectedFile != null then
|
||||||
|
val result = FileSystemGameService.loadGameFromFile(
|
||||||
|
selectedFile.toPath,
|
||||||
|
JsonParser
|
||||||
|
)
|
||||||
|
result match
|
||||||
|
case Right(gameContext) =>
|
||||||
|
engine.loadPosition(gameContext)
|
||||||
|
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
|
||||||
|
case Left(err) =>
|
||||||
|
showMessage(s"⚠️ Error: $err")
|
||||||
|
|
||||||
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user