feat: Refactor promotion handling to use UCI notation and improve move validation
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"created": "2026-04-13T19:58:38.629943",
|
||||
"total_positions": 2522562,
|
||||
"total_positions": 3022562,
|
||||
"stockfish_depth": 12,
|
||||
"sources": [
|
||||
{
|
||||
@@ -46,6 +46,21 @@
|
||||
}
|
||||
],
|
||||
"actual_count": 599993
|
||||
},
|
||||
{
|
||||
"type": "merged_sources",
|
||||
"count": 500000,
|
||||
"sources": [
|
||||
{
|
||||
"type": "lichess",
|
||||
"count": 500000,
|
||||
"params": {
|
||||
"min_depth": 20,
|
||||
"max_positions": 500000
|
||||
}
|
||||
}
|
||||
],
|
||||
"actual_count": 500000
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -1,21 +1,28 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
object Parser:
|
||||
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||
* format.
|
||||
/** Parses UCI move notation: "e2e4" (4 chars) or "e7e8q" (5 chars with promotion piece suffix).
|
||||
* The promotion suffix is q=Queen, r=Rook, b=Bishop, n=Knight. Returns None for invalid input.
|
||||
*/
|
||||
def parseMove(input: String): Option[(Square, Square)] =
|
||||
def parseMove(input: String): Option[(Square, Square, Option[PromotionPiece])] =
|
||||
val trimmed = input.trim.toLowerCase
|
||||
Option
|
||||
.when(trimmed.length == 4)(trimmed)
|
||||
.flatMap: s =>
|
||||
trimmed.length match
|
||||
case 4 =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
from <- parseSquare(trimmed.substring(0, 2))
|
||||
to <- parseSquare(trimmed.substring(2, 4))
|
||||
yield (from, to, None)
|
||||
case 5 =>
|
||||
for
|
||||
from <- parseSquare(trimmed.substring(0, 2))
|
||||
to <- parseSquare(trimmed.substring(2, 4))
|
||||
promo <- parsePromotion(trimmed(4))
|
||||
yield (from, to, Some(promo))
|
||||
case _ => None
|
||||
|
||||
private def parseSquare(s: String): Option[Square] =
|
||||
Option
|
||||
@@ -26,3 +33,10 @@ object Parser:
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||
)
|
||||
|
||||
private def parsePromotion(c: Char): Option[PromotionPiece] = c match
|
||||
case 'q' => Some(PromotionPiece.Queen)
|
||||
case 'r' => Some(PromotionPiece.Rook)
|
||||
case 'b' => Some(PromotionPiece.Bishop)
|
||||
case 'n' => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
|
||||
@@ -19,13 +19,8 @@ class GameEngine(
|
||||
val ruleSet: RuleSet = DefaultRules,
|
||||
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
|
||||
) extends Observable:
|
||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
|
||||
initialContext.copy(initialBoard = initialContext.board)
|
||||
else
|
||||
initialContext
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentContext: GameContext = contextWithInitialBoard
|
||||
private var currentContext: GameContext = initialContext
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||
@@ -72,15 +67,11 @@ class GameEngine(
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||
else if ruleSet.isThreefoldRepetition(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
||||
else
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(
|
||||
currentContext,
|
||||
"Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.",
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -167,7 +158,7 @@ class GameEngine(
|
||||
invoker.clear()
|
||||
|
||||
if ctx.moves.isEmpty then
|
||||
currentContext = ctx.copy(initialBoard = ctx.board)
|
||||
currentContext = ctx
|
||||
Right(())
|
||||
else replayMoves(ctx.moves, savedContext)
|
||||
|
||||
@@ -195,12 +186,7 @@ class GameEngine(
|
||||
|
||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||
val contextWithInitialBoard = if newContext.moves.isEmpty then
|
||||
newContext.copy(initialBoard = newContext.board)
|
||||
else
|
||||
newContext
|
||||
currentContext = contextWithInitialBoard
|
||||
pendingPromotion = None
|
||||
currentContext = newContext
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
@@ -257,7 +243,6 @@ class GameEngine(
|
||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
||||
|
||||
// Request bot move if it's the opponent bot's turn
|
||||
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||
|
||||
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||
@@ -39,13 +39,6 @@ case class InvalidMoveEvent(
|
||||
reason: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
context: GameContext,
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseMove parses valid 'e2e4'"):
|
||||
Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove is case-insensitive"):
|
||||
Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove trims leading and trailing whitespace"):
|
||||
Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove handles corner squares a1h8"):
|
||||
Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8)))
|
||||
Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8), None))
|
||||
|
||||
test("parseMove handles corner squares h8a1"):
|
||||
Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1)))
|
||||
Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1), None))
|
||||
|
||||
test("parseMove returns None for empty string"):
|
||||
Parser.parseMove("") shouldBe None
|
||||
@@ -27,8 +28,8 @@ class ParserTest extends AnyFunSuite with Matchers:
|
||||
test("parseMove returns None for input shorter than 4 chars"):
|
||||
Parser.parseMove("e2e") shouldBe None
|
||||
|
||||
test("parseMove returns None for input longer than 4 chars"):
|
||||
Parser.parseMove("e2e44") shouldBe None
|
||||
test("parseMove returns None for input longer than 5 chars"):
|
||||
Parser.parseMove("e2e4qq") shouldBe None
|
||||
|
||||
test("parseMove returns None when from-file is out of range"):
|
||||
Parser.parseMove("z2e4") shouldBe None
|
||||
@@ -41,3 +42,31 @@ class ParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseMove returns None when to-rank is out of range"):
|
||||
Parser.parseMove("e2e9") shouldBe None
|
||||
|
||||
test("parseMove parses queen promotion 'e7e8q'"):
|
||||
Parser.parseMove("e7e8q") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen)),
|
||||
)
|
||||
|
||||
test("parseMove parses rook promotion 'a7a8r'"):
|
||||
Parser.parseMove("a7a8r") shouldBe Some(
|
||||
(Square(File.A, Rank.R7), Square(File.A, Rank.R8), Some(PromotionPiece.Rook)),
|
||||
)
|
||||
|
||||
test("parseMove parses bishop promotion 'e7e8b'"):
|
||||
Parser.parseMove("e7e8b") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop)),
|
||||
)
|
||||
|
||||
test("parseMove parses knight promotion 'e7e8n'"):
|
||||
Parser.parseMove("e7e8n") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight)),
|
||||
)
|
||||
|
||||
test("parseMove returns None for 5-char input with invalid promotion char"):
|
||||
Parser.parseMove("e7e8x") shouldBe None
|
||||
|
||||
test("parseMove parses black promotion 'e2e1q'"):
|
||||
Parser.parseMove("e2e1q") shouldBe Some(
|
||||
(Square(File.E, Rank.R2), Square(File.E, Rank.R1), Some(PromotionPiece.Queen)),
|
||||
)
|
||||
|
||||
+4
-11
@@ -1,8 +1,9 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -29,10 +30,6 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
case other =>
|
||||
fail(s"Expected CheckmateEvent, but got $other")
|
||||
|
||||
// Board should be reset after checkmate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
@@ -87,13 +84,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
observer.events.clear()
|
||||
engine.processUserInput(moves.last)
|
||||
|
||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
||||
val stalemateEvents = observer.events.collect { case DrawEvent(_, DrawReason.Stalemate) => true }
|
||||
stalemateEvents.size shouldBe 1
|
||||
|
||||
// Board should be reset after stalemate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
private class EndingMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
|
||||
+3
-3
@@ -115,7 +115,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.loadGame(importer, "ignored") shouldBe Right(())
|
||||
engine.context.moves.lastOption shouldBe Some(promotionMove)
|
||||
|
||||
test("loadGame replay restores previous context when promotion cannot be completed"):
|
||||
test("loadGame replay restores previous context when move has no legal candidates"):
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val noLegalMoves = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
@@ -140,7 +140,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val result = engine.loadGame(importer, "ignored")
|
||||
|
||||
result.isLeft shouldBe true
|
||||
result.left.toOption.get should include("Promotion required")
|
||||
result.left.toOption.get should include("Illegal move")
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
@@ -156,7 +156,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.")
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
|
||||
@@ -2,7 +2,6 @@ package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
@@ -90,7 +89,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||
|
||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
|
||||
// White rook on h2 keeps material sufficient (K+R+B vs K) after bishop promotion
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6R/7K").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -99,8 +99,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -111,8 +110,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── King normal move notation (line 246) ───────────────────────────
|
||||
|
||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||
// White king on e1, no castling rights, black king far away
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
|
||||
// Black pawn on h7 prevents K-vs-K insufficient-material draw; white king on e1, no castling rights
|
||||
val board = FenParser.parseBoard("k7/7p/8/8/8/8/8/4K3").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
|
||||
+41
-71
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
@@ -22,7 +22,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
private def engineWith(board: Board, turn: Color = Color.White): GameEngine =
|
||||
new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||
|
||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||
test("processUserInput without promotion suffix fires InvalidMoveEvent when pawn reaches back rank") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
@@ -30,36 +30,18 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists {
|
||||
case _: PromotionRequiredEvent => true
|
||||
case _ => false
|
||||
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
|
||||
case _ => false
|
||||
} should be(true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||
}
|
||||
|
||||
test("isPendingPromotion is true after PromotionRequired input") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
engine.isPendingPromotion should be(true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is false before any promotion input") {
|
||||
val engine = new GameEngine()
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||
test("processUserInput with queen promotion fires MoveExecutedEvent and places queen") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
@@ -69,37 +51,42 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with rook underpromotion") {
|
||||
test("processUserInput with rook underpromotion places rook") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
engine.processUserInput("e7e8r")
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||
}
|
||||
|
||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
test("processUserInput with bishop underpromotion places bishop") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
events.exists {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Bishop)))
|
||||
}
|
||||
|
||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||
test("processUserInput with knight underpromotion places knight") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8n")
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Knight)))
|
||||
}
|
||||
|
||||
test("processUserInput e7e8q fires CheckDetectedEvent when promotion gives check") {
|
||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
@@ -107,15 +94,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||
test("processUserInput e7e8q does not fire CheckDetectedEvent when promotion doesn't give check") {
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
@@ -127,45 +112,39 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||
test("processUserInput h7h8q fires CheckmateEvent when promotion delivers checkmate") {
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("h7h8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists {
|
||||
case _: CheckmateEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||
test("processUserInput b7b8n fires DrawEvent with Stalemate when promotion creates stalemate") {
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
engine.processUserInput("b7b8n")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists {
|
||||
case _: StalemateEvent => true
|
||||
case _ => false
|
||||
case DrawEvent(_, DrawReason.Stalemate) => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with black pawn promotion results in Moved") {
|
||||
test("processUserInput e2e1q with black pawn promotes to queen") {
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||
val engine = engineWith(board, Color.Black)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e2e1q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
@@ -177,11 +156,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||
// Custom RuleSet: delegates all methods to StandardRules except legalMoves,
|
||||
// which strips Promotion move types and returns Normal moves instead.
|
||||
// This makes completePromotion unable to find Move(from, to, Promotion(Queen)),
|
||||
// triggering the "Error completing promotion." branch.
|
||||
test("processUserInput fires InvalidMoveEvent when promotion piece has no matching legal move") {
|
||||
// Custom RuleSet: strips Promotion move types and returns Normal moves instead,
|
||||
// so Move(e7, e8, Promotion(Queen)) is not in legal moves — triggers error branch.
|
||||
val delegatingRuleSet: RuleSet = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
DefaultRules.candidateMoves(context)(square)
|
||||
@@ -213,16 +190,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be(true)
|
||||
// legalMoves returns Normal candidates (non-empty) but no Promotion(Queen) move
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||
// but only Normal moves exist → fires InvalidMoveEvent
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
|
||||
+19
-38
@@ -1,7 +1,6 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -89,7 +88,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── Pawn promotion ─────────────────────────────────────────────
|
||||
|
||||
test("pawn reaching back rank requires promotion"):
|
||||
test("pawn reaching back rank without promotion suffix fires InvalidMoveEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
@@ -99,74 +98,61 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
observer.hasEvent[PromotionRequiredEvent] shouldBe true
|
||||
engine.isPendingPromotion shouldBe true
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
test("completePromotion to Queen executes move"):
|
||||
test("e7e8q promotes to Queen"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Rook executes move"):
|
||||
test("e7e8r promotes to Rook"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
engine.processUserInput("e7e8r")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Bishop executes move"):
|
||||
test("e7e8b promotes to Bishop"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Knight executes move"):
|
||||
test("e7e8n promotes to Knight"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
engine.processUserInput("e7e8n")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||
test("promotion with discovered check emits CheckDetectedEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white pawn e7, black king e6, white king e1
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||
test("promotion with checkmate emits CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: known promotion-mate pattern
|
||||
EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("h7h8q")
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
@@ -177,8 +163,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
observer.clear()
|
||||
|
||||
engine.undo()
|
||||
@@ -187,16 +172,12 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.pgnNotation shouldBe "e8=B"
|
||||
|
||||
test("black pawn promotion executes"):
|
||||
test("black pawn e2e1q promotes to queen"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/8/8/8/8/4k3/4p3/8 b - - 0 1")
|
||||
engine.processUserInput("e2e1")
|
||||
engine.processUserInput("e2e1q")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Promotion capturing ────────────────────────────────────────
|
||||
@@ -205,6 +186,6 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "3n4/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
engine.processUserInput("e7d8")
|
||||
engine.processUserInput("e7d8q")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
@@ -33,7 +33,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
case _: CheckmateEvent =>
|
||||
checkmateDetected.set(true)
|
||||
gameEnded.set(true)
|
||||
case _: StalemateEvent =>
|
||||
case _: DrawEvent =>
|
||||
gameEnded.set(true)
|
||||
case _ => ()
|
||||
|
||||
|
||||
@@ -455,13 +455,21 @@ object DefaultRules extends RuleSet:
|
||||
|
||||
// ── Insufficient material ──────────────────────────────────────────
|
||||
|
||||
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
|
||||
|
||||
private def insufficientMaterial(board: Board): Boolean =
|
||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
||||
pieces match
|
||||
case Nil => true
|
||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case List(p1, p2)
|
||||
val nonKings = board.pieces.toList.filter(_._2.pieceType != PieceType.King)
|
||||
nonKings match
|
||||
case Nil => true
|
||||
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case List((sq1, p1), (sq2, p2))
|
||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||
&& p1.color != p2.color =>
|
||||
&& p1.color != p2.color
|
||||
&& squareColor(sq1) == squareColor(sq2) =>
|
||||
true
|
||||
case bishops
|
||||
if bishops.forall(_._2.pieceType == PieceType.Bishop)
|
||||
&& bishops.map(_._2.color).distinct.size == 1
|
||||
&& bishops.map(e => squareColor(e._1)).distinct.size == 1 =>
|
||||
true
|
||||
case _ => false
|
||||
|
||||
@@ -11,7 +11,7 @@ import scalafx.scene.shape.Rectangle
|
||||
import scalafx.scene.text.{Font, Text}
|
||||
import scalafx.stage.Stage
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.move.MoveType
|
||||
import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
@@ -178,36 +178,37 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if !engine.isPendingPromotion then
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
selectedSquare.get() match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn.get() then
|
||||
selectedSquare.set(Some(clickedSquare))
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
selectedSquare.get() match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
||||
if piece.color == currentTurn.get() then
|
||||
selectedSquare.set(Some(clickedSquare))
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
|
||||
val legalDests = engine.ruleSet
|
||||
.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
val legalDests = engine.ruleSet
|
||||
.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare.set(None)
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
else
|
||||
// Try to move
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare.set(None)
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
// Deselect
|
||||
selectedSquare.set(None)
|
||||
updateBoard(currentBoard.get(), currentTurn.get())
|
||||
else
|
||||
val isPromo = engine.ruleSet
|
||||
.legalMoves(engine.context)(fromSquare)
|
||||
.exists(m => m.to == clickedSquare && m.moveType.isInstanceOf[MoveType.Promotion])
|
||||
if isPromo then showPromotionDialog(fromSquare, clickedSquare)
|
||||
else engine.processUserInput(s"${fromSquare}$clickedSquare")
|
||||
selectedSquare.set(None)
|
||||
|
||||
def updateBoard(board: Board, turn: Color): Unit =
|
||||
currentBoard.set(board)
|
||||
@@ -280,14 +281,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
headerText = "Choose promotion piece"
|
||||
contentText = "Promote to:"
|
||||
}
|
||||
|
||||
val result = dialog.showAndWait()
|
||||
result match
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
val uciSuffix = dialog.showAndWait() match
|
||||
case Some("Rook") => "r"
|
||||
case Some("Bishop") => "b"
|
||||
case Some("Knight") => "n"
|
||||
case _ => "q"
|
||||
engine.processUserInput(s"${from}${to}$uciSuffix")
|
||||
|
||||
private def doFenExport(): Unit =
|
||||
doExport(FenExporter, "FEN")
|
||||
|
||||
@@ -47,9 +47,6 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
boardView.showMessage("Board has been reset to initial position.")
|
||||
|
||||
case e: PromotionRequiredEvent =>
|
||||
boardView.showPromotionDialog(e.from, e.to)
|
||||
|
||||
case e: FiftyMoveRuleAvailableEvent =>
|
||||
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
|
||||
|
||||
|
||||
@@ -6,26 +6,24 @@ import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||
/** Utility object for loading chess piece sprites. */
|
||||
object PieceSprites:
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
|
||||
|
||||
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||
*/
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
||||
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
|
||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
|
||||
new ImageView(image) {
|
||||
fitWidth = size
|
||||
fitHeight = size
|
||||
preserveRatio = true
|
||||
smooth = true
|
||||
}
|
||||
}
|
||||
|
||||
private def loadImage(key: String): Image =
|
||||
private def loadImage(key: String): Option[Image] =
|
||||
val path = s"/sprites/pieces/$key.png"
|
||||
Option(getClass.getResourceAsStream(path)) match
|
||||
case Some(stream) => new Image(stream)
|
||||
case None => sys.error(s"Could not load sprite: $path")
|
||||
Option(getClass.getResourceAsStream(path)).map(new Image(_))
|
||||
|
||||
/** Get square colors for the board using theme. */
|
||||
object SquareColors:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.nowchess.ui.terminal
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.ui.utils.Renderer
|
||||
@@ -90,7 +88,6 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
||||
print(Renderer.render(engine.board))
|
||||
printPrompt(engine.turn)
|
||||
|
||||
// Game loop
|
||||
while running.get() do
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
synchronized {
|
||||
|
||||
Reference in New Issue
Block a user