chore: Set up shared-models library and initial project structure for NowChessSystems
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Architect Agent Memory Index
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
- [api-shared-models module](project_api_module.md) — Status and design of `modules/api`; package layout, what belongs/doesn't, ADR location
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: api-shared-models module
|
||||||
|
description: Status and design decisions for the modules/api shared-models library
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
`modules/api` is established as the shared-models library for NowChessSystems.
|
||||||
|
|
||||||
|
**Why:** All microservices need a common chess domain vocabulary (Square, Move, GameState, etc.) and cross-cutting API envelope types (ApiResponse, ApiError). Without a shared module, types diverge and cause serialisation mismatches.
|
||||||
|
|
||||||
|
**How to apply:** When designing any new service, confirm it declares `implementation(project(":modules:api"))` and does not duplicate any of the types already present. New cross-cutting types (used by 2+ services) should go into `modules/api`, not into a service module.
|
||||||
|
|
||||||
|
Package layout:
|
||||||
|
- `de.nowchess.api.board` — Color, PieceType, Piece, File, Rank, Square
|
||||||
|
- `de.nowchess.api.game` — CastlingRights, GameState, GameResult, GameStatus
|
||||||
|
- `de.nowchess.api.move` — MoveType, Move, PromotionPiece
|
||||||
|
- `de.nowchess.api.player` — PlayerId (opaque type), PlayerInfo
|
||||||
|
- `de.nowchess.api.response` — ApiResponse[A], ApiError, Pagination, PagedResponse[A]
|
||||||
|
|
||||||
|
ADR: `docs/adr/ADR-002-api-shared-models.md`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Memory Index
|
||||||
|
|
||||||
|
## Project
|
||||||
|
- [project_chess_tui.md](project_chess_tui.md) — Chess TUI in modules/core under de.nowchess.chess: model, renderer, parser, game loop
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: chess_tui_implementation
|
||||||
|
description: Chess TUI implemented in modules/core under de.nowchess.chess — model, renderer, parser, game loop
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Chess TUI standalone app implemented in `modules/core`, package `de.nowchess.chess`.
|
||||||
|
|
||||||
|
**Why:** Initial feature to demonstrate the system's TUI capability per ADR-001.
|
||||||
|
|
||||||
|
**How to apply:** When extending the chess logic (legality, castling, en passant, promotion), build on the existing `Model.scala` opaque `Board` type and add methods via extension. The `@main` entry point is `chessMain` in `Game.scala`. `Test.scala` still exists as a separate hello-world stub — do not remove it.
|
||||||
|
|
||||||
|
Key design choices:
|
||||||
|
- `Board` is an opaque type over `Map[Square, Piece]` with extension methods
|
||||||
|
- `Color` and `PieceType` are Scala 3 enums
|
||||||
|
- `Renderer.render` returns `String`, never prints
|
||||||
|
- `Parser.parseMove` returns `Option[(Square, Square)]` — coordinate notation only (e.g. `e2e4`)
|
||||||
|
- No move legality validation — moves are applied as-is
|
||||||
|
- ANSI 256-colour background codes used for light/dark squares (48;5;223 beige, 48;5;130 brown)
|
||||||
Generated
+1
@@ -9,6 +9,7 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/api" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ScalaCompilerConfiguration">
|
<component name="ScalaCompilerConfiguration">
|
||||||
<profile name="Gradle 1" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.test">
|
<profile name="Gradle 1" modules="NowChessSystems.modules.api.main,NowChessSystems.modules.api.test,NowChessSystems.modules.core.main,NowChessSystems.modules.core.test">
|
||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</profile>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build everything
|
||||||
|
./gradlew build
|
||||||
|
|
||||||
|
# Build a single module
|
||||||
|
./gradlew :modules:<service>:build
|
||||||
|
|
||||||
|
# Run tests for a single module
|
||||||
|
./gradlew :modules:<service>:test
|
||||||
|
|
||||||
|
# Run a specific test class
|
||||||
|
./gradlew :modules:<service>:test --tests "de.nowchess.<service>.<ClassName>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The only current module is `core` (`modules/core`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**NowChessSystems** is a chess platform built as a Scala 3 + Quarkus microservice system.
|
||||||
|
|
||||||
|
- Multi-module Gradle project; every service lives under `modules/{service-name}`.
|
||||||
|
- Shared dependency versions live in the root `build.gradle.kts` under `extra["VERSIONS"]`.
|
||||||
|
- Each module reads versions via `rootProject.extra["VERSIONS"] as Map<String, String>`.
|
||||||
|
- `settings.gradle.kts` must `include(":modules:<service>")` for every module.
|
||||||
|
|
||||||
|
### Stack (ADR-001)
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Language | Scala 3.5.x |
|
||||||
|
| Backend framework | Quarkus + `quarkus-scala3` extension |
|
||||||
|
| Persistence | Hibernate / Jakarta Persistence |
|
||||||
|
| Frontend (TBD) | Vite; React/Angular/Vue under evaluation |
|
||||||
|
| TUI | Lanterna |
|
||||||
|
| Container orchestration | Kubernetes + ArgoCD + Kargo |
|
||||||
|
|
||||||
|
### Key Scala 3 / Quarkus Rules
|
||||||
|
- Use `given`/`using`, not `implicit` (no Scala 2 idioms).
|
||||||
|
- Use `Option`/`Either`/`Try`, never `null` or `.get`.
|
||||||
|
- Jakarta annotations only (`jakarta.*`), never `javax.*`.
|
||||||
|
- Use reactive types (`Uni`, `Multi`) for I/O; no blocking calls on the event loop.
|
||||||
|
- **Always exclude `org.scala-lang:scala-library` from Quarkus BOM** to avoid Scala 2 conflicts.
|
||||||
|
- **All test methods must be explicitly typed `: Unit`** — JUnit 5 + Scala 3 requires this.
|
||||||
|
|
||||||
|
### Agent Workflow (for new services)
|
||||||
|
1. **architect** → writes OpenAPI contract to `docs/api/{service}.yaml` and ADR to `docs/adr/`.
|
||||||
|
2. **scala-implementer** → reads contract, implements service under `modules/{service}/`.
|
||||||
|
3. **test-writer** → writes `@QuarkusTest` integration tests and plain JUnit 5 unit tests.
|
||||||
|
4. **gradle-builder** → resolves any build/dependency issues.
|
||||||
|
5. **code-reviewer** → reviews; reports findings back without self-fixing.
|
||||||
|
|
||||||
|
Detailed working agreement (plan/verify/unresolved workflow) is in `.claude/CLAUDE.MD`.
|
||||||
+2
-1
@@ -6,4 +6,5 @@ val versions = mapOf(
|
|||||||
"SCALA3" to "3.5.1",
|
"SCALA3" to "3.5.1",
|
||||||
"SCALA_LIBRARY" to "2.13.18"
|
"SCALA_LIBRARY" to "2.13.18"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# ADR-002: Shared-Models Library (`modules/api`)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
NowChessSystems is a microservice platform. As soon as two or more services need to
|
||||||
|
exchange data — whether through REST, messaging, or internal function calls — they must
|
||||||
|
agree on common data types. Without a shared home for those types, the same case class
|
||||||
|
(e.g. `Square`, `Move`, `GameState`) is duplicated in every module, diverges over time,
|
||||||
|
and causes silent serialisation mismatches at runtime.
|
||||||
|
|
||||||
|
The `core` module currently owns the chess engine logic. Future modules (matchmaking,
|
||||||
|
game history, user management, notation export, etc.) will all need to refer to the
|
||||||
|
same chess domain vocabulary. A cross-cutting place to hold that vocabulary is therefore
|
||||||
|
required before any second service is built.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We introduce `modules/api` as a **shared-models library**: a plain Scala 3 library
|
||||||
|
(no Quarkus, no Jakarta, no persistence) that contains only:
|
||||||
|
|
||||||
|
- Pure Scala 3 data types: `case class`, `sealed trait`, and `enum` definitions
|
||||||
|
- Value objects that model the chess domain (pieces, colors, squares, moves, game state)
|
||||||
|
- Cross-service API envelope types (`ApiResponse[A]`, `ApiError`, `Pagination`)
|
||||||
|
- Minimal player/user identity stubs (IDs and display names only)
|
||||||
|
|
||||||
|
Every service module that needs these types declares:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
implementation(project(":modules:api"))
|
||||||
|
```
|
||||||
|
|
||||||
|
in its own `build.gradle.kts`. The `modules/api` module itself carries no runtime
|
||||||
|
dependencies beyond the Scala 3 standard library.
|
||||||
|
|
||||||
|
### Package layout
|
||||||
|
|
||||||
|
```
|
||||||
|
de.nowchess.api
|
||||||
|
├── board – Color, PieceType, Piece, File, Rank, Square
|
||||||
|
├── game – CastlingRights, GameState, GameResult, GameStatus
|
||||||
|
├── move – MoveType, Move, PromotionPiece
|
||||||
|
├── player – PlayerId, PlayerInfo
|
||||||
|
└── response – ApiResponse, ApiError, Pagination
|
||||||
|
```
|
||||||
|
|
||||||
|
## What belongs in `modules/api`
|
||||||
|
|
||||||
|
| Belongs | Does NOT belong |
|
||||||
|
|---|---|
|
||||||
|
| `case class`, `sealed trait`, `enum` for chess domain | Quarkus `@ApplicationScoped` beans |
|
||||||
|
| API envelope types (`ApiResponse`, `ApiError`) | Jakarta Persistence entities (`@Entity`) |
|
||||||
|
| Player identity stubs (ID + display name) | REST resource classes |
|
||||||
|
| FEN/board-state representation types | Business logic, engine algorithms |
|
||||||
|
| Pure type aliases and value objects | Database queries or repositories |
|
||||||
|
|
||||||
|
The rule of thumb: if a type carries a framework annotation or requires I/O to produce,
|
||||||
|
it does not belong in `modules/api`.
|
||||||
|
|
||||||
|
## How other modules depend on it
|
||||||
|
|
||||||
|
1. `modules/api` is a regular Gradle subproject already declared in `settings.gradle.kts`.
|
||||||
|
2. Consuming modules add `implementation(project(":modules:api"))` — nothing else.
|
||||||
|
3. Because `modules/api` has no Quarkus BOM, consuming modules must not re-export Quarkus
|
||||||
|
transitive dependencies through it.
|
||||||
|
4. If a future module needs JSON serialisation, it adds its own JSON library (e.g.
|
||||||
|
`circe`, `jsoniter-scala`) as a dependency and derives codecs for the shared types
|
||||||
|
there — codec derivation stays out of `modules/api`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Single source of truth for all chess domain vocabulary.
|
||||||
|
- Adding a new microservice requires only one `implementation(project(":modules:api"))`
|
||||||
|
line — no copy-paste of types.
|
||||||
|
- The library is fast to compile (no framework processing) and cheap to test in isolation.
|
||||||
|
- Enforces a strict boundary: if a type needs a framework annotation it is forced into the
|
||||||
|
correct service module.
|
||||||
|
|
||||||
|
### Negative / Risks
|
||||||
|
- Any breaking change to a shared type (rename, field removal) is a cross-cutting change
|
||||||
|
that touches every consuming module simultaneously.
|
||||||
|
- Developers must resist the temptation to add convenience methods or logic to these
|
||||||
|
types; discipline is required to keep the library pure.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
plugins {
|
||||||
|
jacoco
|
||||||
|
id("scala")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.nowchess"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
scala {
|
||||||
|
versions["SCALA3"]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run
|
||||||
|
}
|
||||||
|
tasks.jacocoTestReport {
|
||||||
|
dependsOn(tasks.test) // tests are required to run before generating the report
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation("org.scala-lang:scala3-compiler_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
implementation("org.scala-lang:scala3-library_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
implementation("org.scala-lang:scala-library") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA_LIBRARY"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
enum Color:
|
||||||
|
case White, Black
|
||||||
|
|
||||||
|
def opposite: Color = this match
|
||||||
|
case White => Black
|
||||||
|
case Black => White
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
/** A chess piece on the board — a combination of a color and a piece type. */
|
||||||
|
final case class Piece(color: Color, pieceType: PieceType)
|
||||||
|
|
||||||
|
object Piece:
|
||||||
|
// Convenience constructors
|
||||||
|
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
|
||||||
|
val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight)
|
||||||
|
val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
|
||||||
|
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||||
|
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||||
|
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
||||||
|
|
||||||
|
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
|
||||||
|
val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight)
|
||||||
|
val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
|
||||||
|
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||||
|
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||||
|
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
enum PieceType:
|
||||||
|
case Pawn, Knight, Bishop, Rook, Queen, King
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file (column) on the chess board, a–h.
|
||||||
|
* Ordinal values 0–7 correspond to a–h.
|
||||||
|
*/
|
||||||
|
enum File:
|
||||||
|
case A, B, C, D, E, F, G, H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rank (row) on the chess board, 1–8.
|
||||||
|
* Ordinal values 0–7 correspond to ranks 1–8.
|
||||||
|
*/
|
||||||
|
enum Rank:
|
||||||
|
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique square on the board, identified by its file and rank.
|
||||||
|
*
|
||||||
|
* @param file the column, a–h
|
||||||
|
* @param rank the row, 1–8
|
||||||
|
*/
|
||||||
|
final case class Square(file: File, rank: Rank):
|
||||||
|
/** Algebraic notation string, e.g. "e4". */
|
||||||
|
override def toString: String =
|
||||||
|
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||||
|
|
||||||
|
object Square:
|
||||||
|
/** Parse a square from algebraic notation (e.g. "e4").
|
||||||
|
* Returns None if the input is not a valid square name. */
|
||||||
|
def fromAlgebraic(s: String): Option[Square] =
|
||||||
|
if s.length != 2 then None
|
||||||
|
else
|
||||||
|
val fileChar = s.charAt(0)
|
||||||
|
val rankChar = s.charAt(1)
|
||||||
|
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
||||||
|
val rankOpt =
|
||||||
|
rankChar.toString.toIntOption.flatMap(n =>
|
||||||
|
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
||||||
|
)
|
||||||
|
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.nowchess.api.game
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Color, Square}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Castling availability flags for one side.
|
||||||
|
*
|
||||||
|
* @param kingSide king-side castling still legally available
|
||||||
|
* @param queenSide queen-side castling still legally available
|
||||||
|
*/
|
||||||
|
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
|
||||||
|
|
||||||
|
object CastlingRights:
|
||||||
|
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
|
||||||
|
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
|
||||||
|
|
||||||
|
/** Outcome of a finished game. */
|
||||||
|
enum GameResult:
|
||||||
|
case WhiteWins
|
||||||
|
case BlackWins
|
||||||
|
case Draw
|
||||||
|
|
||||||
|
/** Lifecycle state of a game. */
|
||||||
|
enum GameStatus:
|
||||||
|
case NotStarted
|
||||||
|
case InProgress
|
||||||
|
case Finished(result: GameResult)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FEN-compatible snapshot of board and game state.
|
||||||
|
*
|
||||||
|
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
|
||||||
|
* separated by '/'). All other fields mirror standard FEN fields.
|
||||||
|
*
|
||||||
|
* @param piecePlacement FEN piece-placement field, e.g.
|
||||||
|
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||||
|
* @param activeColor side to move
|
||||||
|
* @param castlingWhite castling rights for White
|
||||||
|
* @param castlingBlack castling rights for Black
|
||||||
|
* @param enPassantTarget square behind the double-pushed pawn, if any
|
||||||
|
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
|
||||||
|
* @param fullMoveNumber increments after Black's move, starts at 1
|
||||||
|
* @param status current lifecycle status of the game
|
||||||
|
*/
|
||||||
|
final case class GameState(
|
||||||
|
piecePlacement: String,
|
||||||
|
activeColor: Color,
|
||||||
|
castlingWhite: CastlingRights,
|
||||||
|
castlingBlack: CastlingRights,
|
||||||
|
enPassantTarget: Option[Square],
|
||||||
|
halfMoveClock: Int,
|
||||||
|
fullMoveNumber: Int,
|
||||||
|
status: GameStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
object GameState:
|
||||||
|
/** Standard starting position. */
|
||||||
|
val initial: GameState = GameState(
|
||||||
|
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||||
|
activeColor = Color.White,
|
||||||
|
castlingWhite = CastlingRights.Both,
|
||||||
|
castlingBlack = CastlingRights.Both,
|
||||||
|
enPassantTarget = None,
|
||||||
|
halfMoveClock = 0,
|
||||||
|
fullMoveNumber = 1,
|
||||||
|
status = GameStatus.InProgress
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.nowchess.api.move
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{PieceType, Square}
|
||||||
|
|
||||||
|
/** The piece a pawn may be promoted to (all non-pawn, non-king pieces). */
|
||||||
|
enum PromotionPiece:
|
||||||
|
case Knight, Bishop, Rook, Queen
|
||||||
|
|
||||||
|
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
||||||
|
enum MoveType:
|
||||||
|
/** A normal move or capture with no special rule. */
|
||||||
|
case Normal
|
||||||
|
/** Kingside castling (O-O). */
|
||||||
|
case CastleKingside
|
||||||
|
/** Queenside castling (O-O-O). */
|
||||||
|
case CastleQueenside
|
||||||
|
/** En-passant pawn capture. */
|
||||||
|
case EnPassant
|
||||||
|
/** Pawn promotion; carries the chosen promotion piece. */
|
||||||
|
case Promotion(piece: PromotionPiece)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A half-move (ply) in a chess game.
|
||||||
|
*
|
||||||
|
* @param from origin square
|
||||||
|
* @param to destination square
|
||||||
|
* @param moveType special semantics; defaults to Normal
|
||||||
|
*/
|
||||||
|
final case class Move(
|
||||||
|
from: Square,
|
||||||
|
to: Square,
|
||||||
|
moveType: MoveType = MoveType.Normal
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.nowchess.api.player
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An opaque player identifier.
|
||||||
|
*
|
||||||
|
* Wraps a plain String so that IDs are not accidentally interchanged with
|
||||||
|
* other String values at compile time.
|
||||||
|
*/
|
||||||
|
opaque type PlayerId = String
|
||||||
|
|
||||||
|
object PlayerId:
|
||||||
|
def apply(value: String): PlayerId = value
|
||||||
|
extension (id: PlayerId) def value: String = id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimal cross-service identity stub for a player.
|
||||||
|
*
|
||||||
|
* Full profile data (email, rating history, etc.) lives in the user-management
|
||||||
|
* service. Only what every service needs is held here.
|
||||||
|
*
|
||||||
|
* @param id unique identifier
|
||||||
|
* @param displayName human-readable name shown in the UI
|
||||||
|
*/
|
||||||
|
final case class PlayerInfo(
|
||||||
|
id: PlayerId,
|
||||||
|
displayName: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package de.nowchess.api.response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standardised envelope for every API response.
|
||||||
|
*
|
||||||
|
* Success and failure are modelled as subtypes so that callers
|
||||||
|
* can pattern-match exhaustively.
|
||||||
|
*
|
||||||
|
* @tparam A the payload type for a successful response
|
||||||
|
*/
|
||||||
|
sealed trait ApiResponse[+A]
|
||||||
|
|
||||||
|
object ApiResponse:
|
||||||
|
/** A successful response carrying a payload. */
|
||||||
|
final case class Success[A](data: A) extends ApiResponse[A]
|
||||||
|
|
||||||
|
/** A failed response carrying one or more errors. */
|
||||||
|
final case class Failure(errors: List[ApiError]) extends ApiResponse[Nothing]
|
||||||
|
|
||||||
|
/** Convenience constructor for a single-error failure. */
|
||||||
|
def error(err: ApiError): Failure = Failure(List(err))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structured error descriptor.
|
||||||
|
*
|
||||||
|
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||||
|
* @param message human-readable explanation
|
||||||
|
* @param field optional field name when the error relates to a specific input
|
||||||
|
*/
|
||||||
|
final case class ApiError(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
field: Option[String] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata for list responses.
|
||||||
|
*
|
||||||
|
* @param page current 0-based page index
|
||||||
|
* @param pageSize number of items per page
|
||||||
|
* @param totalItems total number of items across all pages
|
||||||
|
*/
|
||||||
|
final case class Pagination(
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
totalItems: Long
|
||||||
|
):
|
||||||
|
def totalPages: Int =
|
||||||
|
if pageSize <= 0 then 0
|
||||||
|
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A paginated list response envelope.
|
||||||
|
*
|
||||||
|
* @param items the items on the current page
|
||||||
|
* @param pagination pagination metadata
|
||||||
|
* @tparam A the item type
|
||||||
|
*/
|
||||||
|
final case class PagedResponse[A](
|
||||||
|
items: List[A],
|
||||||
|
pagination: Pagination
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("scala")
|
id("scala")
|
||||||
|
jacoco
|
||||||
|
application
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -16,6 +18,22 @@ scala {
|
|||||||
versions["SCALA3"]!!
|
versions["SCALA3"]!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("de.nowchess.chess.chessMain")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<JavaExec>("run") {
|
||||||
|
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
||||||
|
standardInput = System.`in`
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
finalizedBy(tasks.jacocoTestReport)
|
||||||
|
}
|
||||||
|
tasks.jacocoTestReport {
|
||||||
|
dependsOn(tasks.test)
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation("org.scala-lang:scala3-compiler_3") {
|
implementation("org.scala-lang:scala3-compiler_3") {
|
||||||
@@ -34,6 +52,8 @@ dependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
implementation(project(":modules:api"))
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package de.nowchess
|
|
||||||
|
|
||||||
object Test {
|
|
||||||
|
|
||||||
def main(args: Array[String]): Unit = {
|
|
||||||
println("Hello World")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
import scala.io.StdIn
|
||||||
|
|
||||||
|
@main def chessMain(): Unit =
|
||||||
|
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||||
|
gameLoop(Board.initial, Color.White)
|
||||||
|
|
||||||
|
private def gameLoop(board: Board, turn: Color): Unit =
|
||||||
|
println()
|
||||||
|
print(Renderer.render(board))
|
||||||
|
println(s"${turn.label}'s turn. Enter move: ")
|
||||||
|
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||||
|
input match
|
||||||
|
case "quit" | "q" =>
|
||||||
|
println("Game over. Goodbye!")
|
||||||
|
case raw =>
|
||||||
|
Parser.parseMove(raw) match
|
||||||
|
case None =>
|
||||||
|
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
|
||||||
|
gameLoop(board, turn)
|
||||||
|
case Some((from, to)) =>
|
||||||
|
board.pieceAt(from) match
|
||||||
|
case None =>
|
||||||
|
println(s"No piece on ${from.label}.")
|
||||||
|
gameLoop(board, turn)
|
||||||
|
case Some(movingPiece) =>
|
||||||
|
val (newBoard, captured) = board.withMove(from, to)
|
||||||
|
captured.foreach: cap =>
|
||||||
|
println(s"${turn.label} captures ${cap.color.label} ${cap.pieceType.label} on ${to.label}")
|
||||||
|
gameLoop(newBoard, turn.opposite)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
enum Color:
|
||||||
|
case White, Black
|
||||||
|
|
||||||
|
def opposite: Color = this match
|
||||||
|
case White => Black
|
||||||
|
case Black => White
|
||||||
|
|
||||||
|
def label: String = this match
|
||||||
|
case White => "White"
|
||||||
|
case Black => "Black"
|
||||||
|
|
||||||
|
enum PieceType:
|
||||||
|
case King, Queen, Rook, Bishop, Knight, Pawn
|
||||||
|
|
||||||
|
def label: String = this match
|
||||||
|
case King => "King"
|
||||||
|
case Queen => "Queen"
|
||||||
|
case Rook => "Rook"
|
||||||
|
case Bishop => "Bishop"
|
||||||
|
case Knight => "Knight"
|
||||||
|
case Pawn => "Pawn"
|
||||||
|
|
||||||
|
final case class Piece(color: Color, pieceType: PieceType):
|
||||||
|
def unicode: String = (color, pieceType) match
|
||||||
|
case (Color.White, PieceType.King) => "\u2654"
|
||||||
|
case (Color.White, PieceType.Queen) => "\u2655"
|
||||||
|
case (Color.White, PieceType.Rook) => "\u2656"
|
||||||
|
case (Color.White, PieceType.Bishop) => "\u2657"
|
||||||
|
case (Color.White, PieceType.Knight) => "\u2658"
|
||||||
|
case (Color.White, PieceType.Pawn) => "\u2659"
|
||||||
|
case (Color.Black, PieceType.King) => "\u265A"
|
||||||
|
case (Color.Black, PieceType.Queen) => "\u265B"
|
||||||
|
case (Color.Black, PieceType.Rook) => "\u265C"
|
||||||
|
case (Color.Black, PieceType.Bishop) => "\u265D"
|
||||||
|
case (Color.Black, PieceType.Knight) => "\u265E"
|
||||||
|
case (Color.Black, PieceType.Pawn) => "\u265F"
|
||||||
|
|
||||||
|
/** Zero-based file (0=a..7=h) and rank (0=rank1..7=rank8). */
|
||||||
|
final case class Square(file: Int, rank: Int):
|
||||||
|
require(file >= 0 && file <= 7 && rank >= 0 && rank <= 7, s"Square out of bounds: $file,$rank")
|
||||||
|
|
||||||
|
def label: String = s"${('a' + file).toChar}${rank + 1}"
|
||||||
|
|
||||||
|
opaque type Board = Map[Square, Piece]
|
||||||
|
|
||||||
|
object Board:
|
||||||
|
def apply(pieces: Map[Square, Piece]): Board = pieces
|
||||||
|
|
||||||
|
extension (b: Board)
|
||||||
|
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
||||||
|
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
|
||||||
|
val captured = b.get(to)
|
||||||
|
val updated = b.removed(from).updated(to, b(from))
|
||||||
|
(updated, captured)
|
||||||
|
def pieces: Map[Square, Piece] = b
|
||||||
|
|
||||||
|
val initial: Board =
|
||||||
|
val backRank: Vector[PieceType] =
|
||||||
|
Vector(
|
||||||
|
PieceType.Rook,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook
|
||||||
|
)
|
||||||
|
|
||||||
|
val entries = for
|
||||||
|
file <- 0 until 8
|
||||||
|
(color, rank, row) <- Seq(
|
||||||
|
(Color.White, 0, backRank(file)),
|
||||||
|
(Color.White, 1, PieceType.Pawn),
|
||||||
|
(Color.Black, 7, backRank(file)),
|
||||||
|
(Color.Black, 6, PieceType.Pawn)
|
||||||
|
)
|
||||||
|
yield Square(file, rank) -> Piece(color, row)
|
||||||
|
|
||||||
|
Board(entries.toMap)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
object Parser:
|
||||||
|
|
||||||
|
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
||||||
|
* Returns None for any input that does not match the expected format.
|
||||||
|
*/
|
||||||
|
def parseMove(input: String): Option[(Square, Square)] =
|
||||||
|
val trimmed = input.trim.toLowerCase
|
||||||
|
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
||||||
|
for
|
||||||
|
from <- parseSquare(s.substring(0, 2))
|
||||||
|
to <- parseSquare(s.substring(2, 4))
|
||||||
|
yield (from, to)
|
||||||
|
|
||||||
|
private def parseSquare(s: String): Option[Square] =
|
||||||
|
Option.when(s.length == 2)(s).flatMap: sq =>
|
||||||
|
val file = sq(0) - 'a'
|
||||||
|
val rank = sq(1) - '1'
|
||||||
|
Option.when(file >= 0 && file <= 7 && rank >= 0 && rank <= 7)(Square(file, rank))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
object Renderer:
|
||||||
|
|
||||||
|
private val AnsiReset = "\u001b[0m"
|
||||||
|
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
|
||||||
|
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
|
||||||
|
private val AnsiWhitePiece = "\u001b[97m" // bright white text
|
||||||
|
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||||
|
|
||||||
|
def render(board: Board): String =
|
||||||
|
val sb = new StringBuilder
|
||||||
|
sb.append(" a b c d e f g h\n")
|
||||||
|
for rank <- (0 until 8).reverse do
|
||||||
|
sb.append(s"${rank + 1} ")
|
||||||
|
for file <- 0 until 8 do
|
||||||
|
val sq = Square(file, rank)
|
||||||
|
val isLightSq = (file + rank) % 2 != 0
|
||||||
|
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||||
|
val cellContent = board.pieceAt(sq) match
|
||||||
|
case Some(piece) =>
|
||||||
|
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||||
|
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||||
|
case None =>
|
||||||
|
s"$bgColor $AnsiReset"
|
||||||
|
sb.append(cellContent)
|
||||||
|
sb.append(s" ${rank + 1}\n")
|
||||||
|
sb.append(" a b c d e f g h\n")
|
||||||
|
sb.toString
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
|
||||||
|
class ModelTest:
|
||||||
|
|
||||||
|
@Test def colorOpposite(): Unit =
|
||||||
|
assertEquals(Color.Black, Color.White.opposite)
|
||||||
|
assertEquals(Color.White, Color.Black.opposite)
|
||||||
|
|
||||||
|
@Test def squareLabel(): Unit =
|
||||||
|
assertEquals("a1", Square(0, 0).label)
|
||||||
|
assertEquals("e4", Square(4, 3).label)
|
||||||
|
assertEquals("h8", Square(7, 7).label)
|
||||||
|
|
||||||
|
@Test def pieceUnicode(): Unit =
|
||||||
|
assertEquals("\u2654", Piece(Color.White, PieceType.King).unicode)
|
||||||
|
assertEquals("\u265A", Piece(Color.Black, PieceType.King).unicode)
|
||||||
|
assertEquals("\u2659", Piece(Color.White, PieceType.Pawn).unicode)
|
||||||
|
assertEquals("\u265F", Piece(Color.Black, PieceType.Pawn).unicode)
|
||||||
|
|
||||||
|
@Test def initialBoardHas32Pieces(): Unit =
|
||||||
|
assertEquals(32, Board.initial.pieces.size)
|
||||||
|
|
||||||
|
@Test def initialWhiteKingOnE1(): Unit =
|
||||||
|
val e1 = Square(4, 0)
|
||||||
|
assertEquals(Some(Piece(Color.White, PieceType.King)), Board.initial.pieceAt(e1))
|
||||||
|
|
||||||
|
@Test def initialBlackQueenOnD8(): Unit =
|
||||||
|
val d8 = Square(3, 7)
|
||||||
|
assertEquals(Some(Piece(Color.Black, PieceType.Queen)), Board.initial.pieceAt(d8))
|
||||||
|
|
||||||
|
@Test def initialWhitePawnsOnRank2(): Unit =
|
||||||
|
for file <- 0 until 8 do
|
||||||
|
val sq = Square(file, 1)
|
||||||
|
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), Board.initial.pieceAt(sq))
|
||||||
|
|
||||||
|
@Test def withMoveMovesAndLeavesOriginEmpty(): Unit =
|
||||||
|
val e2 = Square(4, 1)
|
||||||
|
val e4 = Square(4, 3)
|
||||||
|
val (newBoard, captured) = Board.initial.withMove(e2, e4)
|
||||||
|
assertEquals(None, newBoard.pieceAt(e2))
|
||||||
|
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), newBoard.pieceAt(e4))
|
||||||
|
assertEquals(None, captured)
|
||||||
|
|
||||||
|
@Test def withMoveCaptureReturnsCapture(): Unit =
|
||||||
|
// Place a black pawn on e4 and a white pawn already there via two moves
|
||||||
|
val e2 = Square(4, 1)
|
||||||
|
val e4 = Square(4, 3)
|
||||||
|
val (board2, _) = Board.initial.withMove(e2, e4)
|
||||||
|
// Place black pawn on d4 manually for capture test
|
||||||
|
val d7 = Square(3, 6)
|
||||||
|
val d4 = Square(3, 3)
|
||||||
|
val (board3, _) = board2.withMove(d7, d4)
|
||||||
|
// Now white pawn on e4 captures black pawn on d4 (diagonal — no legality check)
|
||||||
|
val (board4, cap) = board3.withMove(e4, d4)
|
||||||
|
assertEquals(Some(Piece(Color.Black, PieceType.Pawn)), cap)
|
||||||
|
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), board4.pieceAt(d4))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
|
||||||
|
class ParserTest:
|
||||||
|
|
||||||
|
@Test def parsesValidMove(): Unit =
|
||||||
|
assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove("e2e4"))
|
||||||
|
|
||||||
|
@Test def parsesKnightMove(): Unit =
|
||||||
|
assertEquals(Some((Square(6, 0), Square(5, 2))), Parser.parseMove("g1f3"))
|
||||||
|
|
||||||
|
@Test def ignoresExtraWhitespace(): Unit =
|
||||||
|
assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove(" e2e4 "))
|
||||||
|
|
||||||
|
@Test def rejectsShortInput(): Unit =
|
||||||
|
assertEquals(None, Parser.parseMove("e2e"))
|
||||||
|
|
||||||
|
@Test def rejectsEmptyInput(): Unit =
|
||||||
|
assertEquals(None, Parser.parseMove(""))
|
||||||
|
|
||||||
|
@Test def rejectsOutOfBoundsFile(): Unit =
|
||||||
|
assertEquals(None, Parser.parseMove("z2a4"))
|
||||||
|
|
||||||
|
@Test def rejectsOutOfBoundsRank(): Unit =
|
||||||
|
assertEquals(None, Parser.parseMove("e9e4"))
|
||||||
|
|
||||||
|
@Test def parsesUppercaseAsInvalid(): Unit =
|
||||||
|
// uppercase files are out of range after toLowerCase — stays lowercase internally
|
||||||
|
assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove("E2E4"))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.nowchess.chess
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
|
||||||
|
class RendererTest:
|
||||||
|
|
||||||
|
@Test def renderContainsFileLabels(): Unit =
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.contains("a"), "render output should contain file label 'a'")
|
||||||
|
assertTrue(output.contains("h"), "render output should contain file label 'h'")
|
||||||
|
|
||||||
|
@Test def renderContainsRankLabels(): Unit =
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.contains("1"), "render output should contain rank label '1'")
|
||||||
|
assertTrue(output.contains("8"), "render output should contain rank label '8'")
|
||||||
|
|
||||||
|
@Test def renderContainsWhiteKingUnicode(): Unit =
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.contains("\u2654"), "render output should contain white king \u2654")
|
||||||
|
|
||||||
|
@Test def renderContainsBlackQueenUnicode(): Unit =
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.contains("\u265B"), "render output should contain black queen \u265B")
|
||||||
|
|
||||||
|
@Test def renderContainsAnsiReset(): Unit =
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.contains("\u001b[0m"), "render output should contain ANSI reset code")
|
||||||
|
|
||||||
|
@Test def renderReturnsStringNotUnit(): Unit =
|
||||||
|
// Compilation-time guarantee, but verify non-empty at runtime
|
||||||
|
val output = Renderer.render(Board.initial)
|
||||||
|
assertTrue(output.nonEmpty)
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
rootProject.name = "NowChessSystems"
|
rootProject.name = "NowChessSystems"
|
||||||
include("modules:core")
|
include("modules:core", "modules:api")
|
||||||
Reference in New Issue
Block a user