diff --git a/.claude/agent-memory/architect/MEMORY.md b/.claude/agent-memory/architect/MEMORY.md
new file mode 100644
index 0000000..5d02269
--- /dev/null
+++ b/.claude/agent-memory/architect/MEMORY.md
@@ -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
diff --git a/.claude/agent-memory/architect/project_api_module.md b/.claude/agent-memory/architect/project_api_module.md
new file mode 100644
index 0000000..d174b32
--- /dev/null
+++ b/.claude/agent-memory/architect/project_api_module.md
@@ -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`
diff --git a/.claude/agent-memory/scala-implementer/MEMORY.md b/.claude/agent-memory/scala-implementer/MEMORY.md
new file mode 100644
index 0000000..d7f5b56
--- /dev/null
+++ b/.claude/agent-memory/scala-implementer/MEMORY.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
diff --git a/.claude/agent-memory/scala-implementer/project_chess_tui.md b/.claude/agent-memory/scala-implementer/project_chess_tui.md
new file mode 100644
index 0000000..88248c8
--- /dev/null
+++ b/.claude/agent-memory/scala-implementer/project_chess_tui.md
@@ -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)
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 3b069e4..dfd509b 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,6 +9,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 015fcd8..226a52a 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ce7b422
--- /dev/null
+++ b/CLAUDE.md
@@ -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::build
+
+# Run tests for a single module
+./gradlew :modules::test
+
+# Run a specific test class
+./gradlew :modules::test --tests "de.nowchess.."
+```
+
+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`.
+- `settings.gradle.kts` must `include(":modules:")` 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`.
diff --git a/build.gradle.kts b/build.gradle.kts
index 74a7e75..ad04499 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,4 +6,5 @@ val versions = mapOf(
"SCALA3" to "3.5.1",
"SCALA_LIBRARY" to "2.13.18"
)
-extra["VERSIONS"] = versions
\ No newline at end of file
+extra["VERSIONS"] = versions
+
diff --git a/docs/adr/ADR-002-api-shared-models.md b/docs/adr/ADR-002-api-shared-models.md
new file mode 100644
index 0000000..fa456ed
--- /dev/null
+++ b/docs/adr/ADR-002-api-shared-models.md
@@ -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.
diff --git a/modules/api/build.gradle.kts b/modules/api/build.gradle.kts
new file mode 100644
index 0000000..05ee4a0
--- /dev/null
+++ b/modules/api/build.gradle.kts
@@ -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
+
+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()
+}
\ No newline at end of file
diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Color.scala b/modules/api/src/main/scala/de/nowchess/api/board/Color.scala
new file mode 100644
index 0000000..420923a
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/board/Color.scala
@@ -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
diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala b/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala
new file mode 100644
index 0000000..07f467e
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala
@@ -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)
diff --git a/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala b/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala
new file mode 100644
index 0000000..4bebd59
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala
@@ -0,0 +1,4 @@
+package de.nowchess.api.board
+
+enum PieceType:
+ case Pawn, Knight, Bishop, Rook, Queen, King
diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Square.scala b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala
new file mode 100644
index 0000000..284b67b
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala
@@ -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)
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameState.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameState.scala
new file mode 100644
index 0000000..7f57b19
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/game/GameState.scala
@@ -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
+ )
diff --git a/modules/api/src/main/scala/de/nowchess/api/move/Move.scala b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala
new file mode 100644
index 0000000..f1f3820
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala
@@ -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
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
new file mode 100644
index 0000000..c655546
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
@@ -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
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala b/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala
new file mode 100644
index 0000000..a87deb6
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala
@@ -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
+)
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index 6389d66..035dcb7 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -1,5 +1,7 @@
plugins {
id("scala")
+ jacoco
+ application
}
group = "de.nowchess"
@@ -16,6 +18,22 @@ scala {
versions["SCALA3"]!!
}
+application {
+ mainClass.set("de.nowchess.chess.chessMain")
+}
+
+tasks.named("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 {
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("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
diff --git a/modules/core/src/main/scala/de/nowchess/Test.scala b/modules/core/src/main/scala/de/nowchess/Test.scala
deleted file mode 100644
index 26804d9..0000000
--- a/modules/core/src/main/scala/de/nowchess/Test.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.nowchess
-
-object Test {
-
- def main(args: Array[String]): Unit = {
- println("Hello World")
- }
-
-}
diff --git a/modules/core/src/main/scala/de/nowchess/chess/Game.scala b/modules/core/src/main/scala/de/nowchess/chess/Game.scala
new file mode 100644
index 0000000..17bdeb4
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/Game.scala
@@ -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)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/Model.scala b/modules/core/src/main/scala/de/nowchess/chess/Model.scala
new file mode 100644
index 0000000..db53aa2
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/Model.scala
@@ -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)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/Parser.scala b/modules/core/src/main/scala/de/nowchess/chess/Parser.scala
new file mode 100644
index 0000000..c4bc967
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/Parser.scala
@@ -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))
diff --git a/modules/core/src/main/scala/de/nowchess/chess/Renderer.scala b/modules/core/src/main/scala/de/nowchess/chess/Renderer.scala
new file mode 100644
index 0000000..8e8e7b5
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/Renderer.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala
new file mode 100644
index 0000000..d25ba98
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala
@@ -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))
diff --git a/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala
new file mode 100644
index 0000000..34a7140
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala
@@ -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"))
diff --git a/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala b/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala
new file mode 100644
index 0000000..1fb8a16
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala
@@ -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)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 784ffd7..4259047 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems"
-include("modules:core")
\ No newline at end of file
+include("modules:core", "modules:api")
\ No newline at end of file