""" AI HTTP server interface for AI training Opens a TCP socket to listen for incoming connections from an AI client. When a new connection is established, the server intialise a new training session : - Model Score is set to 0 For the duration of the training session, two mapSets are created, one with hard maps and one with all of the other maps. When the AI client sends a request for a new game or validation game, the server loads a map based on the saved best score or from the hard mapSet if a validation game is requested, and sends it to the client as a matrix of block types strings. The client then sends back a list of moves to be executed by the game engine. E Each item of the list is a different model givin a string representing a move from GameState.PLAYER_MOVEMENTS to execute in the simulation. The server then executes the moves in the game engine for each model and sends back the new player position or game scores (calculated here using formula with weights) if the game is finished to the client. This process is repeated until all models have finished the game. The client will then train the model based on their scores and the server set the Model Score to the best score of the models and shows in a separate thread the best model series of moves on the previous map. The client then sends a new request for a new game and the process repeats. The simulation runs in a pool of threads to allow multiple models to be trained in parallel. The handler makes sure an AI client is not stuck and only allows a maximum number of moves to be executed in a single game simulation, based on the map min_moves value. """ import json import os import random import socketserver from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from threading import Condition, Thread from time import sleep from game.handlers.aiSimulation import AiSimulation from game.game import Game from game.gameState import GameState from game.handlers.gameHandler import GameHandler from game.map import Map from game.mapReader import MapReader from game.mapSet import MapSet class AiHttpGameHandler(GameHandler): DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 VALIDATION_DIFFICULTY = 70 REPLAY_FRAME_DELAY = 0.1 # Scoring weights TOTAL MUST BE 100 COINS_WEIGHT = 40 DISTANCE_WEIGHT = 20 WIN_WEIGHT = 40 def __init__(self, host: str | None = None, port: int | None = None, max_workers: int | None = None): super().__init__() self.host = host or os.getenv("AI_HOST", self.DEFAULT_HOST) self.port = port or int(os.getenv("AI_PORT", self.DEFAULT_PORT)) self.max_workers = max_workers or int(os.getenv("AI_MAX_WORKERS", "4")) self.map_reader = MapReader() self.map_set = MapSet(self.map_reader, self.map_reader.get_map_names()) self.validation_maps = [ game_map for game_map in self.map_set.maps if game_map.difficulty >= self.VALIDATION_DIFFICULTY ] or list(self.map_set.maps) def run(self) -> None: handler = self class RequestHandler(socketserver.StreamRequestHandler): def handle(self) -> None: session = { "performance": 0.0, "simulations": None, "best_run": None, "replay_condition": Condition(), "client_connected": True, } replay_thread = Thread(target=handler._replay_best_runs, args=(session,)) replay_thread.start() print(f"AI client connected: {self.client_address}", flush=True) try: for raw_request in self.rfile: try: request = json.loads(raw_request) # print(f"AI request received: {request.get('type')} : {request}", flush=True) response = handler._handle_request(request, session) except (json.JSONDecodeError, TypeError, ValueError, KeyError) as error: response = {"ok": False, "type": "error", "error": str(error)} # print(f"AI request error: {error}", flush=True) # print(f"AI response sent: {response.get('type')}", flush=True) compact_response = json.dumps(response, separators=(",", ":")) self.wfile.write((compact_response + "\n").encode("utf-8")) self.wfile.flush() finally: with session["replay_condition"]: session["client_connected"] = False session["replay_condition"].notify_all() replay_thread.join() print(f"AI client disconnected: {self.client_address}", flush=True) class ThreadingTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True with ThreadingTCPServer((self.host, self.port), RequestHandler) as server: self.port = server.server_address[1] print(f"AI TCP server listening on {self.host}:{self.port}") server.serve_forever() def _handle_request(self, request: dict, session: dict | None = None) -> dict: if not isinstance(request, dict): raise ValueError("Each request must be a JSON object.") request_type = request.get("type") if request_type == "ping": return {"ok": True, "type": "pong"} if request_type == "simulate": return self._simulate(request, session) if request_type == "step": return self._step(request, session) raise ValueError("Unknown request type. Expected 'ping', 'simulate', or 'step'.") def _simulate(self, request: dict, session: dict | None = None) -> dict: models = request.get("models") if not isinstance(models, list) or not models: raise ValueError("'models' must be a non-empty list.") if not all(isinstance(model, dict) for model in models): raise ValueError("Each item in 'models' must be a JSON object.") validation = bool(request.get("validation", False)) if session is None: raise ValueError("Simulation requests must belong to a TCP session.") if session.get("simulations"): raise ValueError("A simulation is already active in this session.") session["generation"] = session.get("generation", 0) + 1 selected_map = self._select_map(session.get("performance", 0.0), validation) session["last_result"] = None # print( # f"Selected map for simulation: {selected_map.name} " # f"(difficulty: {selected_map.difficulty})", # flush=True, # ) simulations = {} for model in models: model_id = model.get("id") if not isinstance(model_id, str) or not model_id: raise ValueError("Each model must have a non-empty string 'id'.") if model_id in simulations: raise ValueError(f"Duplicate model id '{model_id}'.") simulation = AiSimulation(selected_map) # print(f"Starting simulation for model '{model_id}'", flush=True) simulation.start() # print(f"Model '{model_id}' is ready for its first move", flush=True) simulations[model_id] = simulation session["simulations"] = simulations session["map"] = selected_map return { "ok": True, "type": "move_request", "map": self._map_payload(selected_map), "results": self._results(simulations), } def _step(self, request: dict, session: dict | None) -> dict: if session is None: raise ValueError("No simulation is active. Send a 'simulate' request first.") if not session.get("simulations"): if session.get("last_result") is not None: print("Repeated step after simulation completion; returning final result", flush=True) return session["last_result"] raise ValueError("No simulation is active. Send a 'simulate' request first.") moves = request.get("moves") # print( # "Step payload contains moves for: " # f"{list(moves) if isinstance(moves, dict) else type(moves).__name__}", # flush=True, # ) if not isinstance(moves, dict): raise ValueError("'moves' must be an object mapping model ids to moves.") simulations = session["simulations"] if set(moves) != set(simulations): raise ValueError("'moves' must contain exactly one move for every model.") for model_id, move in moves.items(): if move is not None and (not isinstance(move, str) or move not in GameState.PLAYER_MOVEMENTS): raise ValueError(f"Invalid move for model '{model_id}'.") # print("Dispatching step to simulation workers", flush=True) def step_model(item): model_id, move = item simulation = simulations[model_id] # print(f"Starting step for model '{model_id}'", flush=True) result = simulation.step(move) result["score"] = self._score_result(simulation, result) # print(f"Finished step for model '{model_id}'", flush=True) return model_id, result with ThreadPoolExecutor(max_workers=min(self.max_workers, len(simulations))) as executor: results = dict(executor.map(step_model, moves.items())) # print(f"Completed step for models: {list(moves)}", flush=True) all_finished = all(result["finished"] for result in results.values()) response = {"ok": True, "type": "move_request", "results": results} if all_finished: response["type"] = "simulation_result" best_score = max(result["score"] for result in results.values()) best_model_id = max(results, key=lambda model_id: results[model_id]["score"]) response["score"] = best_score session["performance"] = best_score session["last_result"] = response session["simulations"] = None session["best_run"] = ( deepcopy(session["map"]), list(simulations[best_model_id].moves), session["generation"], ) replay_condition = session.get("replay_condition") if replay_condition is not None: with replay_condition: replay_condition.notify_all() return response def _replay_best_runs(self, session: dict) -> None: condition = session["replay_condition"] while True: with condition: condition.wait_for( lambda: session["best_run"] is not None or not session["client_connected"] ) best_run = session["best_run"] session["best_run"] = None if best_run is None: return self._show_best_run(*best_run) def _show_best_run(self, game_map, moves: list[str], generation: int) -> None: game = Game(game_map, GameState()) remaining_moves = iter(moves) def on_input_request() -> str: try: return next(remaining_moves) except StopIteration: game.stop_game() return -1 def on_position_update() -> None: game_string = game.to_string() frame_lines = [] for index, line in enumerate(game_string.splitlines()): if index == 0: line += "\tCoins: " + str(game.gameState.coins) elif index == 1: line += "\tMoves: " + str(game.gameState.movesCount) elif index == 2: line += "\tGeneration: " + str(generation) frame_lines.append(line) print("\033[2J\033[H\n\n\n" + "\n".join(frame_lines), flush=True) sleep(self.REPLAY_FRAME_DELAY) game.set_waiting_for_input_callback(on_input_request) game.set_position_update_callback(on_position_update) game.start_game() def _results(self, simulations: dict) -> dict: results = {} for model_id, simulation in simulations.items(): result = simulation.result() result["score"] = self._score_result(simulation, result) results[model_id] = result return results def _select_map(self, performance: float, validation: bool): if validation: return random.choice(self.validation_maps) return self.map_set.get_maps_by_difficulty(round(performance), 1)[0] def _score_result(self, simulation: AiSimulation, result: dict) -> int: if (self.COINS_WEIGHT + self.DISTANCE_WEIGHT + self.WIN_WEIGHT) != 100: raise ValueError("Scoring weights must sum to 100.") game_map = simulation.game.map finish = game_map.get_first_block_type_position("finish") spawn = game_map.get_first_block_type_position("spawn_point") total_coins = game_map.count_blocks_of_type("coin") if finish is None or spawn is None: distance_progress = 0.0 else: start_distance = abs(spawn[0] - finish[0]) + abs(spawn[1] - finish[1]) remaining_distance = abs(result["position"][0] - finish[0]) + abs(result["position"][1] - finish[1]) distance_progress = 1.0 if start_distance == 0 else max( 0.0, min(1.0, 1.0 - remaining_distance / start_distance) ) coin_progress = 0.0 if total_coins == 0 else min(1.0, result["coins"] / total_coins) return round( self.COINS_WEIGHT * coin_progress + self.DISTANCE_WEIGHT * distance_progress + self.WIN_WEIGHT * result["won"] ) @staticmethod def _map_payload(game_map: Map) -> dict: return { "name": game_map.name, "difficulty": game_map.difficulty, "layout": game_map.to_type_dict(), } def on_position_update(self) -> None: pass def on_input_request(self) -> str: raise RuntimeError("AI input is supplied one turn at a time in 'step' requests.")