diff --git a/server/GameBoard.py b/server/GameBoard.py index d76fd12..80839f5 100644 --- a/server/GameBoard.py +++ b/server/GameBoard.py @@ -151,6 +151,11 @@ class GameBoard: return {"name": self.type, "is_ladder": self.is_ladder} + def __getstate__(self): + state = self.__dict__.copy() + state['turns'] = [] # strip turn history — grows linearly, not needed for move computation + return state + async def save(self, store_class, **kwargs): store = store_class(**kwargs) await store.save(self) diff --git a/server/blueprints/battlesnake.py b/server/blueprints/battlesnake.py index e03df7a..c862e21 100644 --- a/server/blueprints/battlesnake.py +++ b/server/blueprints/battlesnake.py @@ -1,5 +1,5 @@ from typing import TYPE_CHECKING, cast -import json, time, os +import asyncio, json, time, os from quart import Blueprint, request, jsonify @@ -61,9 +61,27 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint: server.metrics_collector.record_http_request('move') game_state = await request.get_json() move_started = time.perf_counter() - game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state)) - next_move = game_board.snake_neat_make_a_move() - await server.game_runtime.persist_game_board(game_state['game']['id'], game_board) + + game_id = game_state['game']['id'] + timeout_ms = int(game_state.get('game', {}).get('timeout', 500)) + budget_sec = max(0.05, (timeout_ms - 50) / 1000.0) + + next_move = None + move_completed = False + game_board = None + + try: + async with asyncio.timeout(budget_sec): + game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state)) + loop = asyncio.get_running_loop() + next_move = await loop.run_in_executor(None, game_board.snake_neat_make_a_move) + move_completed = True + except TimeoutError: + await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}')) + + if move_completed: + await server.game_runtime.persist_game_board(game_id, game_board) + await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board) elapsed_ms = (time.perf_counter() - move_started) * 1000.0 await server.metrics_collector.record_move(next_move, elapsed_ms) diff --git a/server/game_state_store/RedisGameBoardStore.py b/server/game_state_store/RedisGameBoardStore.py index c7183e2..587848e 100644 --- a/server/game_state_store/RedisGameBoardStore.py +++ b/server/game_state_store/RedisGameBoardStore.py @@ -1,5 +1,5 @@ from typing import TYPE_CHECKING -import inspect, pickle +import inspect, pickle, zlib if TYPE_CHECKING: from server.GameBoard import GameBoard @@ -28,7 +28,7 @@ class RedisGameBoardStore: async def save(self, game_id:str, game_board:'GameBoard') -> None: redis = await self._get_redis() - payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL) + payload = zlib.compress(pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL), level=1) await redis.set(self._key(game_id), payload, ex=self.ttl_seconds) async def load(self, game_id:str): @@ -36,7 +36,10 @@ class RedisGameBoardStore: payload = await redis.get(self._key(game_id)) if payload is None: return None - return pickle.loads(payload) + try: + return pickle.loads(zlib.decompress(payload)) + except zlib.error: + return pickle.loads(payload) async def delete(self, game_id:str) -> None: redis = await self._get_redis() diff --git a/snakes/ApexBattleSnake.py b/snakes/ApexBattleSnake.py index ae5e3be..74db7db 100644 --- a/snakes/ApexBattleSnake.py +++ b/snakes/ApexBattleSnake.py @@ -79,6 +79,11 @@ class ApexBattleSnake(TemplateSnake): # RL bootstrap dataset recorder self.rl_bootstrap = RLBootstrapDataset() + def __getstate__(self): + state = super().__getstate__() + state['_game_phase'] = 0.0 # A3: per-turn scalar, recomputed in choose_move + return state + # ── Env helpers ────────────────────────────────────────────────────────────── def _get_timeout_buffer_ms(self) -> int: diff --git a/snakes/TemplateSnake.py b/snakes/TemplateSnake.py index 73f38f1..2062feb 100644 --- a/snakes/TemplateSnake.py +++ b/snakes/TemplateSnake.py @@ -202,3 +202,12 @@ class TemplateSnake: def set_target_food(self, target_food:dict): self.target_food = target_food return True + + def __getstate__(self): + state = self.__dict__.copy() + state['history'] = [] # strip history — grows per turn, not needed for moves + state.pop('game_board', None) # re-set at top of every choose_move; circular ref + state.pop('calculations', None) # re-initialised at top of every choose_move + state.pop('eat_the_snake_overwrite', None) # re-initialised at top of every choose_move + state.pop('kill_the_snake', None) # per-call transient + return state diff --git a/snakes/UltimateBattleSnake.py b/snakes/UltimateBattleSnake.py index 5d592e8..a174f3b 100644 --- a/snakes/UltimateBattleSnake.py +++ b/snakes/UltimateBattleSnake.py @@ -100,6 +100,17 @@ class UltimateBattleSnake(TemplateSnake): # RL bootstrap dataset recorder self.rl_bootstrap = RLBootstrapDataset() + def __getstate__(self): + state = super().__getstate__() + # strip per-turn precomputed state — all re-assigned at the top of choose_move + state['_enemy_dmaps'] = [] + state['_enemy_heads'] = [] + state['_base_blocked'] = set() + state['_is_snail'] = False + state['_bfs_cache'] = {} + state['_bfs_cache_turn'] = -1 + return state + # ── Env helpers ────────────────────────────────────────────────────────────── def _get_timeout_buffer_ms(self) -> int: