chore: Set up shared-models library and initial project structure for NowChessSystems

This commit is contained in:
2026-03-21 17:07:28 +01:00
parent a8d457a612
commit 9c2456e928
28 changed files with 815 additions and 12 deletions
+5
View File
@@ -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)
+1
View File
@@ -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>
+1 -1
View File
@@ -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>
+57
View File
@@ -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
View File
@@ -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
+86
View File
@@ -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.
+52
View File
@@ -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, ah.
* Ordinal values 07 correspond to ah.
*/
enum File:
case A, B, C, D, E, F, G, H
/**
* A rank (row) on the chess board, 18.
* Ordinal values 07 correspond to ranks 18.
*/
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, ah
* @param rank the row, 18
*/
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
)
+20
View File
@@ -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
View File
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems" rootProject.name = "NowChessSystems"
include("modules:core") include("modules:core", "modules:api")