From 7cd16cea88084cc7d3fbbecd2b02ddd978a8ac06 Mon Sep 17 00:00:00 2001 From: Mattia Giambirtone Date: Wed, 24 Apr 2024 10:41:01 +0200 Subject: [PATCH] Add heavy tests. Fix minor bugs. Initial work on UCI interface --- Chess/README.md | 7 +- Chess/nimfish.nimble | 3 +- Chess/nimfish/nimfishpkg/board.nim | 2 +- Chess/nimfish/nimfishpkg/moves.nim | 19 +- Chess/nimfish/nimfishpkg/tui.nim | 23 +-- Chess/nimfish/nimfishpkg/uci.nim | 240 +++++++++++++++++++++++++ Chess/tests/{positions.txt => all.txt} | 0 Chess/tests/heavy.txt | 7 + Chess/tests/suite.py | 13 +- 9 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 Chess/nimfish/nimfishpkg/uci.nim rename Chess/tests/{positions.txt => all.txt} (100%) create mode 100644 Chess/tests/heavy.txt diff --git a/Chess/README.md b/Chess/README.md index 9782df7..3e1ef57 100644 --- a/Chess/README.md +++ b/Chess/README.md @@ -5,4 +5,9 @@ For now, that's about it. # Installation -Just run `nimble install` \ No newline at end of file +Just run `nimble install` + + +# Testing + +Just run `nimble test`: sit back, relax, get yourself a cup of coffee and wait for it to finish :) \ No newline at end of file diff --git a/Chess/nimfish.nimble b/Chess/nimfish.nimble index 60a8315..c0711dd 100644 --- a/Chess/nimfish.nimble +++ b/Chess/nimfish.nimble @@ -18,5 +18,4 @@ requires "jsony >= 1.1.5" task test, "Runs the test suite": exec "python tests/suite.py -d 6 -b -p -s" - # TODO: Also run fewer, more intense test - # for more precise results \ No newline at end of file + exec "python tests/suite.py -d 7 -b -p -s -f tests/heavy.txt" \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/board.nim b/Chess/nimfish/nimfishpkg/board.nim index e894c5e..51c731b 100644 --- a/Chess/nimfish/nimfishpkg/board.nim +++ b/Chess/nimfish/nimfishpkg/board.nim @@ -46,7 +46,7 @@ proc toFEN*(self: Chessboard): string proc updateChecksAndPins*(self: Chessboard) -proc newChessboard: Chessboard = +proc newChessboard*: Chessboard = ## Returns a new, empty chessboard new(result) for i in 0..63: diff --git a/Chess/nimfish/nimfishpkg/moves.nim b/Chess/nimfish/nimfishpkg/moves.nim index fe301c4..aea9d41 100644 --- a/Chess/nimfish/nimfishpkg/moves.nim +++ b/Chess/nimfish/nimfishpkg/moves.nim @@ -16,6 +16,9 @@ import pieces +import std/strformat + + type MoveFlag* = enum ## An enumeration of move flags @@ -158,4 +161,18 @@ func getFlags*(move: Move): seq[MoveFlag] = if (move.flags and flag.uint16) == flag.uint16: result.add(flag) if result.len() == 0: - result.add(Default) \ No newline at end of file + result.add(Default) + + +func `$`*(self: Move): string = + ## Returns a string representation + ## for the move + result &= &"{self.startSquare}{self.targetSquare}" + let flags = self.getFlags() + if len(flags) > 0: + result &= " (" + for i, flag in flags: + result &= $flag + if i < flags.high(): + result &= ", " + result &= ")" \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/tui.nim b/Chess/nimfish/nimfishpkg/tui.nim index f4cdbf8..c2ba3f3 100644 --- a/Chess/nimfish/nimfishpkg/tui.nim +++ b/Chess/nimfish/nimfishpkg/tui.nim @@ -13,6 +13,7 @@ # limitations under the License. import movegen +import uci import std/strformat @@ -317,12 +318,6 @@ proc handlePositionCommand(board: var Chessboard, command: seq[string]) = return -proc handleUCICommand(board: var Chessboard, command: seq[string]) = - echo "id name Nimfish 0.1" - echo "id author Nocturn9x & Contributors (see LICENSE)" - # TODO - echo "uciok" - const HELP_TEXT = """Nimfish help menu: - go: Begin a search @@ -374,24 +369,22 @@ proc commandLoop*: int = echo "Nimfish by nocturn9x (see LICENSE)" var board = newDefaultChessboard() - uciMode = false + startUCI = false while true: var cmd: seq[string] cmdStr: string try: - if not uciMode: - stdout.write(">>> ") - stdout.flushFile() + stdout.write(">>> ") + stdout.flushFile() cmdStr = readLine(stdin).strip(leading=true, trailing=true, chars={'\t', ' '}) if cmdStr.len() == 0: continue cmd = cmdStr.splitWhitespace(maxsplit=2) - case cmd[0]: of "uci": - handleUCICommand(board, cmd) - uciMode = true + startUCI = true + break of "clear": echo "\x1Bc" of "help": @@ -459,4 +452,6 @@ proc commandLoop*: int = return 0 except EOFError: echo "" - return 0 \ No newline at end of file + return 0 + if startUCI: + startUCISession() \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/uci.nim b/Chess/nimfish/nimfishpkg/uci.nim new file mode 100644 index 0000000..4383296 --- /dev/null +++ b/Chess/nimfish/nimfishpkg/uci.nim @@ -0,0 +1,240 @@ +# Copyright 2024 Mattia Giambirtone & All Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +## Implementation of a UCI compatible server +import std/strutils +import std/strformat + + + +import board +import movegen + + +type + UCISession = ref object + debug: bool + board: Chessboard + + UCICommandType = enum + Unknown + IsReady + NewGame + Quit, + Debug, + Position + + UCICommand = object + case kind: UCICommandType + of Debug: + value: bool + of Position: + fen: string + moves: seq[string] + of Unknown: + reason: string + else: + discard + + + +proc handleUCIMove(session: UCISession, move: string): tuple[move: Move, cmd: UCICommand] {.discardable.} = + var + startSquare: Square + targetSquare: Square + flags: seq[MoveFlag] + if len(move) notin 4..5: + return (nullMove(), UCICommand(kind: Unknown, reason: &"invalid move syntax")) + try: + startSquare = move[0..1].toSquare() + except ValueError: + return (nullMove(), UCICommand(kind: Unknown, reason: &"invalid start square {move[0..1]}")) + try: + targetSquare = move[2..3].toSquare() + except ValueError: + return (nullMove(), UCICommand(kind: Unknown, reason: &"invalid target square {move[2..3]}")) + + # Since the client tells us just the source and target square of the move, + # we have to figure out all the flags by ourselves (whether it's a double + # push, a capture, a promotion, etc.) + if session.board.getPiece(targetSquare).kind != Empty: + flags.add(Capture) + + if session.board.getPiece(startSquare).kind == Pawn and abs(rankFromSquare(startSquare) - rankFromSquare(targetSquare)) == 2: + flags.add(DoublePush) + + if len(move) == 5: + # Promotion + case move[4]: + of 'b': + flags.add(PromoteToBishop) + of 'n': + flags.add(PromoteToKnight) + of 'q': + flags.add(PromoteToQueen) + of 'r': + flags.add(PromoteToRook) + else: + return + let piece = session.board.getPiece(startSquare) + if piece.kind == King and startSquare == session.board.position.sideToMove.getKingStartingSquare(): + if targetSquare in [piece.kingSideCastling(), piece.queenSideCastling()]: + flags.add(Castle) + elif targetSquare == session.board.position.enPassantSquare: + flags.add(EnPassant) + let move = createMove(startSquare, targetSquare, flags) + if session.debug: + echo &"info string making move {move}" + result.move = session.board.makeMove(move) + + +proc handleUCIPositionCommand(session: UCISession, command: seq[string]): UCICommand = + # Makes sure we don't leave the board in an invalid state if + # some error occurs + result = UCICommand(kind: Position) + case command[1]: + of "startpos": + result.fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + session.board = newDefaultChessboard() + if command.len() > 2: + let args = command[2..^1] + if args.len() > 0: + var i = 0 + while i < args.len(): + case args[i]: + of "moves": + var j = i + 1 + while j < args.len(): + let r = handleUCIMove(session, args[j]) + if r.move == nullMove(): + if r.cmd.reason.len() > 0: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})") + else: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid") + result.moves.add(args[j]) + inc(j) + inc(i) + of "fen": + var + args = command[2..^1] + fenString = "" + stop = 0 + for i, arg in args: + if arg in ["moves", ]: + break + if i > 0: + fenString &= " " + fenString &= arg + inc(stop) + result.fen = fenString + args = args[stop..^1] + session.board = newChessboardFromFEN(fenString) + if args.len() > 0: + var i = 0 + while i < args.len(): + case args[i]: + of "moves": + var j = i + 1 + while j < args.len(): + while j < args.len(): + let r = handleUCIMove(session, args[j]) + if r.move == nullMove(): + if r.cmd.reason.len() > 0: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid ({r.cmd.reason})") + else: + return UCICommand(kind: Unknown, reason: &"move {args[j]} is illegal or invalid") + result.moves.add(args[j]) + inc(j) + inc(i) + else: + return UCICommand(kind: Unknown, reason: &"unknown subcomponent '{command[1]}'") + + +proc parseUCICommand(session: UCISession, command: string): UCICommand = + ## Attempts to parse the given UCI command + var cmd = command.replace("\t", "").splitWhitespace() + result = UCICommand(kind: Unknown) + var current = 0 + while current < cmd.len(): + case cmd[current]: + of "isready": + return UCICommand(kind: IsReady) + of "ucinewgame": + return UCICommand(kind: NewGame) + of "quit": + return UCICommand(kind: Quit) + of "debug": + if current == cmd.high(): + return + case cmd[current + 1]: + of "on": + return UCICommand(kind: Debug, value: true) + of "off": + return UCICommand(kind: Debug, value: false) + else: + return + of "position": + return session.handleUCIPositionCommand(cmd) + else: + # Unknown UCI commands should be ignored. Attempt + # to make sense of the input regardless + inc(current) + + +proc startUCISession* = + ## Begins listening for UCI commands + echo "id name Nimfish 0.1" + echo "id author Nocturn9x & Contributors (see LICENSE)" + echo "uciok" + var + cmd: UCICommand + cmdStr: string + session = UCISession() + while true: + try: + cmdStr = readLine(stdin).strip(leading=true, trailing=true, chars={'\t', ' '}) + if cmdStr.len() == 0: + if session.debug: + echo "info string received empty input, ignoring it" + continue + cmd = session.parseUCICommand(cmdStr) + if cmd.kind == Unknown: + if session.debug: + echo &"info string received unknown or invalid command '{cmdStr}' -> {cmd.reason}" + continue + if session.debug: + echo &"info string received command '{cmdStr}' -> {cmd}" + case cmd.kind: + of Quit: + quit(0) + of IsReady: + echo "readyok" + of Debug: + session.debug = cmd.value + of NewGame: + session.board = newDefaultChessboard() + of Position: + discard + else: + discard + except IOError: + if session.debug: + echo "info string I/O error while reading from stdin, exiting" + echo "" + quit(0) + except EOFError: + if session.debug: + echo "info string EOF received while reading from stdin, exiting" + echo "" + quit(0) \ No newline at end of file diff --git a/Chess/tests/positions.txt b/Chess/tests/all.txt similarity index 100% rename from Chess/tests/positions.txt rename to Chess/tests/all.txt diff --git a/Chess/tests/heavy.txt b/Chess/tests/heavy.txt new file mode 100644 index 0000000..6a9c181 --- /dev/null +++ b/Chess/tests/heavy.txt @@ -0,0 +1,7 @@ +rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 +r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - +8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - +r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1 +r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1 +rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8 +r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10 \ No newline at end of file diff --git a/Chess/tests/suite.py b/Chess/tests/suite.py index 6d53f9f..f25b901 100644 --- a/Chess/tests/suite.py +++ b/Chess/tests/suite.py @@ -10,14 +10,15 @@ from copy import deepcopy def main(args: Namespace) -> int: # We try to be polite with resource usage + successful = [] + failed = [] + positions = args.positions_file.read_text().splitlines() + print(f"[S] Loaded {len(positions)} position{'' if len(positions) == 1 else 's'}") + longest_fen = max(sorted([len(fen) for fen in positions])) if not args.parallel: print("[S] Starting test suite") else: print(f"[S] Starting test suite with {args.workers} workers") - successful = [] - failed = [] - positions = args.positions.read_text().splitlines() - longest_fen = max(sorted([len(fen) for fen in positions])) start = timeit.default_timer() if not args.parallel: for i, fen in enumerate(positions): @@ -72,8 +73,8 @@ if __name__ == "__main__": parser.add_argument("-b", "--bulk", action="store_true", help="Enable bulk-counting for Nimfish (much faster)", default=False) parser.add_argument("--stockfish", type=Path, help="Path to the stockfish executable. Defaults to '' (detected automatically)", default=None) parser.add_argument("--nimfish", type=Path, help="Path to the nimfish executable. Defaults to '' (detected automatically)", default=None) - parser.add_argument("--positions", type=Path, help="Location of the file containing FENs to test, one per line. Defaults to 'tests/positions.txt'", - default=Path("tests/positions.txt")) + parser.add_argument("--positions-file", "-f", type=Path, help="Location of the file containing FENs to test, one per line. Defaults to 'tests/all.txt'", + default=Path("tests/all.txt")) parser.add_argument("--no-silent", action="store_true", help="Do not suppress output from compare_positions.py (defaults to False)", default=False) parser.add_argument("-p", "--parallel", action="store_true", help="Run multiple tests in parallel", default=False) parser.add_argument("--workers", "-w", type=int, required=False, help="How many workers to use in parallel mode (defaults to cpu_count() / 2)", default=cpu_count() // 2)