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