feat: Enhance GameResource with game state validation and add comprehensive tests

This commit is contained in:
2026-04-20 19:08:45 +02:00
parent 98c65dbef6
commit 6cb3f41e44
7 changed files with 379 additions and 34 deletions
+26 -29
View File
@@ -10,7 +10,7 @@ Scala 3.5.1 · Gradle 9
./test # Run all tests
./coverage # Check coverage
```
Try to stick to these commands for consistency.
Use consistently.
## Modules
@@ -25,14 +25,14 @@ Try to stick to these commands for consistency.
## Style
- Use immutable data and pure functions.
- Keep functions under 30 lines. If you need "and" to describe it, split it.
- Keep cyclomatic complexity under 15.
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
- Scan for duplicated logic before finishing. Extract it.
- Immutable data, pure functions.
- Functions under 30 lines. Need "and"? Split it.
- Cyclomatic complexity under 15.
- No comments. Names carry intent. Comment non-obvious algorithms only.
- Scan duplicated logic. Extract.
- Follow default Sonar style for Scala.
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
- Naming: types are PascalCase, functions/values are camelCase.
- `Option`/`Either` for fallible ops. Skip exceptions for control flow.
- Naming: types PascalCase, functions/values camelCase.
## Code Quality
@@ -40,23 +40,23 @@ Try to stick to these commands for consistency.
### Linters
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
- **scalafmt** — Enforces formatting. Check: `./gradlew spotlessScalaCheck`. Refactor: `./gradlew spotlessScalaApply`.
- **scalafix** — Enforces style, detects unused imports/code. Run: `./gradlew scalafix`.
## Architecture Decisions
- **Immutable state as primary model:** GameContext (api) holds board, history, player stateimmutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
- **Immutable state as primary model:** GameContext (api) holds board, history, player stateimmutable throughout. Each move new GameContext. Enables undo/redo without side effects.
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens (no polling). GameEngine never imports UI.
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as black box; rules don't know rest of core.
- **Polyglot hash must follow spec index layout:** Piece keys use interleaved mapping `(pieceType * 2 + colorBit)` (black=0, white=1). Castling keys: `768..771`. En-passant file keys: `772..779`, XORed only if side-to-move has capturable en passant. Side-to-move key: `780` (white).
- **Alpha-beta uses sequential PV search by default:** Parallel split disabled (fixed-window futures removed pruning effectiveness). Sequential PV default. Correctness + pruning quality > speculative parallelism.
- **Search hash is updated incrementally per move:** Bot search updates Zobrist keys from parent hash with move deltas, not recomputing piece scans per node.
## Rules
- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
- **Tests are the spec.** Don't modify to pass. Fix requirements/code. Update only if requirements change.
- Never read build folders. Ask permission if needed.
- Keep this file up to date with any important decisions or conventions.
- Keep file current with decisions + conventions.
---
@@ -64,11 +64,9 @@ Try to stick to these commands for consistency.
### Two-Step Rule (mandatory)
**Step 1 — Orient:** Use wiki articles to find WHERE things live.
**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
**Step 2 — Verify:** Read source files from wiki BEFORE coding.
Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
**Never write or modify code based solely on wiki content — always read source files first.**
Wiki = structural summaries (routes, models, file locations). No function logic, middleware internals, runtime behavior. Don't code from wiki alone—read sources.
Read in order at session start:
1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
@@ -76,8 +74,7 @@ Read in order at session start:
3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
4. `.codesight/CODESIGHT.md` — full context map for deep exploration
Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
`[inferred]` routes = regex-detected. Verify sources. ⚠ in wiki? Re-run `codesight --wiki`.
Or use the codesight MCP server for on-demand queries:
- `codesight_get_wiki_article` — read a specific wiki article by name
@@ -87,13 +84,13 @@ Or use the codesight MCP server for on-demand queries:
- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
- `codesight_get_schema --model users` — specific model details
Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
Consult codesight context first. Saves ~16.893 tokens/conversation.
## graphify
This project has a graphify knowledge graph at graphify-out/.
graphify knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
- Architecture/codebase questions? Read graphify-out/GRAPH_REPORT.md (god nodes, communities).
- graphify-out/wiki/index.md exists? Use it (not raw files).
- Code modified? Run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to sync graph.
+99
View File
@@ -0,0 +1,99 @@
# Now-Chess
Scala 3.5.1 · Gradle 9
## Commands
```
./clean # Clear build dirs — only when necessary
./compile # Compile all modules — always run
./test # Run all tests
./coverage # Check coverage
```
Try to stick to these commands for consistency.
## Modules
| Module | Role | Depends on |
|--------|------|-----------|
| `api` | Model / shared types | (none) |
| `core` | Primary business logic | api, rule |
| `rule` | Game rules | api |
| `bot` | Bots and AI | api,rule,io |
| `io` | Export formats | api, core |
| `ui` | Entrypoint & UI | core, io |
## Style
- Use immutable data and pure functions.
- Keep functions under 30 lines. If you need "and" to describe it, split it.
- Keep cyclomatic complexity under 15.
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
- Scan for duplicated logic before finishing. Extract it.
- Follow default Sonar style for Scala.
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
- Naming: types are PascalCase, functions/values are camelCase.
## Code Quality
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
### Linters
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
## Architecture Decisions
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
## Rules
- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
- Never read build folders. Ask permission if needed.
- Keep this file up to date with any important decisions or conventions.
---
## Instructions for Claude Code
### Two-Step Rule (mandatory)
**Step 1 — Orient:** Use wiki articles to find WHERE things live.
**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
**Never write or modify code based solely on wiki content — always read source files first.**
Read in order at session start:
1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
2. `.codesight/wiki/overview.md` — architecture overview (~500 tokens)
3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
4. `.codesight/CODESIGHT.md` — full context map for deep exploration
Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
Or use the codesight MCP server for on-demand queries:
- `codesight_get_wiki_article` — read a specific wiki article by name
- `codesight_get_wiki_index` — get the wiki index
- `codesight_get_summary` — quick project overview
- `codesight_get_routes --prefix /api/users` — filtered routes
- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
- `codesight_get_schema --model users` — specific model details
Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
## graphify
This project has a graphify knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
+1 -1
View File
@@ -1,7 +1,7 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew test
# ./gradlew test
if [ "$#" -eq 0 ]; then
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
+1
View File
@@ -74,6 +74,7 @@ dependencies {
testImplementation("io.rest-assured:rest-assured")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
@@ -19,4 +19,4 @@ class GameRegistryImpl extends GameRegistry:
def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString
Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
@@ -125,6 +125,9 @@ class GameResource:
private def ok(body: AnyRef): Response = Response.ok(body).build()
private def created(body: AnyRef): Response = Response.status(Response.Status.CREATED).entity(body).build()
private def assertGameNotOver(entry: GameEntry): Unit =
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
// ── endpoints ────────────────────────────────────────────────────────────
// scalafix:off DisableSyntax.throw
@@ -171,7 +174,7 @@ class GameResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def resignGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
entry.engine.resign()
registry.update(entry.copy(resigned = true))
ok(OkResponseDto())
@@ -181,7 +184,7 @@ class GameResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
val (from, to, promoOpt) = Parser
.parseMove(uci)
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
@@ -236,7 +239,7 @@ class GameResource:
@PathParam("action") action: String,
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
action match
case "offer" =>
entry.engine.offerDraw(entry.engine.context.turn)
@@ -0,0 +1,245 @@
package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.*
import de.nowchess.chess.exception.BadRequestException
import de.nowchess.chess.registry.GameRegistry
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.compiletime.uninitialized
@QuarkusTest
class GameResourceTest extends AnyFunSuite with Matchers:
@Inject
var resource: GameResource = uninitialized
@Inject
var registry: GameRegistry = uninitialized
@Inject
var objectMapper: ObjectMapper = uninitialized
test("createGame returns 201 with game data"):
val req = CreateGameRequestDto(None, None)
val resp = resource.createGame(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.gameId should not be null
dto.white.displayName shouldBe "Player 1"
dto.black.displayName shouldBe "Player 2"
dto.state.status shouldBe "started"
test("createGame with custom players"):
val white = PlayerInfoDto("custom1", "Alice")
val black = PlayerInfoDto("custom2", "Bob")
val req = CreateGameRequestDto(Some(white), Some(black))
val resp = resource.createGame(req)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.white.displayName shouldBe "Alice"
dto.black.displayName shouldBe "Bob"
test("getGame returns 200 with game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
getResp.getStatus shouldBe 200
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
dto.gameId shouldBe gameId
test("getGame throws GameNotFoundException for invalid gameId"):
assertThrows[Exception]:
resource.getGame("invalid-id")
test("makeMove on new game advances position"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
moveResp.getStatus shouldBe 200
val state = moveResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "black"
test("makeMove with invalid UCI throws BadRequestException"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.makeMove(gameId, "invalid")
test("makeMove throws GAME_OVER after game ends"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.makeMove(gameId, "a2a3")
ex.code shouldBe "GAME_OVER"
test("getLegalMoves returns all moves when no square specified"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
movesResp.getStatus shouldBe 200
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
dto.moves should not be empty
test("getLegalMoves returns moves for specific square"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "e2")
movesResp.getStatus shouldBe 200
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
dto.moves.map(_.from).distinct shouldBe List("e2")
test("getLegalMoves with invalid square throws"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.getLegalMoves(gameId, "invalid")
test("resignGame updates game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
resignResp.getStatus shouldBe 200
val getResp = resource.getGame(gameId)
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
dto.state.status shouldBe "resign"
test("resignGame throws GAME_OVER when game already over"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.resignGame(gameId)
ex.code shouldBe "GAME_OVER"
test("undoMove reverts last move"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
undoResp.getStatus shouldBe 200
val state = undoResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "white"
test("undoMove throws NO_UNDO when no moves to undo"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.undoMove(gameId)
test("redoMove restores undone move"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
val redoResp = resource.redoMove(gameId)
redoResp.getStatus shouldBe 200
val state = redoResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "black"
test("redoMove throws NO_REDO when no moves to redo"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.redoMove(gameId)
test("drawAction offer returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
resp.getStatus shouldBe 200
test("drawAction accept returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
resp.getStatus shouldBe 200
test("drawAction decline returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "decline")
resp.getStatus shouldBe 200
test("drawAction claim returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "claim")
resp.getStatus shouldBe 200
test("drawAction with invalid action throws"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.drawAction(gameId, "invalid")
test("drawAction throws GAME_OVER when game already over"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.drawAction(gameId, "offer")
ex.code shouldBe "GAME_OVER"
test("importFen creates game from FEN"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val req = ImportFenRequestDto(fen, None, None)
val resp = resource.importFen(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.state.fen shouldBe fen
test("importFen with invalid FEN throws"):
val req = ImportFenRequestDto("invalid fen", None, None)
assertThrows[BadRequestException]:
resource.importFen(req)
test("importPgn creates game from PGN"):
val pgn = "1. e4 c5"
val req = ImportPgnRequestDto(pgn)
val resp = resource.importPgn(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.state.moves.length should be > 0
test("importPgn with invalid PGN throws"):
val req = ImportPgnRequestDto("invalid pgn")
assertThrows[BadRequestException]:
resource.importPgn(req)
test("exportFen returns FEN string"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
resp.getStatus shouldBe 200
resp.getEntity.asInstanceOf[String] should include("rnbqkbnr")
test("exportPgn returns PGN string"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
resp.getStatus shouldBe 200
resp.getEntity.asInstanceOf[String] should include("1.")
test("streamGame emits initial game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val multi = resource.streamGame(gameId)
val events = multi.collect().asList().await.indefinitely()
events should not be empty
events.get(0) should include("GameFullEventDto")