diff --git a/Systems/FairyStockfish/ServerManager.gd b/Systems/FairyStockfish/ServerManager.gd new file mode 100644 index 0000000..b45066c --- /dev/null +++ b/Systems/FairyStockfish/ServerManager.gd @@ -0,0 +1,96 @@ +# ServerManager.gd +extends Node + +var server_path: String = "" +var running := false +var server_process_id: int = -50 + +var server_url = "http://localhost:27531" +func _ready(): + # Get the path to the server directory relative to the project + server_path = ProjectSettings.globalize_path("res://Assets/ChessEngines/fairy-chess-server") + start_server() + +func _exit_tree(): + stop_server() + +func start_server() -> bool: + if running: + return true + + print("Starting chess server...", server_path) + + # Make sure we're in the correct directory + + + var http_request = HTTPRequest.new() + add_child(http_request) + # http_request.connect("request_completed", self._on_init_request_completed) + http_request.request_completed.connect(self._on_init_request_completed) + http_request.request(server_url + "/health") + + # var http = HTTPClient.new() + # var err = http.connect_to_host("localhost", 27531) + # print("is_server_running", err, OK) + # if err != OK: + # server_process_id = OS.create_process("node", [server_path + "/index.js"]) + + # if server_process_id <= 0: + # printerr("Failed to start server") + # return false + + + # if exit_code != 0: + # printerr("Failed to start server: ", output) + # return false + + running = true + return true + +func _on_init_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + var json = JSON.new() + json.parse(body.get_string_from_utf8()) + var response = json.get_data() + # Will print the user agent string used by the HTTPRequest node (as recognized by httpbin.org). + print(response) + if result != HTTPRequest.RESULT_SUCCESS: + print("HTTP Request failed") + server_process_id = OS.create_process("node", [server_path + "/index.js"]) + + if server_process_id <= 0: + printerr("Failed to start server") + return false + print("Chess server started") + return + + if response.status != "ok": + print("Server error : ", response_code, json.parse(body.get_string_from_utf8()),) + return + + +func stop_server(): + if not running: + return + + print("Stopping chess server...") + # Send a stop request to the server + var http = HTTPClient.new() + var err = http.connect_to_host("localhost", 27531) + if err == OK: + # Send a POST request to a shutdown endpoint + var headers = ["Content-Type: application/json"] + var data = JSON.stringify({"command": "shutdown"}) + http.request(HTTPClient.METHOD_POST, "/shutdown", headers, data) + + running = false + print("Chess server stopped") + +func is_server_running() -> bool: + if not running: + return false + + # Try to connect to the server + var http = HTTPClient.new() + var err = http.connect_to_host("localhost", 27531) + print("is_server_running", err, OK) + return err == OK \ No newline at end of file diff --git a/Systems/FairyStockfish/Stockfish.gd b/Systems/FairyStockfish/Stockfish.gd index 164ca72..9e2b279 100644 --- a/Systems/FairyStockfish/Stockfish.gd +++ b/Systems/FairyStockfish/Stockfish.gd @@ -6,10 +6,18 @@ var board: Array var game: ChessGame # Engine state -var engine_path: String = "" +var process_id: int = -1 +var engine_thread: Thread +var pipe_info: Dictionary +var command_queue: Array = [] +var response_queue: Array = [] var mutex: Mutex +var semaphore: Semaphore var running := false - +var engine_path: String = "" +var _cleanup_in_progress := false +var threadTracker: Array = [] +var last_file_size = 0 # Game state var moves: Array = [] var move_time: int = 1000 # in ms @@ -29,6 +37,7 @@ var piece_type_from_symbol := { func _init(boardRef: Array): board = boardRef mutex = Mutex.new() + semaphore = Semaphore.new() func _ready(): game = get_parent() as ChessGame @@ -36,36 +45,82 @@ func _ready(): func _exit_tree(): disconnect_engine() +func _notification(what): + if what == NOTIFICATION_PREDELETE: + cleanup() + +func cleanup(): + if _cleanup_in_progress: + return + _cleanup_in_progress = true + + disconnect_engine() + _cleanup_in_progress = false + +func safe_cleanup(): + if engine_thread: + if engine_thread.is_alive(): + var timeout = 5000 # 5 seconds + var start_time = Time.get_ticks_msec() + while engine_thread.is_alive(): + if Time.get_ticks_msec() - start_time > timeout: + printerr("Thread cleanup timeout") + break + OS.delay_msec(10) + var result = engine_thread.wait_to_finish() + if result != OK: + printerr("Failed to cleanup thread: ", result) + engine_thread = null + func connect_to_engine(path: String) -> bool: if running: return false engine_path = path - - # Test if we can execute stockfish - var output = [] - var exit_code = OS.execute(engine_path, ["uci"], output, true) - print("Exit code: ", exit_code) - print("Output: ", output) - if exit_code != OK: - printerr("Failed to start Stockfish engine: ", exit_code) + + pipe_info = OS.execute_with_pipe(engine_path, ["uci"]) + process_id = pipe_info.get("pid", -1) + if process_id <= 0: + printerr("Failed to start engine process") return false running = true - print("Connected to engine: ", engine_path) + print("PID ", process_id, " Connected to engine: ", engine_path) - # Initialize with current game state + # Start communication thread + engine_thread = Thread.new() + engine_thread.start(_engine_thread_function) + + # # Initialize UCI mode + # _send_command_wait("uci", "uciok") + # _send_command_wait("isready", "readyok") + + # # Initialize with current game state + _send_command("ucinewgame") + _send_command_wait("isready", "readyok") load_fen(game.getCurrentFen()) return true + func disconnect_engine(): if running: - mutex.lock() - var output = [] - OS.execute(engine_path, ["quit"], output, true) - mutex.unlock() + _send_command("quit") running = false - engine_path = "" + + # Wait for thread to finish + if engine_thread and engine_thread.is_started(): + engine_thread.wait_to_finish() + + # Clean up process + if pipe_info: + if pipe_info.get("stdio"): + pipe_info["stdio"].close() + if pipe_info.get("stderr"): + pipe_info["stderr"].close() + if process_id > 0: + OS.kill(process_id) + process_id = -1 + print("Disconnected from engine") func limit_strength_to(elo_value: int): @@ -87,40 +142,35 @@ func load_fen(fen: String): update_position(fen) func update_position(fen: String): - mutex.lock() _send_command("position fen " + fen) - mutex.unlock() - + # _send_command_wait("isready", "readyok") + func generateMove(think_time_ms: int = 1000) -> void: if not running: return + print("------------=========GENERATE MOVE =========-----------------") move_time = think_time_ms - # Update position first - mutex.lock() + # Send position var command = "position fen " + game.getCurrentFen() - if moves.size() > 0: - command += " moves " + " ".join(moves) - print(command) - var output = _send_command(command) + _send_command(command) + # _send_command_wait("isready", "readyok") + # var response = _send_command_wait("isready", "readyok") + # if not "readyok" in response: + # print("Engine not ready after setting position") + # return - # Then get move - output = _send_command("go movetime " + str(move_time)) - if output.size() == 0: - return - print(type_string(typeof(output[0]))) - var lines = output[0].split("\n") - mutex.unlock() - # Parse the output + # Get move with timeout for thinking + var response = _send_command_wait("go movetime " + str(move_time), "bestmove") + print("Move response: ", response) + + # Parse response for bestmove + var lines = response.split("\n") for line in lines: - # print("-") - # print(line) - # print("-") if line.begins_with("bestmove"): var parts = line.split(" ") - print( parts) - if parts.size() >= 2: + if parts.size() >= 4: # Should have "bestmove e2e4 ponder e7e5" generated_move = { "move": parts[1], "ponder": parts[3] @@ -172,10 +222,169 @@ func send_move(move_data: Dictionary): _send_command(command) mutex.unlock() -func _send_command(command: String) -> Array: - if not running: - return [] + + +func safe_get_line(stdio: FileAccess) -> Array: + var start_time = Time.get_ticks_msec() + var timeout = 500 # 50ms timeout + + if stdio and not stdio.eof_reached(): + var thread = Thread.new() + var mutex = Mutex.new() + var done = false + var result = "" - var output = [] - OS.execute(engine_path, [command], output, true) - return output \ No newline at end of file + thread.start(func(): + # Try to read a chunk of data + var buffer = stdio.get_buffer(1024) # Read up to 1KB at a time + if buffer.size() > 0: + var content = buffer.get_string_from_utf8() + var lines = content.split("\n") + # Get last non-empty line + # var last_line = "" + # for i in range(lines.size() - 1, -1, -1): + # if not lines[i].strip_edges().is_empty(): + # last_line = lines[i] + # break + + mutex.lock() + # result = last_line + done = true + mutex.unlock() + print("THREAD: ", lines) + return lines + + mutex.lock() + done = true + mutex.unlock() + return [] + ) + + # Wait for either completion or timeout + while true: + mutex.lock() + var is_done = done + mutex.unlock() + + if not thread.is_alive(): + print("DeadThread") + var thread_result = thread.wait_to_finish() + print("******T-RES*********", thread_result) + return [thread_result, null] + # else: + # print("Alive THread Kill ME") + + if Time.get_ticks_msec() - start_time > timeout: + break + + OS.delay_msec(200) + + # Handle thread cleanup and result capture + if thread.is_alive(): + threadTracker.push_front(thread) + mutex.lock() + var final_result = [result, null] + mutex.unlock() + return final_result + else: + var thread_result = thread.wait_to_finish() + return [thread_result, null] + + return ["", null] + +func _engine_thread_function(): + print("Engine thread started") + var stdio = pipe_info.get("stdio") + var stderr = pipe_info.get("stderr") + + + while running: + mutex.lock() + print("CMDS ", command_queue) + if command_queue.size() > 0: + var command = command_queue.pop_front() + mutex.unlock() + + if stdio: + stdio.store_line(command) + print("************************Sent command: ", command) + OS.delay_msec(200) + else: + mutex.unlock() + + print("safe_get_line Start") + var result = safe_get_line(stdio) + print("safe_get_line End", result) + + # var line = result[0] + # if not line.is_empty(): + # mutex.lock() + # response_queue.push_back(line) + # mutex.unlock() + # semaphore.post() + # print("Engine response: ", line) + # var line = result[0] + for line in result: + if line and not line.is_empty(): + mutex.lock() + response_queue.append_array(line) + mutex.unlock() + semaphore.post() + print("Engine response: ", line) + + OS.delay_msec(200) + + print("################################left thread###########################") + +func _send_command_wait(command: String, expected: String = "") -> String: + # Add command to queue more efficiently + mutex.lock() + command_queue.push_back(command) + mutex.unlock() + + var response = "" + var start_time = Time.get_ticks_msec() + var timeout = 5000 # Reduced timeout to 5 seconds + + # Use an exponential backoff for delays + var delay_time = 1 + var max_delay = 16 # Maximum delay in milliseconds + + while running: + var current_time = Time.get_ticks_msec() + if current_time - start_time > timeout: + printerr("Command timeout: ", command) + break + + if semaphore.try_wait(): + # Reset delay on successful response + delay_time = 1 + + mutex.lock() + var lines_to_process = response_queue.duplicate() + response_queue.clear() + mutex.unlock() + print("PROCESSING LINES ##################") + print(lines_to_process) + # if lines_to_process.is_empty(): + # return + # Process all available lines + for line in lines_to_process: + if not line.is_empty(): + print(expected, " =? ", line) + if expected.is_empty() or expected in line: + print("MATCHED LINES ##################", line, ' ', expected) + return line + # print("This should not print if we returned") + else: + # Use exponential backoff for delays + OS.delay_msec(delay_time) + delay_time = mini(delay_time * 2, max_delay) + + return response + +func _send_command(command: String): + mutex.lock() + command_queue.push_back(command) + mutex.unlock() + diff --git a/Systems/FairyStockfish/StockfishClient.gd b/Systems/FairyStockfish/StockfishClient.gd new file mode 100644 index 0000000..bf68ef5 --- /dev/null +++ b/Systems/FairyStockfish/StockfishClient.gd @@ -0,0 +1,215 @@ +# StockfishClient.gd +extends Node + +var server_url = "http://localhost:27531" +var http_request: HTTPRequest +var running := false +var generated_move: Dictionary = {} +var move_time: int = 1000 +var game: ChessGame + +var symbol_from_piece_type := { + "PAWN": "p", "KNIGHT": "n", "BISHOP": "b", + "ROOK": "r", "QUEEN": "q", "KING": "k" +} + +var piece_type_from_symbol := { + "p": "PAWN", "n": "KNIGHT", "b": "BISHOP", + "r": "ROOK", "q": "QUEEN", "k": "KING" +} + +func _init(): + print("STARTING SERVER CLIENT") + http_request = HTTPRequest.new() + add_child(http_request) + http_request.request_completed.connect(self._on_request_completed) + +func connect_to_engine(_path: String, g: ChessGame) -> bool: + game = g + # Wait for server to be ready + var retries = 5 + var delay = 1.0 # seconds + + while retries > 0: + if ServerManager.is_server_running(): + print("**************SERVER RUNNING ****************") + running = true + start_board(); + load_fen(game.getCurrentFen()) + return true + + await get_tree().create_timer(delay).timeout + retries -= 1 + print("**************ATTEMPTING SERVER CONNECTION****************") + + return false + +func disconnect_engine(): + running = false + +func limit_strength_to(elo_value: int): + if not running: + return + + var headers = ["Content-Type: application/json"] + var body = JSON.stringify({ + "options": { + "UCI_LimitStrength": "true", + "UCI_Elo": str(elo_value) + } + }) + print(body) + http_request.request(server_url + "/setoption", headers, HTTPClient.METHOD_POST, body) + +func stop_calculating(): + if not running: + return + # Send stop command to server + http_request.request(server_url + "/stop", [], HTTPClient.METHOD_POST) + +func load_fen(fen: String): + if not running: + return + # var http_request = HTTPRequest.new() + # add_child(http_request) + # http_request.request_completed.connect(self._on_request_completed) + var headers = ["Content-Type: application/json"] + var body = JSON.new().stringify({ + "fen": fen + }) + print(server_url + "/position") + print(body) + + http_request.request(server_url + "/position", headers, HTTPClient.METHOD_POST, body) + +func start_board(): + if not running: + return + var headers = ["Content-Type: application/json"] + var body = JSON.new().stringify({ + "variant": 'chess' + }) + print(server_url + "/new") + print(body) + http_request.request(server_url + "/new", headers, HTTPClient.METHOD_POST, body) + + +func update_position(fen: String): + load_fen(fen) + +func generateMove(think_time_ms: int = 1000) -> void: + if not running: + return + print("&&&&&&&&&&&&&&&GENERATING MOVE&&&&&&&&&&&&&&&&&&&&&&") + + move_time = think_time_ms + + var headers = ["Content-Type: application/json"] + var body = JSON.stringify({ + "movetime": think_time_ms, + "depth": 15 + }) + + # Request engine move + var move_request = HTTPRequest.new() + add_child(move_request) + move_request.request_completed.connect(self._on_bestmove_completed) + var error = move_request.request( + server_url + "/enginemove", + headers, + HTTPClient.METHOD_POST, + body + ) + + + + +func getGeneratedMove() -> Dictionary: + var move = generated_move.duplicate() + generated_move.clear() + return move + +func send_move(move_data: Dictionary): + if not running: + return + + var headers = ["Content-Type: application/json"] + var move_str = from_move_to_string(move_data) + var body = JSON.stringify({ + "move": move_str + }) + + http_request.request(server_url + "/move", headers, HTTPClient.METHOD_POST, body) + +# Helper functions +func from_move_to_string(move_data: Dictionary) -> String: + # Same implementation as original + var board_size = 8 # Standard chess board + + var source_i = move_data.source % board_size + var source_j = move_data.source / board_size + var target_i = move_data.target % board_size + var target_j = move_data.target / board_size + + var letters = "abcdefgh" + var str_move = "%s%d%s%d" % [ + letters[source_i], + board_size - source_j, + letters[target_i], + board_size - target_j + ] + + if move_data.get("flags", "") == "PROMOTION": + str_move += symbol_from_piece_type[move_data.promotion_piece].to_lower() + + return str_move + + + +func _on_bestmove_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + print("^^^^^^^^^^^^^^^_on_bestmove_completed^^^^^^^^^^^^^^^^^") + var json = JSON.new() + json.parse(body.get_string_from_utf8()) + var response = json.get_data() + # Will print the user agent string used by the HTTPRequest node (as recognized by httpbin.org). + print(response) + if result != HTTPRequest.RESULT_SUCCESS: + print("HTTP Request failed") + return + + if response.status != "ok": + print("Server error:", response_code, response,) + return + + + generated_move = { + "move": response.move, + "ponder": "" + } + + + +func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + print("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^") + var json = JSON.new() + json.parse(body.get_string_from_utf8()) + var response = json.get_data() + # Will print the user agent string used by the HTTPRequest node (as recognized by httpbin.org). + print(response) + if result != HTTPRequest.RESULT_SUCCESS: + print("HTTP Request failed") + return + + if response.status != "ok": + print("Server error:", response_code, json.parse(body.get_string_from_utf8()),) + return + + + # Handle different response types + if "bestMove" in response: + generated_move = { + "move": response.bestMove, + "ponder": response.get("ponderMove", "") + } + + # You can add more response handling here \ No newline at end of file diff --git a/Systems/Game/ChessGame.gd b/Systems/Game/ChessGame.gd index 8133fd7..1fd3cf3 100644 --- a/Systems/Game/ChessGame.gd +++ b/Systems/Game/ChessGame.gd @@ -26,7 +26,7 @@ var currentlyMovingPiece = null var p1Points: int = 0 var p2Points: int = 0 var Turn: int = 0 -const StockfishController = preload("res://Systems/FairyStockfish/Stockfish.gd") +const StockfishController = preload("res://Systems/FairyStockfish/StockfishClient.gd") var stockfishController: StockfishController var stockfishPath = "res://Assets/ChessEngines/stockfish/stockfish.exe" @onready var turnIndicator: ColorRect = $TurnIndicator @@ -56,6 +56,7 @@ var highlightStyle = null var cpuElo = 1500 func _ready() -> void: + print("Initi Local Stockfish") if OS.get_name() == "Windows": stockfishPath = "res://Assets/ChessEngines/stockfish/stockfish.exe" else: @@ -66,10 +67,11 @@ func _ready() -> void: initializeGame() initializeTiles() stateMachine.transitionToNextState(Constants.WHITE_TURN) - stockfishController = StockfishController.new(board) + stockfishController = StockfishController.new() add_child(stockfishController) - if stockfishController.connect_to_engine(stockfishPath): - stockfishController.limit_strength_to(cpuElo) + stockfishController.connect_to_engine(stockfishPath, self) + # if stockfishController.connect_to_engine(stockfishPath): + # stockfishController.limit_strength_to(cpuElo) func _exit_tree(): stockfishController.disconnect_engine() @@ -134,7 +136,7 @@ func getCurrentFen() -> String: # Add the rest of the FEN string components fen += " %s %s %s %d %d" % [ - "w" if isWhiteToMove else "b", + "b" if currentPlayer == BLACK else "w", castlingRights, enPassantTarget, halfMoveClock, diff --git a/Systems/StateMachine/GameStates/BlackTurn.gd b/Systems/StateMachine/GameStates/BlackTurn.gd index 4bf21fe..f15a1b2 100644 --- a/Systems/StateMachine/GameStates/BlackTurn.gd +++ b/Systems/StateMachine/GameStates/BlackTurn.gd @@ -2,14 +2,20 @@ extends "res://Systems/StateMachine/ChessGameState.gd" var moveTimer: Timer +var stateDelay: Timer func _ready() -> void: moveTimer = Timer.new() moveTimer.one_shot = true # Timer only fires once moveTimer.connect("timeout", _on_move_timer_timeout) add_child(moveTimer) + stateDelay = Timer.new() + stateDelay.one_shot = true # Timer only fires once + stateDelay.connect("timeout", _on_state_delay_timeout) + add_child(stateDelay) func enter(_previous: String, _data := {}) -> void: + game.moveCount += 1; print("ENTERING STATE ", Constants.BLACK_TURN) game.currentPlayer = game.BLACK @@ -22,9 +28,14 @@ func _on_move_timer_timeout() -> void: print("------------------GENERATING MOVE --------------------") if game.stockfishController: print("------------------STARTING GENERATING MOVE --------------------") - game.stockfishController.generateMove(1000) # 1 second think time - finished.emit(Constants.HAND_SETUP) + game.stockfishController.load_fen(game.getCurrentFen()) + OS.delay_msec(250) + game.stockfishController.generateMove(1000) # 1 second think time + stateDelay.start(2) + +func _on_state_delay_timeout() -> void: + finished.emit(Constants.HAND_SETUP) func exit() -> void: moveTimer.stop() diff --git a/Systems/StateMachine/GameStates/Movement.gd b/Systems/StateMachine/GameStates/Movement.gd index 220905a..abec343 100644 --- a/Systems/StateMachine/GameStates/Movement.gd +++ b/Systems/StateMachine/GameStates/Movement.gd @@ -74,8 +74,11 @@ func convert_algebraic_to_location(square: String) -> String: # Convert file letter to number (a=0, b=1, etc) var file_num = file.unicode_at(0) - 'a'.unicode_at(0) - # Convert rank to 0-based index from bottom - var rank_num = rank - 1 + # Since we're working with black's moves and our board is oriented with white at bottom: + # 1. Flip file: 7 - file_num to mirror horizontally + # 2. Flip rank: 8 - rank to mirror vertically + file_num = file_num + var rank_num = 8 - rank # Return location in your game's format return "%d-%d" % [file_num, rank_num] diff --git a/Systems/TileManager.gd b/Systems/TileManager.gd index c4ccd79..b6b6327 100644 --- a/Systems/TileManager.gd +++ b/Systems/TileManager.gd @@ -76,7 +76,7 @@ func clear_tiles() -> void: remove_tile(location) # Function to place random game tiles at the start of each match -func place_random_game_tiles(num_tiles: int = 8) -> void: +func place_random_game_tiles(num_tiles: int = 0) -> void: if !board_flow: push_error("TileManager not initialized with board_flow") return @@ -131,24 +131,7 @@ func place_random_game_tiles(num_tiles: int = 8) -> void: var next_pos = available_positions[i + 1] var next_container = board_flow.get_node(next_pos) as PieceContainer if next_container: - # var next_is_white = (int(next_pos.split("-")[0]) + int(next_pos.split("-")[1])) % 2 == 0 - - # # Create portal pair with alternating blue/orange colors - # # var portal_color = Color.ORANGE if (i % 2 == 0) else Color.BLUE - # var portals = PortalTile.create_portal_pair( - # container, is_white, - # next_container, next_is_white, - # -1, # permanent duration - # i, # pair id - # Color.ORANGE, - # Color.BLUE - # ) - - # # Add both portals - # add_tile(pos, portals[0]) - # add_tile(next_pos, portals[1]) place_portal_pair(pos, i, available_positions, container, is_white) - # Skip the next position since we used it for the portal pair skipNext = true else: # If we can't make a pair, fall back to a wall tile diff --git a/project.godot b/project.godot index 6f12b78..2171d21 100644 --- a/project.godot +++ b/project.godot @@ -18,6 +18,7 @@ config/icon="res://icon.svg" [autoload] Constants="*res://Systems/StateMachine/Constants.gd" +ServerManager="*res://Systems/FairyStockfish/ServerManager.gd" [editor]