feat: NCS-16 Core Separation via Patterns (#10)
Build & Test (NowChessSystems) TeamCity build finished

Co-authored-by: Janis <janis-e@gmx.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #10
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Shahd Lala <shosho996@blackhole.local>
Co-committed-by: Shahd Lala <shosho996@blackhole.local>
This commit was merged in pull request #10.
This commit is contained in:
2026-03-31 10:31:02 +02:00
committed by Janis
parent 707c4826a4
commit 1361dfc895
28 changed files with 2517 additions and 179 deletions
@@ -0,0 +1,15 @@
package de.nowchess.ui
import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI
/** Application entry point - starts the Terminal UI for the chess game. */
object Main:
def main(args: Array[String]): Unit =
// Create the core game engine (single source of truth)
val engine = new GameEngine()
// Create and start the terminal UI
val tui = new TerminalUI(engine)
tui.start()
@@ -0,0 +1,76 @@
package de.nowchess.ui.terminal
import scala.io.StdIn
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.view.Renderer
/** Terminal UI that implements Observer pattern.
* Subscribes to GameEngine and receives state change events.
* Handles all I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private var running = true
/** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit =
event match
case e: MoveExecutedEvent =>
println()
print(Renderer.render(e.board))
e.capturedPiece.foreach: cap =>
println(s"Captured: $cap on ${e.toSquare}")
printPrompt(e.turn)
case e: CheckDetectedEvent =>
println(s"${e.turn.label} is in check!")
case e: CheckmateEvent =>
println(s"Checkmate! ${e.winner.label} wins.")
println()
print(Renderer.render(e.board))
case e: StalemateEvent =>
println("Stalemate! The game is a draw.")
println()
print(Renderer.render(e.board))
case e: InvalidMoveEvent =>
println(s"⚠️ ${e.reason}")
case e: BoardResetEvent =>
println("Board has been reset to initial position.")
println()
print(Renderer.render(e.board))
printPrompt(e.turn)
/** Start the terminal UI game loop. */
def start(): Unit =
// Register as observer
engine.subscribe(this)
// Show initial board
println()
print(Renderer.render(engine.board))
printPrompt(engine.turn)
// Game loop
while running do
val input = Option(StdIn.readLine()).getOrElse("quit").trim
input.toLowerCase match
case "quit" | "q" =>
running = false
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)
case _ =>
engine.processUserInput(input)
// Unsubscribe when done
engine.unsubscribe(this)
private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
val undoHint = if engine.canUndo then " [undo]" else ""
val redoHint = if engine.canRedo then " [redo]" else ""
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
@@ -0,0 +1,22 @@
package de.nowchess.ui
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
class MainTest extends AnyFunSuite with Matchers {
test("main should execute and quit immediately when fed 'quit'") {
val in = new ByteArrayInputStream("quit\n".getBytes)
val out = new ByteArrayOutputStream()
Console.withIn(in) {
Console.withOut(out) {
Main.main(Array.empty)
}
}
val output = out.toString
output should include ("Game over. Goodbye!")
}
}
@@ -0,0 +1,189 @@
package de.nowchess.ui.terminal
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
class TerminalUITest extends AnyFunSuite with Matchers {
test("TerminalUI should start, print initial state, and correctly respond to 'q'") {
val in = new ByteArrayInputStream("q\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
}
test("TerminalUI should ignore empty inputs and re-print prompt") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// Prompt appears three times: Initial, after empty, on exit.
output.split("White's turn.").length should be > 2
}
test("TerminalUI should explicitly handle empty input by re-prompting") {
val in = new ByteArrayInputStream("\n\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// With two empty inputs, prompt should appear at least 4 times:
// 1. Initial board display
// 2. After first empty input
// 3. After second empty input
// 4. Before quit
val promptCount = output.split("White's turn.").length
promptCount should be >= 4
output should include("Game over. Goodbye!")
}
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine() {
// Stub engine to force undo/redo to true
override def canUndo: Boolean = true
override def canRedo: Boolean = true
}
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("[undo]")
output should include("[redo]")
}
test("TerminalUI onGameEvent should properly format InvalidMoveEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format"))
}
out.toString should include("⚠️")
out.toString should include("Invalid move format")
}
test("TerminalUI onGameEvent should properly format CheckDetectedEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Black is in check!")
}
test("TerminalUI onGameEvent should properly format CheckmateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White))
}
val ostr = out.toString
ostr should include("Checkmate! White wins.")
}
test("TerminalUI onGameEvent should properly format StalemateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Stalemate! The game is a draw.")
}
test("TerminalUI onGameEvent should properly format BoardResetEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White))
}
out.toString should include("Board has been reset to initial position.")
}
test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)")))
}
out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize
}
test("TerminalUI processes valid move input via processUserInput") {
val in = new ByteArrayInputStream("e2e4\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
// The move should have been processed and the board displayed
engine.turn shouldBe Color.Black
}
}