diff --git a/.github/workflows/test_pytest.yml b/.github/workflows/test_pytest.yml index 651ce0cc7..a990cbfd2 100644 --- a/.github/workflows/test_pytest.yml +++ b/.github/workflows/test_pytest.yml @@ -55,6 +55,9 @@ jobs: - name: Run Pelita CLI as a script run: | pelita --null --rounds 100 --size small + - name: Run Pelita CLI without timeouts + run: | + pelita --null --rounds 100 --size small --no-timeout - name: Test Pelita template repo run: | # We must clone pelita_template to a location outside of the pelita repo diff --git a/contrib/ci_engine.py b/contrib/ci_engine.py index 5a2552604..bbbac8e4c 100755 --- a/contrib/ci_engine.py +++ b/contrib/ci_engine.py @@ -55,7 +55,7 @@ from rich.console import Console from rich.table import Table -from pelita.network import ZMQClientError +from pelita.network import RemotePlayerFailure from pelita.scripts.script_utils import start_logging from pelita.tournament import call_pelita, check_team @@ -126,7 +126,7 @@ def load_players(self): _logger.debug('Querying team name for %s.' % pname) team_name = check_team(player['path']) self.dbwrapper.add_team_name(pname, team_name) - except ZMQClientError as e: + except RemotePlayerFailure as e: e_type, e_msg = e.args _logger.debug(f'Could not import {player} at path {path} ({e_type}): {e_msg}') player['error'] = e.args diff --git a/demo/benchmark_game.py b/demo/benchmark_game.py index cdc6dedc9..6d4008505 100644 --- a/demo/benchmark_game.py +++ b/demo/benchmark_game.py @@ -34,7 +34,7 @@ layout = parse_layout(LAYOUT) def run(teams, max_rounds): - return run_game(teams, max_rounds=max_rounds, layout_dict=layout, print_result=False, allow_exceptions=True, store_output=subprocess.DEVNULL) + return run_game(teams, max_rounds=max_rounds, layout_dict=layout, print_result=False, raise_bot_exceptions=True, store_output=subprocess.DEVNULL) def parse_args(): parser = argparse.ArgumentParser(description='Benchmark pelita run_game') diff --git a/pelita/base_utils.py b/pelita/base_utils.py index d031024b0..a0629f0ca 100644 --- a/pelita/base_utils.py +++ b/pelita/base_utils.py @@ -1,5 +1,6 @@ from random import Random +import zmq def default_rng(seed=None): """Construct a new RNG from a given seed or return the same RNG. @@ -12,3 +13,8 @@ def default_rng(seed=None): if isinstance(seed, Random): return seed return Random(seed) + +def default_zmq_context(zmq_context=None): + if zmq_context is None: + return zmq.Context() + return zmq_context diff --git a/pelita/exceptions.py b/pelita/exceptions.py index 16cac0b67..8583c5805 100644 --- a/pelita/exceptions.py +++ b/pelita/exceptions.py @@ -1,19 +1,16 @@ -class FatalException(Exception): # TODO rename to FatalGameException etc - pass - -class NonFatalException(Exception): +class NoFoodWarning(Warning): + """ Warns when a layout has no food during setup. """ pass -class PlayerTimeout(NonFatalException): +class GameOverError(Exception): + """ raised from game when match is game over """ pass -class PlayerDisconnected(FatalException): - # unsure, if PlayerDisconnected should be fatal in the sense of that the team loses - # it could simply be a network error for both teams - # and it would be random who will be punished +class PelitaBotError(Exception): + """ Raised when raise_bot_exceptions is turned on """ pass -class NoFoodWarning(Warning): - """ Warns when a layout has no food during setup. """ +class PelitaIllegalGameState(Exception): + """ Raised when there is something wrong with the game state """ pass diff --git a/pelita/game.py b/pelita/game.py index 4402a09c0..c578235e8 100644 --- a/pelita/game.py +++ b/pelita/game.py @@ -8,14 +8,15 @@ import time from warnings import warn +import zmq + from . import layout from .base_utils import default_rng -from .exceptions import (FatalException, NoFoodWarning, NonFatalException, - PlayerTimeout) +from .exceptions import NoFoodWarning, PelitaBotError, PelitaIllegalGameState from .gamestate_filters import noiser, relocate_expired_food, update_food_age, in_homezone from .layout import get_legal_positions, initial_positions -from .network import ZMQPublisher, setup_controller -from .team import make_team +from .network import Controller, RemotePlayerFailure, RemotePlayerRecvTimeout, RemotePlayerSendError, ZMQPublisher +from .team import RemoteTeam, make_team from .viewer import (AsciiViewer, ProgressViewer, ReplayWriter, ReplyToViewer, ResultPrinter) @@ -24,7 +25,6 @@ ### Global constants -# All constants that are currently not redefinable in setup_game #: The points a team gets for killing another bot KILL_POINTS = 5 @@ -41,6 +41,10 @@ #: Food pellet shadow distance SHADOW_DISTANCE = 2 +#: Timeout to be chosen when run with --no-timeout +# (Players are expected to exit after 60 minutes without contact from pelita main) +MAX_TIMEOUT_SECS = 60 * 60 + #: Default maze sizes MSIZE = { 'small' : (16, 8), @@ -92,7 +96,7 @@ def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop p = subprocess.Popen(external_call, preexec_fn=os.setsid) return p -def controller_exit(state, await_action='play_step'): +def controller_await(state, await_action='play_step'): """Wait for the controller to receive a action from a viewer action can be 'exit' (return True), 'play_setup', 'set_initial' (return True) @@ -109,7 +113,7 @@ def run_game(team_specs, *, layout_dict, max_rounds=300, rng=None, allow_camping=False, error_limit=5, timeout_length=3, viewers=None, store_output=False, team_names=(None, None), team_infos=(None, None), - allow_exceptions=False, print_result=True): + raise_bot_exceptions=False, print_result=True): """ Run a pelita match. Parameters @@ -167,11 +171,11 @@ def run_game(team_specs, *, layout_dict, max_rounds=300, team_info : tuple(team_info_0, team_info_1) a tuple containing additional team info. - allow_exceptions : bool + raise_bot_exceptions : bool when True, allow teams to raise Exceptions. This is especially useful when running local games, where you typically want to see Exceptions do debug them. When running remote games, - allow_exceptions should be False, so that the game can collect + raise_bot_exceptions should be False, so that the game can collect the exceptions and cleanly create a game-over state if needed. @@ -214,16 +218,16 @@ def run_game(team_specs, *, layout_dict, max_rounds=300, print_result=print_result) # Play the game until it is gameover. - while not state.get('gameover'): + while state['game_phase'] == 'RUNNING': # this is only needed if we have a controller, for example a viewer # this function call *blocks* until the viewer has replied - if controller_exit(state): + if controller_await(state): # if the controller asks us, we'll exit and stop playing break # play the next turn - state = play_turn(state, allow_exceptions=allow_exceptions) + state = play_turn(state, raise_bot_exceptions=raise_bot_exceptions) return state @@ -253,12 +257,14 @@ def setup_viewers(viewers, print_result=True): elif viewer == 'write-replay-to': viewer_state['viewers'].append(ReplayWriter(open(viewer_opts, 'w'))) elif viewer == 'publish-to': - zmq_external_publisher = ZMQPublisher(address=viewer_opts, bind=False) + zmq_context = zmq.Context() + zmq_external_publisher = ZMQPublisher(address=viewer_opts, bind=False, zmq_context=zmq_context) viewer_state['viewers'].append(zmq_external_publisher) elif viewer == 'tk': - zmq_publisher = ZMQPublisher(address='tcp://127.0.0.1') + zmq_context = zmq.Context() + zmq_publisher = ZMQPublisher(address='tcp://127.0.0.1', zmq_context=zmq_context) viewer_state['viewers'].append(zmq_publisher) - viewer_state['controller'] = setup_controller() + viewer_state['controller'] = Controller(zmq_context=zmq_context) _proc = TkViewer(address=zmq_publisher.socket_addr, controller=viewer_state['controller'].socket_addr, stop_after=viewer_opts.get('stop_at'), @@ -281,7 +287,7 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, allow_camping=False, error_limit=5, timeout_length=3, viewers=None, store_output=False, team_names=(None, None), team_infos=(None, None), - allow_exceptions=False, print_result=True): + raise_bot_exceptions=False, print_result=True): """ Generates a game state for the given teams and layout with otherwise default values. """ if viewers is None: viewers = [] @@ -340,6 +346,9 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, food_age=[{}, {}], ### Round/turn information + #: Phase + game_phase='INIT', + #: Current bot, int, None turn=None, @@ -362,8 +371,8 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, #: Fatal errors fatal_errors=[[], []], - #: Errors - errors=[{}, {}], + #: Number of timeouts for a team + timeouts=[{}, {}], ### Configuration #: Maximum number of rounds, int @@ -372,6 +381,9 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, #: Time till timeout, int timeout=3, + #: Initial timeout, int + initial_timeout=6, + #: Noise radius, int noise_radius=NOISE_RADIUS, @@ -438,10 +450,14 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, controller=viewer_state['controller'] ) + # Wait until the controller tells us that it is ready # We then can send the initial maze # This call *blocks* until the controller replies - if controller_exit(game_state, await_action='set_initial'): + if controller_await(game_state, await_action='set_initial'): + # controller_await has flagged exit + # We should return with an error + game_state['game_phase'] = 'FAILURE' return game_state # Send maze before team creation. @@ -449,25 +465,37 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, rng=None, # to answer to the server. update_viewers(game_state) - team_state = setup_teams(team_specs, game_state, store_output=store_output, allow_exceptions=allow_exceptions) + team_state = setup_teams(team_specs, game_state, store_output=store_output, raise_bot_exceptions=raise_bot_exceptions) game_state.update(team_state) - # Check if one of the teams has already generate a fatal error - # or if the game has finished (might happen if we set it up with max_rounds=0). - game_state.update(check_gameover(game_state, detect_final_move=True)) + # Check if the game has finished (might happen if we set it up with max_rounds=0). + game_state.update(check_gameover(game_state)) # Send updated game state with team names to the viewers update_viewers(game_state) - # exit remote teams in case we are game over - check_exit_remote_teams(game_state) + if game_state['game_phase'] == 'INIT': + # All good. + send_initial(game_state, raise_bot_exceptions=raise_bot_exceptions) + game_state.update(check_gameover(game_state)) + + # send_initial might have changed our game phase to FAILURE or FINISHED + if game_state['game_phase'] == 'INIT': + game_state['game_phase'] = 'RUNNING' + else: + # exit remote teams in case there was a failure or the game has finished + # In this case, we also want to update the viewers + update_viewers(game_state) + exit_remote_teams(game_state) return game_state -def setup_teams(team_specs, game_state, store_output=False, allow_exceptions=False): +def setup_teams(team_specs, game_state, store_output=False, raise_bot_exceptions=False): """ Creates the teams according to the `teams`. """ + assert game_state['game_phase'] == 'INIT' + # we start with a dummy zmq_context # make_team will generate and return a new zmq_context, # if it is needed for a remote team @@ -476,82 +504,171 @@ def setup_teams(team_specs, game_state, store_output=False, allow_exceptions=Fal teams = [] # First, create all teams # If a team is a RemoteTeam, this will start a subprocess - for idx, team_spec in enumerate(team_specs): - team, zmq_context = make_team(team_spec, idx=idx, zmq_context=zmq_context, store_output=store_output, team_name=game_state['team_names'][idx]) + for team_idx, team_spec in enumerate(team_specs): + team, zmq_context = make_team(team_spec, idx=team_idx, zmq_context=zmq_context, store_output=store_output, team_name=game_state['team_names'][team_idx]) teams.append(team) - # Send the initial state to the teams and await the team name (if the teams are local, the name can be get from the game_state directly - team_names = [] - for idx, team in enumerate(teams): - try: - team_name = team.set_initial(idx, prepare_bot_state(game_state, idx)) - except (FatalException, PlayerTimeout) as e: - # TODO: Not sure if PlayerTimeout should let the other payer win. - # It could simply be a network problem. - if allow_exceptions: - raise - exception_event = { - 'type': e.__class__.__name__, - 'description': str(e), - 'turn': idx, - 'round': None, - } - game_state['fatal_errors'][idx].append(exception_event) - if len(e.args) > 1: - game_print(idx, f"{type(e).__name__} ({e.args[0]}): {e.args[1]}") - team_name = f"%%%{e.args[0]}%%%" - else: - game_print(idx, f"{type(e).__name__}: {e}") - team_name = "%%%error%%%" - team_names.append(team_name) + # Await that the teams signal readiness and get the team name + initial_timeout = game_state['initial_timeout'] + start = time.monotonic() + + has_remote_teams = any(isinstance(team, RemoteTeam) for team in teams) + remote_sockets = {} + + if has_remote_teams: + poll = zmq.Poller() + for team_idx, team in enumerate(teams): + if isinstance(team, RemoteTeam): + poll.register(team.conn.socket, zmq.POLLIN) + remote_sockets[team.conn.socket] = team_idx + + break_error = False + while remote_sockets and not break_error: + timeout_left = int((initial_timeout - time.monotonic() + start) * 1000) + if timeout_left <= 0: + break + + # socket -> zmq.POLLIN id + evts = dict(poll.poll(timeout_left)) + for socket in evts: + team_idx = remote_sockets[socket] + team = teams[team_idx] + + try: + _state = team.wait_ready(timeout=0) + except (RemotePlayerSendError, RemotePlayerRecvTimeout, RemotePlayerFailure) as e: + if len(e.args) > 1: + game_print(team_idx, f"{type(e).__name__} ({e.args[0]}): {e.args[1]}") + else: + game_print(team_idx, f"{type(e).__name__}: {e}") + + add_fatal_error(game_state, round=None, turn=team_idx, type=e.__class__.__name__, msg=str(e), raise_bot_exceptions=raise_bot_exceptions) + break_error = True + + del remote_sockets[socket] + + # Handle timeouts + if not break_error and remote_sockets: + break_error = True + for socket, team_idx in remote_sockets.items(): + game_print(team_idx, f"Team '{teams[team_idx].team_spec}' did not start (timeout).") + add_fatal_error(game_state, round=None, turn=team_idx, type='Timeout', msg='Team did not start (timeout).', raise_bot_exceptions=raise_bot_exceptions) + + # if we encountered an error, the game_phase should have been set to FAILURE + + # Send the initial state to the teams + team_names = [team.team_name for team in teams] team_state = { 'teams': teams, - 'team_names': team_names + 'team_names': team_names, } return team_state +def send_initial(game_state, raise_bot_exceptions=False): + assert game_state["game_phase"] == "INIT" + + teams = game_state['teams'] + + for team_idx, team in enumerate(teams): + # NB: Iterating over the teams may set the game_phase to FAILURE + if game_state['game_phase'] == 'FAILURE': + break + + try: + _res = team.set_initial(team_idx, prepare_bot_state(game_state, team_idx)) + + except RemotePlayerFailure as e: + game_print(team_idx, f"{e.error_type}: {e.error_msg}") + add_fatal_error(game_state, round=None, turn=team_idx, type=e.error_type, msg=e.error_msg, raise_bot_exceptions=raise_bot_exceptions) + + except RemotePlayerSendError: + game_print(team_idx, "Send error: Remote team unavailable") + add_fatal_error(game_state, round=None, turn=team_idx, type='Send error', msg='Remote team unavailable', raise_bot_exceptions=raise_bot_exceptions) + + except RemotePlayerRecvTimeout: + game_print(team_idx, "timeout: Timeout in set initial") + add_fatal_error(game_state, round=None, turn=team_idx, type='timeout', msg='Timeout in set initial', raise_bot_exceptions=raise_bot_exceptions) + def request_new_position(game_state): - team = game_state['turn'] % 2 - move_fun = game_state['teams'][team] + round = game_state['round'] + turn = game_state['turn'] + team_idx = game_state['turn'] % 2 + _bot_turn = game_state['turn'] // 2 + team = game_state['teams'][team_idx] bot_state = prepare_bot_state(game_state) + try: + start_time = time.monotonic() + bot_reply = team.get_move(bot_state) + + except RemotePlayerFailure as e: + bot_reply = { + 'error': e.error_type, + 'error_msg': e.error_msg + } + + except RemotePlayerSendError: + bot_reply = { + 'error': 'Send error', + 'error_msg': 'Remote team unavailable' + } - start_time = time.monotonic() + except RemotePlayerRecvTimeout: + if game_state['error_limit'] != 0 and len(game_state['timeouts'][team_idx]) + 1 >= game_state['error_limit']: + # We had too many timeouts already. Trigger a fatal_error. + # If error_limit is 0, the game will go on. + bot_reply = { + 'error': 'Timeout error', + 'error_msg': 'Too many timeouts' + } + else: + # There was a timeout. Execute a random move + legal_positions = get_legal_positions(game_state["walls"], game_state["shape"], + game_state["bots"][game_state["turn"]]) + req_position = game_state['rng'].choice(legal_positions) + game_print(turn, f"Player timeout. Setting a legal position at random: {req_position}") + + bot_reply = { + 'move': req_position + } + timeout_event = { + 'type': 'timeout', + 'description': f"Player timeout. Setting a legal position at random: {req_position}" + } + game_state['timeouts'][team_idx][(round, turn)] = timeout_event - new_position = move_fun.get_move(bot_state) duration = time.monotonic() - start_time # update the team_time - game_state['team_time'][team] += duration + game_state['team_time'][team_idx] += duration - return new_position + return bot_reply -def prepare_bot_state(game_state, idx=None): +def prepare_bot_state(game_state, team_idx=None): """ Prepares the bot’s game state for the current bot. """ - - bot_initialization = game_state.get('turn') is None and idx is not None - bot_finalization = game_state.get('turn') is not None and idx is not None - - if bot_initialization: + if game_state['game_phase'] == 'INIT': # We assume that we are in get_initial phase - turn = idx + turn = team_idx bot_turn = None seed = game_state['rng'].randint(0, sys.maxsize) - elif bot_finalization: + elif game_state['game_phase'] == 'FINISHED': # Called for remote players in _exit - turn = idx + turn = team_idx bot_turn = None seed = None - else: + elif game_state['game_phase'] == 'RUNNING': turn = game_state['turn'] bot_turn = game_state['turn'] // 2 seed = None + else: + _logger.warning("Got bad game_state in prepare_bot_state") + return bot_position = game_state['bots'][turn] own_team = turn % 2 @@ -585,7 +702,7 @@ def prepare_bot_state(game_state, idx=None): 'kills': game_state['kills'][own_team::2], 'deaths': game_state['deaths'][own_team::2], 'bot_was_killed': game_state['bot_was_killed'][own_team::2], - 'error_count': len(game_state['errors'][own_team]), + 'error_count': len(game_state['timeouts'][own_team]), 'food': list(game_state['food'][own_team]), 'shaded_food': shaded_food, 'name': game_state['team_names'][own_team], @@ -616,7 +733,7 @@ def prepare_bot_state(game_state, idx=None): 'max_rounds': game_state['max_rounds'], } - if bot_initialization: + if game_state['game_phase'] == 'INIT': bot_state.update({ 'walls': game_state['walls'], # only in initial round 'shape': game_state['shape'], # only in initial round @@ -655,7 +772,7 @@ def prepare_viewer_state(game_state): viewer_state['food_age'] = [item for team_food_age in viewer_state['food_age'] for item in team_food_age.items()] - # game_state["errors"] has a tuple as a dict key + # game_state["timeouts"] has a tuple as a dict key # that cannot be serialized in json. # To fix this problem, we only send the current error # and add another attribute "num_errors" @@ -663,16 +780,16 @@ def prepare_viewer_state(game_state): # the key for the current round, turn round_turn = (game_state["round"], game_state["turn"]) - viewer_state["errors"] = [ + viewer_state["timeouts"] = [ # retrieve the current error or None team_errors.get(round_turn) - for team_errors in game_state["errors"] + for team_errors in game_state["timeouts"] ] # add the number of errors viewer_state["num_errors"] = [ len(team_errors) - for team_errors in game_state["errors"] + for team_errors in game_state["timeouts"] ] # remove unserializable values @@ -683,8 +800,7 @@ def prepare_viewer_state(game_state): return viewer_state - -def play_turn(game_state, allow_exceptions=False): +def play_turn(game_state, raise_bot_exceptions=False): """ Plays the next turn of the game. This function increases the round and turn counters, requests a move @@ -693,12 +809,12 @@ def play_turn(game_state, allow_exceptions=False): Raises ------ ValueError - If gamestate['gameover'] is True + If game_state["game_phase"] != "RUNNING": """ # TODO: Return a copy of the game_state # if the game is already over, we return a value error - if game_state['gameover']: + if game_state["game_phase"] != "RUNNING": raise ValueError("Game is already over!") # Now update the round counter @@ -712,47 +828,25 @@ def play_turn(game_state, allow_exceptions=False): game_state.update(update_food_age(game_state, team, SHADOW_DISTANCE)) game_state.update(relocate_expired_food(game_state, team, SHADOW_DISTANCE)) - # request a new move from the current team - try: - position_dict = request_new_position(game_state) - if "error" in position_dict: - error_type, error_string = position_dict['error'] - raise FatalException(f"Exception in client ({error_type}): {error_string}") - try: - position = tuple(position_dict['move']) - except TypeError as e: - raise NonFatalException(f"Type error {e}") + position_dict = request_new_position(game_state) - if position_dict.get('say'): - game_state['say'][game_state['turn']] = position_dict['say'] - else: - game_state['say'][game_state['turn']] = "" - except FatalException as e: - if allow_exceptions: - raise - # FatalExceptions (such as PlayerDisconnect) should immediately - # finish the game - exception_event = { - 'type': e.__class__.__name__, - 'description': str(e), - 'turn': game_state['turn'], - 'round': game_state['round'], - } - game_state['fatal_errors'][team].append(exception_event) - position = None - game_print(turn, f"{type(e).__name__}: {e}") - except NonFatalException as e: - if allow_exceptions: - raise - # NonFatalExceptions (such as Timeouts and ValueErrors in the JSON handling) - # are collected and added to team_errors - exception_event = { - 'type': e.__class__.__name__, - 'description': str(e) - } - game_state['errors'][team][(round, turn)] = exception_event + if "error" in position_dict: + error_type = position_dict['error'] + error_string = position_dict.get('error_msg', '') + + game_print(turn, f"{error_type}: {error_string}") + add_fatal_error(game_state, round=game_state['round'], turn=game_state['turn'], + type=error_type, msg=error_string, + raise_bot_exceptions=raise_bot_exceptions) position = None - game_print(turn, f"{type(e).__name__}: {e}") + + else: + position = position_dict['move'] + + if position_dict.get('say'): + game_state['say'][game_state['turn']] = position_dict['say'] + else: + game_state['say'][game_state['turn']] = "" # If the returned move looks okay, we add it to the list of requested moves old_position = game_state['bots'][turn] @@ -762,29 +856,21 @@ def play_turn(game_state, allow_exceptions=False): 'success': False # Success is set to true after apply_move } - # Check if a team has exceeded their maximum number of errors - # (we do not want to apply the move in this case) - # Note: Since we already updated the move counter, we do not check anymore, - # if the game has exceeded its rounds. - game_state.update(check_gameover(game_state)) - - if not game_state['gameover']: + if game_state["game_phase"] == "RUNNING": # ok. we can apply the move for this team # try to execute the move and return the new state game_state = apply_move(game_state, position) # If there was no error, we claim a success in requested_moves - if (round, turn) not in game_state["errors"][team] and not game_state['fatal_errors'][team]: + if (round, turn) not in game_state['timeouts'][team] and not game_state['fatal_errors'][team]: game_state['requested_moves'][turn]['success'] = True - # Check again, if we had errors or if this was the last move of the game (final round or food eaten) - game_state.update(check_gameover(game_state, detect_final_move=True)) - # Send updated game state with team names to the viewers update_viewers(game_state) # exit remote teams in case we are game over - check_exit_remote_teams(game_state) + if game_state["game_phase"] != "RUNNING": + exit_remote_teams(game_state) return game_state @@ -862,7 +948,9 @@ def apply_move(gamestate, bot_position): state of the game after applying current turn """ - # TODO is a timeout counted as an error? + # TODO: gamestate should be immutable + assert gamestate["game_phase"] == "RUNNING" + # define local variables bots = gamestate["bots"] turn = gamestate["turn"] @@ -880,39 +968,20 @@ def apply_move(gamestate, bot_position): # reset our own bot_was_killed flag bot_was_killed[turn] = False - # previous errors - team_errors = gamestate["errors"][team] - # the allowed moves for the current bot legal_positions = get_legal_positions(walls, shape, gamestate["bots"][gamestate["turn"]]) - # unless we have already made an error, check if we made a legal move - if (n_round, turn) not in team_errors: - if bot_position not in legal_positions: - previous_position = gamestate["bots"][gamestate["turn"]] - game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:" - f" {sorted(legal_positions)}.") - exception_event = { - 'type': 'IllegalPosition', - 'description': f"bot{turn}: {previous_position}➔{bot_position}", - 'turn': turn, - 'round': n_round, - } - gamestate['fatal_errors'][team].append(exception_event) + # check if we made a legal move + if bot_position not in legal_positions: + previous_position = gamestate["bots"][gamestate["turn"]] + game_print(turn, f"Illegal position. {previous_position}➔{bot_position} not in legal positions:" + f" {sorted(legal_positions)}.") + add_fatal_error(gamestate, round=n_round, turn=turn, type='IllegalPosition', msg=f"bot{turn}: {previous_position}➔{bot_position}") # only execute move if errors not exceeded - gamestate.update(check_gameover(gamestate)) - if gamestate['gameover']: + if not gamestate['game_phase'] == "RUNNING": return gamestate - # Now check if we must make a random move - if (n_round, turn) in team_errors: - # There was an error for this round and turn - # but the game is not over. - # We execute a random move - bot_position = gamestate['rng'].choice(legal_positions) - game_print(turn, f"Setting a legal position at random: {bot_position}") - # take step bots[turn] = bot_position _logger.info(f"Bot {turn} moves to {bot_position}.") @@ -933,8 +1002,6 @@ def apply_move(gamestate, bot_position): # we check if we killed or have been killed and update the gamestate accordingly gamestate.update(apply_bot_kills(gamestate)) - errors = gamestate["errors"] - errors[team] = team_errors gamestate_new = { "food": food, "bots": bots, @@ -942,10 +1009,14 @@ def apply_move(gamestate, bot_position): "deaths": deaths, "kills": kills, "bot_was_killed": bot_was_killed, - "errors": errors, - } + "game_phase": "RUNNING", + } gamestate.update(gamestate_new) + + # Check if this was the last move of the game (final round or food eaten) + gamestate.update(check_gameover(gamestate)) + return gamestate @@ -964,6 +1035,8 @@ def next_round_turn(game_state): If gamestate['gameover'] is True """ + # TODO: This should take a whole game_phase + if game_state['gameover']: raise ValueError("Game is already over") turn = game_state['turn'] @@ -972,6 +1045,8 @@ def next_round_turn(game_state): if turn is None and round is None: turn = 0 round = 1 + elif turn is None or round is None: + raise PelitaIllegalGameState("Bad configuration for turn and round") else: # if one of turn or round is None bot not both, it is illegal. # TODO: fail with a better error message @@ -985,88 +1060,138 @@ def next_round_turn(game_state): 'turn': turn, } +def add_fatal_error(game_state, *, round, turn, type, msg, raise_bot_exceptions=False): + team_idx = turn % 2 + + exception_event = { + 'type': type, + 'description': msg, + 'turn': turn, + 'round': round, + } + game_state['fatal_errors'][team_idx].append(exception_event) + + if game_state['game_phase'] == 'INIT': + num_fatal_errors = [len(f) for f in game_state['fatal_errors']] + if num_fatal_errors[0] > 0 or num_fatal_errors[1] > 0: + game_state.update({ + 'whowins' : -1, + 'gameover' : True, + 'game_phase': 'FAILURE' + }) + + if game_state['game_phase'] == 'RUNNING': + # If any team has a fatal error, this team loses. + # If both teams have a fatal error, it’s a draw. + num_fatal_errors = [len(f) for f in game_state['fatal_errors']] + if num_fatal_errors[0] == 0 and num_fatal_errors[1] == 0: + # no one has any fatal errors + pass + elif num_fatal_errors[0] > 0 and num_fatal_errors[1] > 0: + # both teams have fatal errors: it is a draw + game_state.update({ + 'whowins' : 2, + 'gameover' : True, + 'game_phase': 'FINISHED' + }) + else: + # one team has fatal errors + for team in (0, 1): + if num_fatal_errors[team] > 0: + game_state.update({ + 'whowins' : 1 - team, + 'gameover' : True, + 'game_phase': 'FINISHED' + }) + + if raise_bot_exceptions: + exit_remote_teams(game_state) + raise PelitaBotError(type, msg) -def check_gameover(game_state, detect_final_move=False): + +def check_gameover(game_state): """ Checks if this was the final moves or if the errors have exceeded the threshold. Returns ------- - dict { 'gameover' , 'whowins' } + dict { 'gameover' , 'whowins', 'game_phase' } Flags if the game is over and who won it """ - - # If any team has a fatal error, this team loses. - # If both teams have a fatal error, it’s a draw. - num_fatals = [len(f) for f in game_state['fatal_errors']] - if num_fatals[0] == 0 and num_fatals[1] == 0: - # no one has any fatal errors - pass - elif num_fatals[0] > 0 and num_fatals[1] > 0: - # both teams have fatal errors: it is a draw - return { 'whowins' : 2, 'gameover' : True} - else: - # some one has fatal errors - for team in (0, 1): - if num_fatals[team] > 0: - return { 'whowins' : 1 - team, 'gameover' : True} - - # If any team has reached error_limit errors, this team loses. - # If both teams have reached error_limit errors, it’s a draw. - # If error_limit is 0, the game will go on without checking. - num_errors = [len(f) for f in game_state['errors']] - if game_state['error_limit'] == 0: - pass - elif num_errors[0] < game_state['error_limit'] and num_errors[1] < game_state['error_limit']: - # no one has reached the error limit - pass - elif num_errors[0] >= game_state['error_limit'] and num_errors[1] >= game_state['error_limit']: - # both teams have reached or exceeded the error limit - return { 'whowins' : 2, 'gameover' : True} - else: - # only one team has reached the error limit - for team in (0, 1): - if num_errors[team] >= game_state['error_limit']: - return { 'whowins' : 1 - team, 'gameover' : True} - - if detect_final_move: - # No team wins/loses because of errors? - # Good. Now check if the game finishes because the food is gone - # or because we are in the final turn of the last round. - - # will we overshoot the max rounds with the next step? + if game_state['game_phase'] == 'FAILURE': + return { + 'whowins' : -1, + 'gameover' : True, + 'game_phase': 'FAILURE' + } + if game_state['game_phase'] == 'FINISHED': + return { + 'whowins' : game_state['whowins'], + 'gameover' : game_state['gameover'], + 'game_phase': 'FINISHED' + } + if game_state['game_phase'] == 'INIT': next_step = next_round_turn(game_state) next_round = next_step['round'] - # count how much food is left for each team + # Fail if there is not enough food or rounds food_left = [len(f) for f in game_state['food']] if next_round > game_state['max_rounds'] or any(f == 0 for f in food_left): - if game_state['score'][0] > game_state['score'][1]: - whowins = 0 - elif game_state['score'][0] < game_state['score'][1]: - whowins = 1 - else: - whowins = 2 - return { 'whowins' : whowins, 'gameover' : True} + return { + 'whowins': -1, + 'gameover': True, + 'game_phase': 'FAILURE' + } + return { + 'whowins' : None, + 'gameover' : False, + 'game_phase': 'INIT' + } + + # We are in running phase. Check if the food is gone + # or if we are in the final turn of the last round. - return { 'whowins' : None, 'gameover' : False} + # will we overshoot the max rounds with the next step? + next_step = next_round_turn(game_state) + next_round = next_step['round'] + # count how much food is left for each team + food_left = [len(f) for f in game_state['food']] + if next_round > game_state['max_rounds'] or any(f == 0 for f in food_left): + if game_state['score'][0] > game_state['score'][1]: + whowins = 0 + elif game_state['score'][0] < game_state['score'][1]: + whowins = 1 + else: + whowins = 2 + return { + 'whowins' : whowins, + 'gameover' : True, + 'game_phase': 'FINISHED' + } + + return { + 'whowins' : None, + 'gameover' : False, + 'game_phase': 'RUNNING' + } -def check_exit_remote_teams(game_state): + +def exit_remote_teams(game_state): """ If the we are gameover, we want the remote teams to shut down. """ - if game_state['gameover']: - _logger.info("Gameover. Telling teams to exit.") - for idx, team in enumerate(game_state['teams']): - if len(game_state['fatal_errors'][idx]) > 0: - _logger.info(f"Not sending exit to team {idx} which had a fatal error.") - # We pretend we already send the exit message, otherwise - # the team’s __del__ method will do it once more. - team._sent_exit = True - continue - try: - team_game_state = prepare_bot_state(game_state, idx=idx) - team._exit(team_game_state) - except AttributeError: - pass + _logger.info("Telling teams to exit.") + for idx, team in enumerate(game_state['teams']): + if len(game_state['fatal_errors'][idx]) > 0: + _logger.info(f"Not sending exit to team {idx} which had a fatal error.") + # We pretend we already send the exit message, otherwise + # the team’s __del__ method will do it once more. + team._sent_exit = True + continue + try: + team_game_state = prepare_bot_state(game_state, team_idx=idx) + team.send_exit(team_game_state) + except AttributeError: + pass + def split_food(width, food): diff --git a/pelita/network.py b/pelita/network.py index dc00a616b..6dd28eae7 100644 --- a/pelita/network.py +++ b/pelita/network.py @@ -8,45 +8,39 @@ import zmq +from .base_utils import default_zmq_context + _logger = logging.getLogger(__name__) # 41736 is the word PELI(T)A when read upside down in reverse without glasses # The missing T stands for tcp PELITA_PORT = 41736 -## Pelita network data structures - -# ControlRequest -# {__action__} - -# ViewerUpdate -# {__action__, __data__} - -# Request -# {__uuid__, __action__, __data__} - -# Reply -# {__uuid__, __return__} -# Error -# {__uuid__, __error__, __error_msg__} - - -class ZMQUnreachablePeer(Exception): +class RemotePlayerSendError(Exception): """ Raised when ZMQ cannot send a message (connection may have been lost). """ -class ZMQReplyTimeout(Exception): +class RemotePlayerRecvTimeout(Exception): """ Is raised when an ZMQ socket does not answer in time. """ - -class ZMQClientError(Exception): +class RemotePlayerFailure(Exception): """ Used to propagate errors from the client. Raised when the zmq connection receives an __error__ message. """ - def __init__(self, message, error_type, *args): - self.message = message + def __init__(self, error_type, error_msg): self.error_type = error_type - super().__init__(message, error_type, *args) + self.error_msg = error_msg + super().__init__(error_type, error_msg) + + def __str__(self): + return f"{self.error_type}: {self.error_msg}" + +class RemotePlayerProtocolError(RemotePlayerFailure): + def __init__(self): + super().__init__("BadProtocol", "Bad protocol error") + +class BadState(Exception): + pass #: The timeout to use during sending @@ -98,7 +92,7 @@ def json_default_handler(o): raise TypeError("Cannot convert %r of type %s to json" % (o, type(o))) -class ZMQConnection: +class RemotePlayerConnection: """ This class is supposed to ease request–reply connections through a zmq socket. It does so by attaching a uuid to each request. It will only accept a reply if this also includes @@ -125,7 +119,8 @@ class ZMQConnection: pollout : zmq poller Poller for outgoing connections """ - def __init__(self, socket): + + def __init__(self, socket: zmq.Socket): self.socket = socket self.socket.setsockopt(zmq.LINGER, 0) @@ -137,37 +132,56 @@ def __init__(self, socket): self.pollout = zmq.Poller() self.pollout.register(socket, zmq.POLLOUT) - def send(self, action, data, timeout=None): + self.state = "WAIT" + + def _send(self, action, data, msg_id): """ Sends a message or request `action` - and attached data to the socket and returns the - message id that is needed to receive the reply. + and attached data to the socket. """ - if timeout is None: - timeout = DEAD_CONNECTION_TIMEOUT + timeout = DEAD_CONNECTION_TIMEOUT - msg_id = str(uuid.uuid4()) - _logger.debug("---> %r [%s]", action, msg_id) + if msg_id is not None: + message_obj = {"__uuid__": msg_id, "__action__": action, "__data__": data} + _logger.debug("---> %r [%s]", action, msg_id) + else: + message_obj = {"__action__": action, "__data__": data} + _logger.debug("---> %r", action) # Check before sending that the socket can receive socks = dict(self.pollout.poll(timeout * 1000)) - if socks.get(self.socket) == zmq.POLLOUT: + if self.socket in socks and socks[self.socket] == zmq.POLLOUT: # I think we need to set NOBLOCK here, else we may run into a # race condition if a connection was closed between poll and send. # NOBLOCK should raise, so we can catch that - message_obj = {"__uuid__": msg_id, "__action__": action, "__data__": data} json_message = json.dumps(message_obj, cls=SetEncoder) try: self.socket.send_unicode(json_message, flags=zmq.NOBLOCK) except zmq.ZMQError as e: - _logger.info("Could not send message. Assume socket is unavailable. %r", e) - raise ZMQUnreachablePeer() + _logger.info("Could not send message. Socket is unavailable. %r", e) + raise RemotePlayerSendError() else: - raise ZMQUnreachablePeer() + raise RemotePlayerSendError() return msg_id + def send_req(self, action, data): + """ Sends a message or request `action` + and attached data to the socket and returns the + message id that is needed to receive the reply. + """ + msg_id = str(uuid.uuid4()) + self._send(action=action, data=data, msg_id=msg_id) + return msg_id + + def send_exit(self, payload): + if self.state == "EXITING": + return + + self.state = "EXITING" + self.send_req("exit", payload) + def _recv(self): - """ Receive the next message on the socket. + """ Receive the next message on the socket. Will wait forever Returns ------- @@ -178,35 +192,87 @@ def _recv(self): ------ ZMQReplyTimeout if the message cannot be parsed from JSON - ZMQClientError + PelitaRemoteError if an error message is returned """ json_message = self.socket.recv_unicode() try: py_obj = json.loads(json_message) except ValueError: - _logger.warning('Received non-json message from self. Triggering a timeout.') - raise ZMQReplyTimeout() + _logger.warning('Received non-json message. Closing socket.') - try: + # TODO: Should we tell the remote end that we are exiting? + self.socket.close() + self.state = "CLOSED" + + raise RemotePlayerProtocolError + + if '__error__' in py_obj: error_type = py_obj['__error__'] error_message = py_obj.get('__error_msg__', '') _logger.warning(f'Received error reply ({error_type}): {error_message}. Closing socket.') self.socket.close() - raise ZMQClientError(error_message, error_type) - except KeyError: - pass - try: - msg_id = py_obj["__uuid__"] - except KeyError: - msg_id = None - _logger.warning('__uuid__ missing in message.') + self.state = "CLOSED" + + # Failure in the pelita code on client side + raise RemotePlayerFailure(error_type, error_message) + + if '__uuid__' in py_obj: + msg_id = py_obj['__uuid__'] + msg_return = py_obj.get("__return__") + _logger.debug("<--- %r [%s]", msg_return, msg_id) - msg_return = py_obj.get("__return__") + return msg_id, msg_return - _logger.debug("<--- %r [%s]", msg_return, msg_id) - return msg_id, msg_return + if '__status__' in py_obj: + msg_ack = py_obj['__status__'] # == 'ok' + msg_data = py_obj.get('__data__') + _logger.debug("<--- %r %r", msg_ack, msg_data) + + self.state = "CONNECTED" + + return None, msg_data + + _logger.warning('Received malformed json message. Closing socket.') + + # TODO: Should we tell the remote end that we are exiting? + self.socket.close() + self.state = "CLOSED" + + raise RemotePlayerProtocolError + + + def recv_status(self, timeout): + """ Receive the next message on the socket. + + Returns + ------- + status + The message status + + Raises + ------ + ZMQReplyTimeout + if the message cannot be parsed from JSON + PelitaRemoteError + if an error message is returned + + """ + if not self.state == "WAIT": + raise BadState + + status = self.recv_timeout(None, timeout) + + self.state = "CONNECTED" + + return status + + def recv_reply(self, expected_id, timeout): + + if not self.state == "CONNECTED": + raise BadState + return self.recv_timeout(expected_id, timeout) def recv_timeout(self, expected_id, timeout): """ Waits `timeout` seconds for a reply with msg_id `expected_id`. @@ -224,15 +290,8 @@ def recv_timeout(self, expected_id, timeout): ZMQConnectionError if an error message is returned """ - # special case for no timeout - # just loop until we receive the correct reply - if timeout is None: - while True: - msg_id, reply = self._recv() - if msg_id == expected_id: - return reply - - # normal timeout handling + if self.state == "CLOSED": + return time_now = time.monotonic() # calculate until when it may take @@ -241,7 +300,7 @@ def recv_timeout(self, expected_id, timeout): # can still be handled timeout_until = time_now + timeout - while time_now < timeout_until: + while time_now <= timeout_until: time_left = timeout_until - time_now socks = dict(self.pollin.poll(time_left * 1000)) # poll needs milliseconds @@ -262,10 +321,10 @@ def recv_timeout(self, expected_id, timeout): # answer did not arrive in time break - raise ZMQReplyTimeout() + raise RemotePlayerRecvTimeout() def __repr__(self): - return "ZMQConnection(%r)" % self.socket + return "RemotePlayerConnection(%r)" % self.socket class ZMQPublisher: """ Sets up a simple Publisher which sends all viewed events @@ -278,9 +337,9 @@ class ZMQPublisher: bind : bool Whether we are in bind or connect mode """ - def __init__(self, address, bind=True): + def __init__(self, address, bind=True, zmq_context=None): self.address = address - self.context = zmq.Context() + self.context = default_zmq_context(zmq_context) self.socket = self.context.socket(zmq.PUB) if bind: self.socket_addr = bind_socket(self.socket, self.address, '--publish') @@ -291,6 +350,7 @@ def __init__(self, address, bind=True): def _send(self, action, data): info = {'round': data['round'], 'turn': data['turn']} + # TODO: this should be game_phase if data['gameover']: info['gameover'] = True _logger.debug(f"--#> [{action}] %r", info) @@ -305,10 +365,8 @@ def show_state(self, game_state): class Controller: def __init__(self, address='tcp://127.0.0.1', zmq_context=None): self.address = address - if zmq_context: - self.context = zmq_context - else: - self.context = zmq.Context() + self.context = default_zmq_context(zmq_context) + # We use a ROUTER which we bind. # This means other DEALERs can connect and # each one can take over control. @@ -351,20 +409,3 @@ def await_action(self, await_action, timeout=None, accept_exit=True): if action in expected_actions: return action _logger.warning('Unexpected action %r. (Expected: %s) Ignoring.', action, ", ".join(expected_actions)) - continue - - - def recv_start(self, timeout=None): - """ Waits `timeout` seconds for start message. - - Returns `True`, when the message arrives, `False` when an exit - message arrives or a timeout occurs. - """ - - -def setup_controller(zmq_context=None): - if not zmq_context: - import zmq - zmq_context = zmq.Context() - controller = Controller(zmq_context=zmq_context) - return controller diff --git a/pelita/scripts/pelita_main.py b/pelita/scripts/pelita_main.py index 2ca4f6713..47cfd8df3 100755 --- a/pelita/scripts/pelita_main.py +++ b/pelita/scripts/pelita_main.py @@ -245,7 +245,7 @@ def long_help(s): timeout_opt = game_settings.add_mutually_exclusive_group() timeout_opt.add_argument('--timeout', type=float, metavar="SEC", dest='timeout_length', help='Time before timeout is triggered (default: 3 seconds).') -timeout_opt.add_argument('--no-timeout', const=None, action='store_const', +timeout_opt.add_argument('--no-timeout', const=pelita.game.MAX_TIMEOUT_SECS, action='store_const', dest='timeout_length', help='Run game without timeouts.') game_settings.add_argument('--error-limit', type=int, default=5, dest='error_limit', help='Error limit. Reaching this limit disqualifies a team (default: 5).') @@ -332,7 +332,7 @@ def main(): try: team_name = check_team(team_spec) print("NAME:", team_name) - except pelita.network.ZMQClientError as e: + except pelita.network.RemotePlayerFailure as e: if e.error_type == 'ModuleNotFoundError': #print(f"{e.message}") pass @@ -368,7 +368,7 @@ def main(): if args.replayfile: viewer_state = pelita.game.setup_viewers(viewers) - if pelita.game.controller_exit(viewer_state, await_action='set_initial'): + if pelita.game.controller_await(viewer_state, await_action='set_initial'): sys.exit(0) old_game = Path(args.replayfile).read_text().split("\x04") @@ -382,7 +382,7 @@ def main(): state['food'] = list(map(tuple, state['food'])) for viewer in viewer_state['viewers']: viewer.show_state(state) - if pelita.game.controller_exit(viewer_state): + if pelita.game.controller_await(viewer_state): break sys.exit(0) diff --git a/pelita/scripts/pelita_player.py b/pelita/scripts/pelita_player.py index da7f37b05..453714636 100755 --- a/pelita/scripts/pelita_player.py +++ b/pelita/scripts/pelita_player.py @@ -12,11 +12,14 @@ import zmq from ..network import json_default_handler -from ..team import make_team +from ..team import Team from .script_utils import start_logging _logger = logging.getLogger(__name__) +# Maximum time that a player will wait for a move request +# Note that this means that a player will exit when a game has been paused for one hour +TIMEOUT_SECS = 60 * 60 @contextlib.contextmanager def with_sys_path(dirname): @@ -38,11 +41,12 @@ def run_player(team_spec, address, team_name_override=False, silent_bots=False): the address of the remote team socket """ - - address = address.replace('*', 'localhost') # Connect to the given address context = zmq.Context() socket = context.socket(zmq.PAIR) + poller = zmq.Poller() + poller.register(socket, flags=zmq.POLLIN) + try: socket.connect(address) except zmq.ZMQError as e: @@ -50,39 +54,33 @@ def run_player(team_spec, address, team_name_override=False, silent_bots=False): try: team = load_team(team_spec) + _logger.info(f"Running player '{team_spec}' ({team.team_name})") + except Exception as e: # We could not load the team. - # Wait for the set_initial message from the server - # and reply with an error. - try: - json_message = socket.recv_unicode() - py_obj = json.loads(json_message) - uuid_ = py_obj["__uuid__"] - _action = py_obj["__action__"] - _data = py_obj["__data__"] - - socket.send_json({ - '__uuid__': uuid_, - '__error__': e.__class__.__name__, - '__error_msg__': f'Could not load {team_spec}: {e}' - }) - except zmq.ZMQError as e: - raise IOError('failed to connect the client to address %s: %s' - % (address, e)) - # TODO: Do not raise here but wait for zmq to return a sensible error message - # We need a way to distinguish between syntax errors in the client - # and general zmq disconnects - raise + # Send an error to the server - _logger.info(f"Running player '{team_spec}' ({team.team_name})") + error = { + '__error__': e.__class__.__name__, + '__error_msg__': f'Could not load {team_spec}: {e}', + } + + socket.send_json(error) + # TODO: Exit with a status code? + return False + + data = { + 'team_name': team.team_name, + } + socket.send_json({'__status__': 'ok', '__data__': data}) while True: - cont = player_handle_request(socket, team, team_name_override=team_name_override, silent_bots=silent_bots) + cont = player_handle_request(socket, poller, team, team_name_override=team_name_override, silent_bots=silent_bots) if not cont: return -def player_handle_request(socket, team, team_name_override=False, silent_bots=False): +def player_handle_request(socket, poller, team, team_name_override=False, silent_bots=False): """ Awaits a new request on `socket` and dispatches it to `team`. @@ -104,9 +102,22 @@ def player_handle_request(socket, team, team_name_override=False, silent_bots=Fa # answer from the player. try: - json_message = socket.recv_unicode() + socks = dict(poller.poll(timeout=TIMEOUT_SECS * 1000)) + if socks.get(socket) == zmq.POLLIN: + json_message = socket.recv_unicode() + else: + # TODO: Would be nice to tell Pelita main that we’re exiting + _logger.warning(f"No request in {TIMEOUT_SECS} seconds. Exiting player.") + + # returning False breaks the loop + return False + except Exception: + # Exit without sending a value back on the socket + return True + + try: py_obj = json.loads(json_message) - msg_id = py_obj["__uuid__"] + msg_id = py_obj.get("__uuid__") # if an uuid is given, we must reply with a value action = py_obj["__action__"] data = py_obj["__data__"] _logger.debug(" %r [%s]", message_obj['__error__'], msg_id) - else: - _logger.debug("o--> %r [%s]", message_obj['__return__'], msg_id) + if msg_id is not None: + # we use our own json_default_handler + # to automatically convert numpy ints to json + json_message = json.dumps(message_obj, default=json_default_handler) + # return the message + socket.send_unicode(json_message) + if '__error__' in message_obj: + _logger.warning("o-!> %r [%s]", message_obj['__error__'], msg_id) + else: + _logger.debug("o--> %r [%s]", message_obj['__return__'], msg_id) def check_team_name(name): @@ -265,13 +275,13 @@ def load_team_from_module(path: str): FileNotFoundError if the parent folder cannot be found """ - path = Path(path) + module_path = Path(path) - if not path.parent.exists(): - raise FileNotFoundError("Folder {} does not exist.".format(path.parent)) + if not module_path.parent.exists(): + raise FileNotFoundError("Folder {} does not exist.".format(module_path.parent)) - dirname = str(path.parent) - modname = path.stem + dirname = str(module_path.parent) + modname = module_path.stem if modname in sys.modules: raise ValueError("A module named ‘{}’ has already been imported.".format(modname)) @@ -285,15 +295,25 @@ def load_team_from_module(path: str): def team_from_module(module): """ Looks for a move function and a team name in `module` and returns a team. + + Raises + ------ + TypeError + move not a function or TEAM_NAME not a string + AttributeError + no move or no TEAM_NAME attribute + """ # look for a new-style team move = module.move name = module.TEAM_NAME + if not callable(move): raise TypeError("move is not a function") if not isinstance(name, str): raise TypeError("TEAM_NAME is not a string") - team, _ = make_team(move, team_name=name) + + team = Team(move, team_name=name) return team diff --git a/pelita/scripts/pelita_server.py b/pelita/scripts/pelita_server.py index 09de10c7f..3db613a1a 100755 --- a/pelita/scripts/pelita_server.py +++ b/pelita/scripts/pelita_server.py @@ -258,14 +258,17 @@ def handle_new_connection(self, dealer_id, message, progress): if msg_obj.get('TEAM') == 'ADD': team_info = load_team_info(team_spec) + if not team_info: + return self.team_infos.append(team_info) - info = zeroconf_register(self.zc, self.advertise, self.port, team.spec, team.server_path, print=progress.console.print) + # TODO broken + info = zeroconf_register(self.zc, self.advertise, self.port, team_info.spec, team_info.server_path, print=progress.console.print) if info: - self.team_serviceinfo_mapping[(team.spec, team.server_path)] = info + self.team_serviceinfo_mapping[(team_info.spec, team_info.server_path)] = info if msg_obj.get('TEAM') == 'REMOVE': # TODO: cannot remove from self.team_infos yet - info = self.team_serviceinfo_mapping[(team.spec, team.server_path)] + info = self.team_serviceinfo_mapping[(team_info.spec, team_info.server_path)] zeroconf_deregister(self.zc, info) elif "SCAN" in msg_obj: @@ -334,7 +337,8 @@ def handle_new_connection(self, dealer_id, message, progress): # Send a reply to the requester (that the process has started) # Otherwise they might already start querying for the team name - self.router_sock.send_multipart([dealer_id, b"OK"]) + ## TODO: Still needed? + # self.router_sock.send_multipart([dealer_id, b"OK"]) else: _logger.info("Unknown incoming DEALER and not a request.") diff --git a/pelita/team.py b/pelita/team.py index fa21356e6..33615b307 100644 --- a/pelita/team.py +++ b/pelita/team.py @@ -7,16 +7,16 @@ from io import StringIO from pathlib import Path from random import Random +import typing from urllib.parse import urlparse import networkx as nx import zmq from . import layout -from .exceptions import PlayerDisconnected, PlayerTimeout +from .base_utils import default_zmq_context from .layout import BOT_I2N, layout_as_str, wall_dimensions -from .network import (PELITA_PORT, ZMQClientError, ZMQConnection, - ZMQReplyTimeout, ZMQUnreachablePeer) +from .network import PELITA_PORT, RemotePlayerConnection, RemotePlayerRecvTimeout, RemotePlayerSendError _logger = logging.getLogger(__name__) @@ -142,8 +142,18 @@ class Team: the team’s move function team_name : the name of the team (optional) + + Raises + ------ + TypeError : Move is not a function or team_name is not a string """ - def __init__(self, team_move, *, team_name=""): + def __init__(self, team_move: typing.Callable[[typing.Any, typing.Any], typing.Tuple[int, int]], *, team_name=""): + if not callable(team_move): + raise TypeError("move is not a function") + + if not isinstance(team_name, str): + raise TypeError("TEAM_NAME is not a string") + self._team_move = team_move self.team_name = team_name @@ -167,12 +177,6 @@ def set_initial(self, team_id, game_state): The id of the team game_state : dict The initial game state - - Returns - ------- - Team name : string - The name of the team - """ # Reset the team state self._state.clear() @@ -201,8 +205,7 @@ def set_initial(self, team_id, game_state): # over self._graph = walls_to_graph(self._walls, shape=self._shape).copy(as_view=True) - return self.team_name - + # TODO: get_move could also take the main game state??? def get_move(self, game_state): """ Requests a move from the Player who controls the Bot with id `bot_id`. @@ -248,32 +251,68 @@ def get_move(self, game_state): mybot.track = self._bot_track[idx][:] + move = self.apply_move_fn(self._team_move, team[me._bot_turn], self._state) + if "error" not in move: + move["say"] = me._say + return move + + @staticmethod + def apply_move_fn(move_fn, bot: "Bot", state): try: # request a move from the current bot - move = self._team_move(team[me._bot_turn], self._state) - - # check that the returned value is a position tuple - try: - if len(move) != 2: - raise ValueError(f"Function move did not return a valid position: got {move} instead.") - except TypeError: - # Convert to ValueError - raise ValueError(f"Function move did not return a valid position: got {move} instead.") from None + move = move_fn(bot, state) except Exception as e: # Our client had an exception. We print a traceback and # return the type of the exception to the server. # If this is a remote player, then this will be detected in pelita_player # and pelita_player will close the connection automatically. - traceback.print_exc() + + # Stacktrace is not needed, when we raise the ValueError above! + # from rich.console import Console + # console = Console() + + # if bot.is_blue: + # console.print(f"Team [blue]{bot.team_name}[/] caused an exception:") + # else: + # console.print(f"Team [red]{bot.team_name}[/] caused an exception:") + + # console.print_exception(show_locals=True) #, suppress=["pelita"]) + + try: + import _colorize + colorize = _colorize.can_colorize() + # This probably only works from Python 3.13 onwards + traceback.print_exception(sys.exception(), limit=None, file=None, chain=True, colorize=colorize) + except (ImportError, AttributeError): + traceback.print_exc() + return { - "error": (type(e).__name__, str(e)), + "error": type(e).__name__, + "error_msg": str(e), } - return { - "move": move, - "say": me._say + # check that the returned value is a position tuple + try: + if len(move) == 2: + return { "move": move } + + except TypeError: + pass + + error = { + "error": "ValueError", + "error_msg": f"Function move did not return a valid position: got '{move}' instead." } + from rich.console import Console + console = Console() + console.print(f"[b][red]{error['error']}[/red][/b]: {error['error_msg']}") + + # If move cannot take len, we get a type error; convert it to a ValueError + return error + + + def _exit(self, game_state=None): """ Dummy function. Only needed for `RemoteTeam`. """ pass @@ -281,224 +320,219 @@ def _exit(self, game_state=None): def __repr__(self): return f'Team({self._team_move!r}, {self.team_name!r})' +## TODO: +# Team -> Team + +# Split class RemoteTeam in two: +# One class for handling connections to a server-run Pelita client +# One class that starts its own subprocess and owns it +# +# In the first case, a connection is started with a zmq message +# The game code (setup_teams) is then responsible for awaiting +# the success message +# +# In the second case, the remote client needs to send a success message +# after it has started. The game code (setup_teams) is responsible for awaiting this message +# +# With the Ok message that the process has started, the team name should be included. +# +# The set initial req–rep should be made separately. This ensures that set initial includes the team names +# and the team names do not need to be send during regular move requests. +# +# Rename set_initial -> start_game +# class RemoteTeam: - """ Start a child process with the given `team_spec` and handle - communication with it through a zmq.PAIR connection. + def __init__(self, team_spec, socket): + self.team_spec = team_spec + self._team_name = None - It also does some basic checks for correct return values and tries to - terminate the child process once it is not needed anymore. + #: Default timeout for a request, unless specified in the game_state + self.request_timeout = 3 - Parameters - ---------- - team_spec - The string to pass as a command line argument to pelita_player - or the address of a remote player - team_name - Overrides the team name - zmq_context - A zmq_context (if None, a new one will be created) - idx - The team index (currently only used to specify the team’s color) - store_output - If store_output is a string it will be interpreted as a path to a - directory where to store stdout and stderr for the client processes. - It helps in debugging issues with the clients. - In the special case of store_output==subprocess.DEVNULL, stdout of - the remote clients will be suppressed. - """ - def __init__(self, team_spec, *, team_name=None, zmq_context=None, idx=None, store_output=False): - if zmq_context is None: - zmq_context = zmq.Context() + self.conn = RemotePlayerConnection(socket) + + @property + def team_name(self): + return self._team_name - self._team_spec = team_spec - self._team_name = team_name + def wait_ready(self, timeout): + msg = self.conn.recv_status(timeout) + try: + self._team_name = msg['team_name'] + except TypeError: + raise RemotePlayerRecvTimeout("", "") from None - #: Default timeout for a request, unless specified in the game_state - self._request_timeout = 3 + def set_initial(self, team_id, game_state): + timeout_length = game_state['timeout_length'] - if team_spec.startswith('pelita://'): - # We connect to a remote player that is listening - # on the given team_spec address. - # We create a new DEALER socket and send a single - # REQUEST message to the remote address. - # The remote player will then create a new instance - # of a player and forward all of our zmq traffic - # to that player. - - # given a url pelita://hostname:port/path we extract hostname and port and - # convert it to tcp://hostname:port that we use for the zmq connection - parsed_url = urlparse(team_spec) - if parsed_url.port: - port = parsed_url.port - else: - port = PELITA_PORT - send_addr = f"tcp://{parsed_url.hostname}:{port}" - address = "tcp://*" - self.bound_to_address = address - - socket = zmq_context.socket(zmq.DEALER) - socket.setsockopt(zmq.LINGER, 0) - socket.connect(send_addr) - _logger.info("Connecting zmq.DEALER to remote player at {}.".format(send_addr)) - - socket.send_json({"REQUEST": team_spec}) - WAIT_TIMEOUT = 5000 - incoming = socket.poll(timeout=WAIT_TIMEOUT) - if incoming == zmq.POLLIN: - _ok = socket.recv() - else: - # Server did not respond - raise PlayerTimeout() - self.proc = None + msg_id = self.conn.send_req("set_initial", {"team_id": team_id, + "game_state": game_state}) + reply = self.conn.recv_reply(msg_id, timeout_length) + # reply should be None + + return reply + + def get_move(self, game_state): + timeout_length = game_state['timeout_length'] + + msg_id = self.conn.send_req("get_move", {"game_state": game_state}) + reply = self.conn.recv_reply(msg_id, timeout_length) + + if "error" in reply: + return reply + # make sure that the move is a tuple + try: + reply["move"] = tuple(reply.get("move")) + except TypeError as e: + # This should also exit the remote connection + reply = { + "error": type(e).__name__, + "error_msg": str(e), + } + return reply + def send_exit(self, game_state=None): + + if game_state: + payload = {'game_state': game_state} + else: + payload = {} + + try: + _logger.info("Sending exit to remote player %r.", self) + self.conn.send_exit(payload) + except RemotePlayerSendError: + _logger.info("Remote Player %r is already dead during exit. Ignoring.", self) + + def _teardown(self): + pass + + def __del__(self): + try: + self.send_exit() + self._teardown() + except AttributeError: + # in case we exit before self.proc or self.zmqconnection have been set + pass + + def __repr__(self): + team_name = f" ({self._team_name})" if self._team_name is not None else "" + return f"RemoteTeam<{self.team_spec}{team_name} on {self.bound_to_address}>" + +class SubprocessTeam(RemoteTeam): + def __init__(self, team_spec, *, zmq_context=None, idx=None, store_output=False): + zmq_context = default_zmq_context(zmq_context) + + # We bind to a local tcp port with a zmq PAIR socket + # and start a new subprocess of pelita_player.py + # that includes the address of that socket and the + # team_spec as command line arguments. + # The subprocess will then connect to this address + # and load the team. + + socket = zmq_context.socket(zmq.PAIR) + port = socket.bind_to_random_port('tcp://localhost') + self.bound_to_address = f"tcp://localhost:{port}" + if idx == 0: + color='blue' + elif idx == 1: + color='red' else: - # We bind to a local tcp port with a zmq PAIR socket - # and start a new subprocess of pelita_player.py - # that includes the address of that socket and the - # team_spec as command line arguments. - # The subprocess will then connect to this address - # and load the team. - - socket = zmq_context.socket(zmq.PAIR) - port = socket.bind_to_random_port('tcp://*') - self.bound_to_address = f"tcp://localhost:{port}" - if idx == 0: - color='blue' - elif idx == 1: - color='red' - else: - color='' - self.proc = self._call_pelita_player(team_spec, self.bound_to_address, - color=color, store_output=store_output) - - self.zmqconnection = ZMQConnection(socket) + color='' + self.proc, self.stdout_path, self.stderr_path = self._call_pelita_player(team_spec, self.bound_to_address, + color=color, store_output=store_output) + + super().__init__(team_spec, socket) def _call_pelita_player(self, team_spec, address, color='', store_output=False): - """ Starts another process with the same Python executable, - the same start script (pelitagame) and runs `team_spec` + """ Starts another process with the same Python executable and runs `team_spec` as a standalone client on URL `addr`. """ player = 'pelita.scripts.pelita_player' external_call = [sys.executable, - '-m', - player, - 'remote-game', - team_spec, - address] + '-m', + player, + 'remote-game', + team_spec, + address] _logger.debug("Executing: %r", external_call) if store_output == subprocess.DEVNULL: return (subprocess.Popen(external_call, stdout=store_output), None, None) elif store_output: store_path = Path(store_output) - stdout = (store_path / f"{color or team_spec}.out").open('w') - stderr = (store_path / f"{color or team_spec}.err").open('w') + stdout_path = (store_path / f"{color or team_spec}.out") + stderr_path = (store_path / f"{color or team_spec}.err") # We must run in unbuffered mode to enforce flushing of stdout/stderr, # otherwise we may lose some of what is printed - proc = subprocess.Popen(external_call, stdout=stdout, stderr=stderr, + proc = subprocess.Popen(external_call, stdout=stdout_path.open('w'), stderr=stderr_path.open('w'), env=dict(os.environ, PYTHONUNBUFFERED='x')) - return (proc, stdout, stderr) + return (proc, stdout_path, stderr_path) else: return (subprocess.Popen(external_call), None, None) - @property - def team_name(self): - if self._team_name is not None: - return self._team_name + def _teardown(self): + if self.proc: + self.proc.terminate() - try: - msg_id = self.zmqconnection.send("team_name", {}) - team_name = self.zmqconnection.recv_timeout(msg_id, self._request_timeout) - if team_name: - self._team_name = team_name - return team_name - except ZMQReplyTimeout: - _logger.info("Detected a timeout, returning a string nonetheless.") - return "%error%" - except ZMQUnreachablePeer: - _logger.info("Detected a DeadConnection, returning a string nonetheless.") - return "%error%" - def set_initial(self, team_id, game_state): - timeout_length = game_state['timeout_length'] - try: - msg_id = self.zmqconnection.send("set_initial", {"team_id": team_id, - "game_state": game_state}) - team_name = self.zmqconnection.recv_timeout(msg_id, timeout_length) - if team_name: - self._team_name = team_name - return team_name - except ZMQReplyTimeout: - # answer did not arrive in time - raise PlayerTimeout() - except ZMQUnreachablePeer: - _logger.info("Could not properly send the message. Maybe just a slow client. Ignoring in set_initial.") - except ZMQClientError as e: - error_message = e.message - error_type = e.error_type - _logger.warning(f"Client connection failed ({error_type}): {error_message}") - raise PlayerDisconnected(*e.args) from None +class RemoteServerTeam(RemoteTeam): + """ Start a child process with the given `team_spec` and handle + communication with it through a zmq.PAIR connection. - def get_move(self, game_state): - timeout_length = game_state['timeout_length'] - try: - msg_id = self.zmqconnection.send("get_move", {"game_state": game_state}) - reply = self.zmqconnection.recv_timeout(msg_id, timeout_length) - # make sure it is a dict - reply = dict(reply) - if "error" in reply: - return reply - # make sure that the move is a tuple - reply["move"] = tuple(reply.get("move")) - return reply - except ZMQReplyTimeout: - # answer did not arrive in time - raise PlayerTimeout() - except TypeError: - # if we could not convert into a tuple or dict (e.g. bad reply) - return None - except ZMQUnreachablePeer: - # if the remote connection is closed - raise PlayerDisconnected() - except ZMQClientError: - raise + It also does some basic checks for correct return values and tries to + terminate the child process once it is not needed anymore. - def _exit(self, game_state=None): - # We only want to exit once. - if getattr(self, '_sent_exit', False): - return + Parameters + ---------- + team_spec + The string to pass as a command line argument to pelita_player + or the address of a remote player + team_name + Overrides the team name + zmq_context + A zmq_context (if None, a new one will be created) + idx + The team index (currently only used to specify the team’s color) + store_output + If store_output is a string it will be interpreted as a path to a + directory where to store stdout and stderr for the client processes. + It helps in debugging issues with the clients. + In the special case of store_output==subprocess.DEVNULL, stdout of + the remote clients will be suppressed. + """ - if game_state: - payload = {'game_state': game_state} + def __init__(self, team_spec, *, team_name=None, zmq_context=None): + zmq_context = default_zmq_context(zmq_context) + + # We connect to a remote player that is listening + # on the given team_spec address. + # We create a new DEALER socket and send a single + # REQUEST message to the remote address. + # The remote player will then create a new instance + # of a player and forward all of our zmq traffic + # to that player. + + # given a url pelita://hostname:port/path we extract hostname and port and + # convert it to tcp://hostname:port that we use for the zmq connection + parsed_url = urlparse(team_spec) + if parsed_url.port: + port = parsed_url.port else: - payload = {} + port = PELITA_PORT + send_addr = f"tcp://{parsed_url.hostname}:{port}" + self.bound_to_address = send_addr - try: - # TODO: make zmqconnection stateful. set flag when already disconnected - # For now, we simply check the state of the socket so that we do not send - # over an already closed socket. - if self.zmqconnection.socket.closed: - return - # TODO: Include final state with exit message - self.zmqconnection.send("exit", payload, timeout=1) - self._sent_exit = True - except ZMQUnreachablePeer: - _logger.info("Remote Player %r is already dead during exit. Ignoring.", self) + socket = zmq_context.socket(zmq.DEALER) + socket.setsockopt(zmq.LINGER, 0) + socket.connect(send_addr) + _logger.info("Connecting zmq.DEALER to remote player at {}.".format(send_addr)) - def __del__(self): - try: - self._exit() - if self.proc: - self.proc[0].terminate() - except AttributeError: - # in case we exit before self.proc or self.zmqconnection have been set - pass + socket.send_json({"REQUEST": team_spec}) - def __repr__(self): - team_name = f" ({self._team_name})" if self._team_name else "" - return f"RemoteTeam<{self._team_spec}{team_name} on {self.bound_to_address}>" + super().__init__(team_spec, socket) def make_team(team_spec, team_name=None, zmq_context=None, idx=None, store_output=False): @@ -533,11 +567,14 @@ def make_team(team_spec, team_name=None, zmq_context=None, idx=None, store_outpu team_name = f'local-team ({team_spec.__name__})' team_player = Team(team_spec, team_name=team_name) elif isinstance(team_spec, str): - _logger.info("Making a remote team for %s", team_spec) # set up the zmq connections and build a RemoteTeam - if not zmq_context: - zmq_context = zmq.Context() - team_player = RemoteTeam(team_spec=team_spec, zmq_context=zmq_context, idx=idx, store_output=store_output) + zmq_context = default_zmq_context(zmq_context) + if team_spec.startswith('pelita://'): + _logger.info("Making a remote team for %s", team_spec) + team_player = RemoteServerTeam(team_spec=team_spec, zmq_context=zmq_context) + else: + _logger.info("Making a subprocess team for %s", team_spec) + team_player = SubprocessTeam(team_spec=team_spec, zmq_context=zmq_context, idx=idx, store_output=store_output) else: raise TypeError(f"Not possible to create team from {team_spec} (wrong type).") @@ -763,7 +800,7 @@ def __str__(self): return out.getvalue() def __repr__(self): - return f'' + return f'' # def __init__(self, *, bot_index, position, initial_position, walls, homezone, food, is_noisy, score, random, round, is_blue): diff --git a/pelita/tournament/__init__.py b/pelita/tournament/__init__.py index 740db2161..5f696b040 100644 --- a/pelita/tournament/__init__.py +++ b/pelita/tournament/__init__.py @@ -15,7 +15,7 @@ import yaml import zmq -from ..team import make_team +from ..team import make_team, RemoteTeam from . import knockout_mode, roundrobin _logger = logging.getLogger(__name__) @@ -31,8 +31,13 @@ def check_team(team_spec): + """ Instantiates a team from a team_spec and returns its name """ team, _zmq_context = make_team(team_spec) + match team: + case RemoteTeam(): + team.wait_ready(3) + return team.team_name diff --git a/pelita/ui/tk_canvas.py b/pelita/ui/tk_canvas.py index ba885533d..114e5565a 100644 --- a/pelita/ui/tk_canvas.py +++ b/pelita/ui/tk_canvas.py @@ -455,15 +455,19 @@ def update(self, game_state=None, redraw=False): for food_pos in eaten_food: del self.food_items[food_pos] - winning_team_idx = game_state.get("whowins") - if winning_team_idx is None: - self.draw_end_of_game(None) - elif winning_team_idx in (0, 1): - team_name = game_state["team_names"][winning_team_idx] - self.draw_game_over(team_name) - elif winning_team_idx == 2: - self.draw_game_draw() + if game_state.get("game_phase") == "FINISHED": + winning_team_idx = game_state.get("whowins") + if winning_team_idx in (0, 1): + team_name = game_state["team_names"][winning_team_idx] or "???" + self.draw_game_over(team_name) + elif winning_team_idx == 2: + self.draw_game_draw() + + elif game_state.get("game_phase") == "FAILURE": + self.draw_game_failure() + else: + self.draw_end_of_game(None) def draw_universe(self, game_state, redraw): self.draw_overlay(game_state.get('overlays', [])) @@ -820,8 +824,8 @@ def draw_title(self, game_state): center = self.ui_game_canvas.winfo_width() // 2 - left_name = game_state["team_names"][0] - right_name = game_state["team_names"][1] + left_name = game_state["team_names"][0] or '???' + right_name = game_state["team_names"][1] or '???' left_score = game_state["score"][0] right_score = game_state["score"][1] @@ -852,7 +856,7 @@ def status(team_idx): # sum the deaths of both bots in this team deaths = game_state['deaths'][team_idx] + game_state['deaths'][team_idx+2] kills = game_state['kills'][team_idx] + game_state['kills'][team_idx+2] - ret = "Errors: %d, Kills: %d, Deaths: %d, Time: %.2f" % (game_state["num_errors"][team_idx], kills, deaths, game_state["team_time"][team_idx]) + ret = "Timeouts: %d, Kills: %d, Deaths: %d, Time: %.2f" % (game_state["num_errors"][team_idx], kills, deaths, game_state["team_time"][team_idx]) return ret except TypeError: return "" @@ -960,7 +964,6 @@ def draw_end_of_game(self, display_string): fill="#FFC903", tags="gameover", justify=tkinter.CENTER, anchor=tkinter.CENTER) - def draw_game_over(self, win_name): """ Draw the game over string. """ # shorten the winning name @@ -973,6 +976,10 @@ def draw_game_draw(self): """ Draw the game draw string. """ self.draw_end_of_game("GAME OVER\nDRAW!") + def draw_game_failure(self): + """ Draw the game draw string. """ + self.draw_end_of_game("No Game\nCannot run Pelita") + def clear(self): self.ui_game_canvas.delete(tkinter.ALL) diff --git a/pelita/utils.py b/pelita/utils.py index b333343ca..de12379a5 100644 --- a/pelita/utils.py +++ b/pelita/utils.py @@ -105,7 +105,7 @@ def run_background_game(*, blue_move, red_move, layout=None, max_rounds=300, see game_state = run_game((blue_move, red_move), layout_dict=layout_dict, max_rounds=max_rounds, rng=rng, - team_names=('blue', 'red'), allow_exceptions=True, print_result=False) + team_names=('blue', 'red'), raise_bot_exceptions=True, print_result=False) out = {} out['seed'] = seed out['walls'] = game_state['walls'] @@ -117,8 +117,8 @@ def run_background_game(*, blue_move, red_move, layout=None, max_rounds=300, see out['red_bots'] = game_state['bots'][1::2] out['blue_score'] = game_state['score'][0] out['red_score'] = game_state['score'][1] - out['blue_errors'] = game_state['errors'][0] - out['red_errors'] = game_state['errors'][1] + out['blue_errors'] = game_state['timeouts'][0] + out['red_errors'] = game_state['timeouts'][1] out['blue_deaths'] = game_state['deaths'][::2] out['red_deaths'] = game_state['deaths'][1::2] out['blue_kills'] = game_state['kills'][::2] diff --git a/test/fixtures/player_bad_team_name.py b/test/fixtures/player_bad_team_name.py new file mode 100644 index 000000000..deda109d8 --- /dev/null +++ b/test/fixtures/player_bad_team_name.py @@ -0,0 +1,5 @@ +# Player with an overly long team name + +TEAM_NAME = "123456789 123456789 123456789 123456789" +def move(b, s): + return b.position diff --git a/test/fixtures/remote_timeout_red.py b/test/fixtures/remote_timeout_red.py index 95e6c3502..23f1a6615 100644 --- a/test/fixtures/remote_timeout_red.py +++ b/test/fixtures/remote_timeout_red.py @@ -2,7 +2,7 @@ TEAM_NAME = "500ms timeout" def move(b, s): - if b.round == 1 and b.turn == 1: + if b.round == 2 and b.turn == 1: return (-2, 0) time.sleep(0.5) return b.position \ No newline at end of file diff --git a/test/test_game.py b/test/test_game.py index 9215a50b6..24bf2121f 100644 --- a/test/test_game.py +++ b/test/test_game.py @@ -7,11 +7,12 @@ from pathlib import Path from random import Random +from pelita.network import RemotePlayerRecvTimeout import pytest -from pelita import game, maze_generator -from pelita.exceptions import NoFoodWarning -from pelita.game import (apply_move, get_legal_positions, initial_positions, +from pelita import game, layout, maze_generator +from pelita.exceptions import NoFoodWarning, PelitaBotError +from pelita.game import (add_fatal_error, apply_move, get_legal_positions, initial_positions, play_turn, run_game, setup_game) from pelita.layout import parse_layout from pelita.player import stepping_player, stopping_player @@ -180,28 +181,6 @@ def test_get_legal_positions_random(parsed_l, bot_idx): assert move not in parsed_l["walls"] assert abs((move[0] - bot[0])+(move[1] - bot[1])) <= 1 -@pytest.mark.parametrize('turn', (0, 1, 2, 3)) -def test_play_turn_apply_error(game_state, turn): - """check that quits when there are too many errors""" - error_dict = { - "type": 'PlayerTimeout', - } - game_state["turn"] = turn - team = turn % 2 - game_state["errors"] = [{(r, t): error_dict for r in (1, 2) for t in (0, 1)}, - {(r, t): error_dict for r in (1, 2) for t in (0, 1)}] - # we pretend that two rounds have already been played - # so that the error dictionaries are sane - game_state["round"] = 3 - # add a timeout to the current bot - game_state["errors"][team][(3, turn%2)] = error_dict - game_state_new = apply_move(game_state, game_state["bots"][turn]) - assert game_state_new["gameover"] - assert len(game_state_new["errors"][team]) == 5 - assert game_state_new["whowins"] == int(not team) - assert game_state_new["errors"][team][(3, turn%2)] == error_dict - - @pytest.mark.parametrize('turn', (0, 1, 2, 3)) def test_illegal_position_is_fatal(game_state, turn): """check that quits when illegal position""" @@ -223,14 +202,15 @@ def test_illegal_position_is_fatal(game_state, turn): def test_play_turn_fatal(game_state, turn): """Checks that game quite after fatal error""" game_state["turn"] = turn + game_state["round"] = 1 + game_state["game_phase"] = "RUNNING" team = turn % 2 - fatal_list = [{}, {}] - fatal_list[team] = {"error":True} - game_state["fatal_errors"] = fatal_list - move = get_legal_positions(game_state["walls"], game_state["shape"], game_state["bots"][turn]) - game_state_new = apply_move(game_state, move[0]) - assert game_state_new["gameover"] - assert game_state_new["whowins"] == int(not team) + add_fatal_error(game_state, round=1, turn=turn, type="some error", msg="") + # move = get_legal_positions(game_state["walls"], game_state["shape"], game_state["bots"][turn]) + # game_state_new = apply_move(game_state, move[0]) + assert game_state["game_phase"] == "FINISHED" + assert game_state["gameover"] + assert game_state["whowins"] == int(not team) @pytest.mark.parametrize('turn', (0, 1, 2, 3)) @@ -344,6 +324,7 @@ def test_multiple_enemies_killing(): for bot in (0, 2): game_state = setup_game([dummy_bot, dummy_bot], layout_dict=parsed_l0) + game_state['round'] = 1 game_state['turn'] = bot # get position of bots x (and y) kill_position = game_state['bots'][1] @@ -358,6 +339,7 @@ def test_multiple_enemies_killing(): for bot in (1, 3): game_state = setup_game([dummy_bot, dummy_bot], layout_dict=parsed_l1) + game_state['round'] = 1 game_state['turn'] = bot # get position of bots 0 (and 2) kill_position = game_state['bots'][0] @@ -390,6 +372,7 @@ def test_suicide(): for bot in (1, 3): game_state = setup_game([dummy_bot, dummy_bot], layout_dict=parsed_l0) + game_state['round'] = 1 game_state['turn'] = bot # get position of bot 2 suicide_position = game_state['bots'][2] @@ -406,6 +389,7 @@ def test_suicide(): for bot in (0, 2): game_state = setup_game([dummy_bot, dummy_bot], layout_dict=parsed_l1) + game_state['round'] = 1 game_state['turn'] = bot # get position of bot 3 suicide_position = game_state['bots'][3] @@ -767,9 +751,10 @@ def test_play_turn_move(): "kills":[0]*4, "deaths": [0]*4, "bot_was_killed": [False]*4, - "errors": [[], []], + "timeouts": [[], []], "fatal_errors": [{}, {}], - "rng": Random() + "rng": Random(), + "game_phase": "RUNNING", } legal_positions = get_legal_positions(game_state["walls"], game_state["shape"], game_state["bots"][turn]) game_state_new = apply_move(game_state, legal_positions[0]) @@ -787,7 +772,6 @@ def move(bot, s): # in the first round (round #1), # all bots move to the south if bot.round == 1: - # go one step to the right return (bot.position[0], bot.position[1] + 1) else: # There should not be more then one round in this test @@ -816,8 +800,8 @@ def move(bot, s): assert final_state['gameover'] assert final_state['whowins'] == 1 assert final_state['fatal_errors'][0][0] == { - 'type': 'FatalException', - 'description': 'Exception in client (RuntimeError): We should not be here in this test', + 'type': 'RuntimeError', + 'description': 'We should not be here in this test', 'round': 2, 'turn': 0, } @@ -845,29 +829,27 @@ def test_update_round_counter(): 'round': round0, 'gameover': True,}) - -def test_last_round_check(): - # (max_rounds, current_round, turn): gameover - test_map = { - (1, None, None): False, - (1, 1, 0): False, - (1, 1, 3): True, +@pytest.mark.parametrize( + 'max_rounds, current_round, turn, game_phase, gameover', [ + [1, None, None, 'INIT', False], + [1, 1, 0, 'RUNNING', False], + [1, 1, 3, 'RUNNING', True], + ]) +def test_last_round_check(max_rounds, current_round, turn, game_phase, gameover): + state = { + 'max_rounds': max_rounds, + 'round': current_round, + 'turn': turn, + 'error_limit': 5, + 'fatal_errors': [[],[]], + 'timeouts': [[],[]], + 'gameover': False, + 'score': [0, 0], + 'food': [{(1,1)}, {(1,1)}], # dummy food + 'game_phase': game_phase } - for test_val, test_res in test_map.items(): - max_rounds, current_round, current_turn = test_val - state = { - 'max_rounds': max_rounds, - 'round': current_round, - 'turn': current_turn, - 'error_limit': 5, - 'fatal_errors': [[],[]], - 'errors': [[],[]], - 'gameover': False, - 'score': [0, 0], - 'food': [{(1,1)}, {(1,1)}] # dummy food - } - res = game.check_gameover(state, detect_final_move=True) - assert res['gameover'] == test_res + res = game.check_gameover(state) + assert res['gameover'] == gameover @pytest.mark.parametrize( @@ -881,11 +863,11 @@ def test_last_round_check(): (((0, 4), (0, 4)), False), (((0, 5), (0, 0)), 1), (((0, 0), (0, 5)), 0), - (((0, 5), (0, 5)), 2), + (((0, 5), (0, 5)), 1), # earlier team fails first (((1, 0), (0, 0)), 1), (((0, 0), (1, 0)), 0), - (((1, 0), (1, 0)), 2), - (((1, 1), (1, 0)), 2), + (((1, 0), (1, 0)), 1), # earlier team fails first + (((1, 1), (1, 0)), 1), # earlier team fails first (((1, 0), (0, 5)), 1), (((0, 5), (1, 0)), 0), ] @@ -893,22 +875,69 @@ def test_last_round_check(): def test_error_finishes_game(team_errors, team_wins): # the mapping is as follows: # [(num_fatal_0, num_errors_0), (num_fatal_1, num_errors_1), result_flag] - # the result flag: 0/1: team 0/1 wins, 2: draw, False: no winner yet + # the result flag: 0/1: team 0/1 wins, 2: draw, False: draw after 20 rounds + + (fatal_0, timeouts_0), (fatal_1, timeouts_1) = team_errors + + def move0(b, s): + if not s: + s['count'] = 0 + s['count'] += 1 + + if s['count'] <= fatal_0: + return None + + if s['count'] <= timeouts_0: + raise RemotePlayerRecvTimeout + + return b.position + + def move1(b, s): + if not s: + s['count'] = 0 + s['count'] += 1 + + if s['count'] <= fatal_1: + return None + + if s['count'] <= timeouts_1: + raise RemotePlayerRecvTimeout + + return b.position + + l = maze_generator.generate_maze() + state = game.setup_game([move0, move1], max_rounds=20, layout_dict=l) + + # We must patch apply_move_fn so that RemotePlayerRecvTimeout is not caught + # and we can actually test timeouts + def apply_move_fn(move_fn, bot, state): + move = move_fn(bot, state) + if move is None: + return { + 'error': 'Some fatal error', + 'error_msg': 'Some fatal error' + } + return { "move": move } + + state['teams'][0].apply_move_fn = apply_move_fn + state['teams'][1].apply_move_fn = apply_move_fn + + # Play the game until it is gameover. + while state['game_phase'] == 'RUNNING': + # play the next turn + state = play_turn(state) - (fatal_0, errors_0), (fatal_1, errors_1) = team_errors - # just faking a bunch of errors in our game state - state = { - "error_limit": 5, - "fatal_errors": [[None] * fatal_0, [None] * fatal_1], - "errors": [[None] * errors_0, [None] * errors_1] - } res = game.check_gameover(state) if team_wins is False: - assert res["whowins"] is None - assert res["gameover"] is False + assert res["whowins"] == 2 + assert res["gameover"] is True + assert state["round"] == 20 + assert len(state['timeouts'][0]) == timeouts_0 + assert len(state['timeouts'][1]) == timeouts_1 else: assert res["whowins"] == team_wins assert res["gameover"] is True + assert state["round"] < 20 @pytest.mark.parametrize('bot_to_move', [0, 1, 2, 3]) @@ -979,15 +1008,26 @@ def move(b, s): def test_non_existing_file(): - # TODO: Change error message to be more meaningful l = maze_generator.generate_maze() res = run_game(["blah", "nothing"], max_rounds=1, layout_dict=l) - assert res['fatal_errors'][0][0] == { - 'description': '("Could not load blah: No module named \'blah\'", \'ModuleNotFoundError\')', - 'round': None, - 'turn': 0, - 'type': 'PlayerDisconnected' - } + print(res['fatal_errors']) + + # We might only catch only one of the errors + assert len(res['fatal_errors'][0]) > 0 or len(res['fatal_errors'][1]) > 0 + if len(res['fatal_errors'][0]) > 0: + assert res['fatal_errors'][0][0] == { + 'description': "ModuleNotFoundError: Could not load blah: No module named \'blah\'", + 'round': None, + 'turn': 0, + 'type': 'RemotePlayerFailure' + } + if len(res['fatal_errors'][1]) > 0: + assert res['fatal_errors'][1][0] == { + 'description': "ModuleNotFoundError: Could not load nothing: No module named \'nothing\'", + 'round': None, + 'turn': 1, + 'type': 'RemotePlayerFailure' + } # TODO: Get it working again on Windows @pytest.mark.skipif(_mswindows, reason="Test fails on some Python versions.") @@ -1000,34 +1040,55 @@ def test_remote_errors(tmp_path): l = maze_generator.generate_maze() res = run_game([str(syntax_error), str(import_error)], layout_dict=l, max_rounds=20) - # Error messages have changed in Python 3.10. We can only do approximate matching - assert "SyntaxError" in res['fatal_errors'][0][0].pop('description') - assert res['fatal_errors'][0][0] == { - 'round': None, - 'turn': 0, - 'type': 'PlayerDisconnected' - } - # Both teams fail during setup: DRAW - assert res['whowins'] == 2 + print(res['fatal_errors']) + + # We might only catch only one of the errors + assert len(res['fatal_errors'][0]) > 0 or len(res['fatal_errors'][1]) > 0 + if len(res['fatal_errors'][0]) > 0: + # Error messages have changed in Python 3.10. We can only do approximate matching + assert "SyntaxError" in res['fatal_errors'][0][0].pop('description') + assert res['fatal_errors'][0][0] == { + 'round': None, + 'turn': 0, + 'type': 'RemotePlayerFailure' + } + + if len(res['fatal_errors'][1]) > 0: + assert "ModuleNotFoundError" in res['fatal_errors'][1][0].pop('description') + assert res['fatal_errors'][1][0] == { + 'round': None, + 'turn': 1, + 'type': 'RemotePlayerFailure' + } + + # Both teams fail during setup: FAILURE + assert res['game_phase'] == "FAILURE" + assert res['gameover'] is True + assert res['whowins'] == -1 + res = run_game(["0", str(import_error)], layout_dict=l, max_rounds=20) # Error messages have changed in Python 3.10. We can only do approximate matching assert "ModuleNotFoundError" in res['fatal_errors'][1][0].pop('description') assert res['fatal_errors'][1][0] == { 'round': None, 'turn': 1, - 'type': 'PlayerDisconnected' + 'type': 'RemotePlayerFailure' } - assert res['whowins'] == 0 + assert res['game_phase'] == "FAILURE" + assert res['gameover'] is True + assert res['whowins'] == -1 + res = run_game([str(import_error), "1"], layout_dict=l, max_rounds=20) # Error messages have changed in Python 3.10. We can only do approximate matching assert "ModuleNotFoundError" in res['fatal_errors'][0][0].pop('description') assert res['fatal_errors'][0][0] == { 'round': None, 'turn': 0, - 'type': 'PlayerDisconnected' + 'type': 'RemotePlayerFailure' } - assert res['whowins'] == 1 - + assert res['game_phase'] == "FAILURE" + assert res['gameover'] is True + assert res['whowins'] == -1 @pytest.mark.parametrize('team_to_test', [0, 1]) def test_bad_move_function(team_to_test): @@ -1055,30 +1116,35 @@ def test_run_game(move): teams = [stopping, move] return run_game(teams, layout_dict=l, max_rounds=10) + res = test_run_game(stopping) + assert res['gameover'] + assert res['whowins'] == 2 + assert res['fatal_errors'] == [[], []] + other = 1 - team_to_test res = test_run_game(move0) assert res['gameover'] assert res['whowins'] == other - assert res['fatal_errors'][team_to_test][0]['type'] == 'FatalException' - assert res['fatal_errors'][team_to_test][0]['description'] == 'Exception in client (ValueError): Function move did not return a valid position: got None instead.' + assert res['fatal_errors'][team_to_test][0]['type'] == 'ValueError' + assert res['fatal_errors'][team_to_test][0]['description'] == "Function move did not return a valid position: got 'None' instead." res = test_run_game(move1) assert res['gameover'] assert res['whowins'] == other - assert res['fatal_errors'][team_to_test][0]['type'] == 'FatalException' - assert res['fatal_errors'][team_to_test][0]['description'] == 'Exception in client (ValueError): Function move did not return a valid position: got 0 instead.' + assert res['fatal_errors'][team_to_test][0]['type'] == 'ValueError' + assert res['fatal_errors'][team_to_test][0]['description'] == "Function move did not return a valid position: got '0' instead." res = test_run_game(move3) assert res['gameover'] assert res['whowins'] == other - assert res['fatal_errors'][team_to_test][0]['type'] == 'FatalException' - assert res['fatal_errors'][team_to_test][0]['description'] == 'Exception in client (ValueError): Function move did not return a valid position: got (0, 0, 0) instead.' + assert res['fatal_errors'][team_to_test][0]['type'] == 'ValueError' + assert res['fatal_errors'][team_to_test][0]['description'] == "Function move did not return a valid position: got '(0, 0, 0)' instead." res = test_run_game(move4) assert res['gameover'] assert res['whowins'] == other - assert res['fatal_errors'][team_to_test][0]['type'] == 'FatalException' + assert res['fatal_errors'][team_to_test][0]['type'] == 'TypeError' assert "takes 1 positional argument but 2 were given" in res['fatal_errors'][team_to_test][0]['description'] @@ -1216,40 +1282,56 @@ def test_remote_game_closes_players_on_exit(): l = maze_generator.generate_maze() # run a remote demo game with "0" and "1" - state = run_game(["0", "1"], layout_dict=l, max_rounds=20, allow_exceptions=True) + state = run_game(["0", "1"], layout_dict=l, max_rounds=20, raise_bot_exceptions=True) assert state["gameover"] # Check that both processes have exited - assert state["teams"][0].proc[0].wait(timeout=3) == 0 - assert state["teams"][1].proc[0].wait(timeout=3) == 0 + assert state["teams"][0].proc.wait(timeout=3) == 0 + assert state["teams"][1].proc.wait(timeout=3) == 0 def test_manual_remote_game_closes_players(): l = maze_generator.generate_maze() # run a remote demo game with "0" and "1" - state = setup_game(["0", "1"], layout_dict=l, max_rounds=10, allow_exceptions=True) + state = setup_game(["0", "1"], layout_dict=l, max_rounds=10, raise_bot_exceptions=True) assert not state["gameover"] while not state["gameover"]: # still running # still running - assert state["teams"][0].proc[0].poll() is None - assert state["teams"][1].proc[0].poll() is None + assert state["teams"][0].proc.poll() is None + assert state["teams"][1].proc.poll() is None state = play_turn(state) # Check that both processes have exited - assert state["teams"][0].proc[0].wait(timeout=3) == 0 - assert state["teams"][1].proc[0].wait(timeout=3) == 0 + assert state["teams"][0].proc.wait(timeout=3) == 0 + assert state["teams"][1].proc.wait(timeout=3) == 0 def test_invalid_setup_game_closes_players(): l = maze_generator.generate_maze() # setup a remote demo game with "0" and "1" but bad max rounds - state = setup_game(["0", "1"], layout_dict=l, max_rounds=0, allow_exceptions=True) - assert state["gameover"] + state = setup_game(["0", "1"], layout_dict=l, max_rounds=0, raise_bot_exceptions=True) + assert state["game_phase"] == "FAILURE" + # Check that both processes have exited + assert state["teams"][0].proc.wait(timeout=3) == 0 + assert state["teams"][1].proc.wait(timeout=3) == 0 + +def test_raises_and_exits_cleanly(): + l = layout.parse_layout(small_layout) + + path = FIXTURE_DIR / "player_move_division_by_zero" + state = setup_game([str(path), "1"], layout_dict=l, max_rounds=2) + with pytest.raises(PelitaBotError): + while not state["gameover"]: + state = play_turn(state, raise_bot_exceptions=True) + + # Game state is updated before the exception is raised + assert state["gameover"] is True + assert state["fatal_errors"][0][0]["type"] == "ZeroDivisionError" # Check that both processes have exited - assert state["teams"][0].proc[0].wait(timeout=3) == 0 - assert state["teams"][1].proc[0].wait(timeout=3) == 0 + assert state["teams"][0].proc.wait(timeout=3) == 0 + assert state["teams"][1].proc.wait(timeout=3) == 0 @pytest.mark.parametrize('move_request, expected_prev, expected_req, expected_success', [ diff --git a/test/test_libpelita.py b/test/test_libpelita.py index 4b9a7b92c..63a3698ba 100644 --- a/test/test_libpelita.py +++ b/test/test_libpelita.py @@ -5,7 +5,7 @@ import pytest -from pelita.network import ZMQClientError +from pelita.network import RemotePlayerFailure from pelita.scripts.pelita_tournament import firstNN from pelita.tournament import call_pelita, check_team @@ -71,7 +71,7 @@ def test_check_team_external(): assert check_team("pelita/player/StoppingPlayer") == "Stopping Players" def test_check_team_external_fails(): - with pytest.raises(ZMQClientError): + with pytest.raises(RemotePlayerFailure): check_team("Unknown Module") def test_check_team_internal(): diff --git a/test/test_network.py b/test/test_network.py index a59145d5d..0bb68851d 100644 --- a/test/test_network.py +++ b/test/test_network.py @@ -1,9 +1,13 @@ + +import concurrent.futures +import queue import sys import uuid import pytest import zmq +from pelita.game import play_turn, setup_game from pelita.network import bind_socket from pelita.scripts.pelita_player import player_handle_request from pelita.team import make_team @@ -44,6 +48,8 @@ def stopping(bot, state): team, _ = make_team(stopping, team_name='test stopping player') client_sock = zmq_context.socket(zmq.PAIR) client_sock.connect(f'tcp://127.0.0.1:{port}') + poller = zmq.Poller() + poller.register(client_sock, zmq.POLLIN) # Faking some data _uuid = uuid.uuid4().__str__() @@ -64,11 +70,11 @@ def stopping(bot, state): } } sock.send_json(set_initial) - player_handle_request(client_sock, team) + player_handle_request(client_sock, poller, team) assert sock.recv_json() == { '__uuid__': _uuid, - '__return__': 'test stopping player' + '__return__': None } _uuid = uuid.uuid4().__str__() @@ -110,7 +116,7 @@ def stopping(bot, state): } } sock.send_json(get_move) - player_handle_request(client_sock, team) + player_handle_request(client_sock, poller, team) assert sock.recv_json() == { '__uuid__': _uuid, @@ -128,3 +134,171 @@ def stopping(bot, state): sock.send_json(exit_msg) assert res[0] == "success" + + +@pytest.mark.parametrize("checkpoint", range(11)) +def test_simpleclient_broken(zmq_context, checkpoint): + # This test runs a test game against a (malicious) server client + # (a malicious subprocess client is harder to test) + # Depending on the checkpoint selected, the broken test client will + # run up to a particular point and then send a malicious message. + + # Depending on whether this message occurs in the game setup stage + # or during the game run, this will either set the phase to FAILURE or + # let the good team win. Pelita itself should not break in the process. + + timeout = 3000 + + q1 = queue.Queue() + q2 = queue.Queue() + + def dealer_good(q): + zmq_context = zmq.Context() + sock = zmq_context.socket(zmq.DEALER) + poll = zmq.Poller() + + port = sock.bind_to_random_port('tcp://127.0.0.1') + q.put(port) + + poll.register(sock, zmq.POLLIN) + _available_socks = poll.poll(timeout=timeout) + request = sock.recv_json() + assert request['REQUEST'] + sock.send_json({'__status__': 'ok', '__data__': {'team_name': 'good player'}}) + + _available_socks = poll.poll(timeout=timeout) + set_initial = sock.recv_json(flags=zmq.NOBLOCK) + if set_initial['__action__'] == 'exit': + return + assert set_initial['__action__'] == "set_initial" + sock.send_json({'__uuid__': set_initial['__uuid__'], '__return__': None}) + + for _i in range(8): + _available_socks = poll.poll(timeout=timeout) + game_state = sock.recv_json(flags=zmq.NOBLOCK) + + action = game_state['__action__'] + if action == 'exit': + return + assert set_initial['__action__'] == "set_initial" + + current_pos = game_state['__data__']['game_state']['team']['bot_positions'][game_state['__data__']['game_state']['bot_turn']] + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': current_pos}}) + + _available_socks = poll.poll(timeout=timeout) + exit_state = sock.recv_json(flags=zmq.NOBLOCK) + + assert exit_state['__action__'] == 'exit' + + def dealer_bad(q): + zmq_context = zmq.Context() + sock = zmq_context.socket(zmq.DEALER) + poll = zmq.Poller() + + port = sock.bind_to_random_port('tcp://127.0.0.1') + q.put(port) + + poll.register(sock, zmq.POLLIN) + # we set our recv to raise, if there is no message (zmq.NOBLOCK), + # so we do not need to care to check whether something is in the _available_socks + _available_socks = poll.poll(timeout=timeout) + + request = sock.recv_json(flags=zmq.NOBLOCK) + assert request['REQUEST'] + if checkpoint == 1: + sock.send_string("") + return + elif checkpoint == 2: + sock.send_json({'__status__': 'ok'}) + return + else: + sock.send_json({'__status__': 'ok', '__data__': {'team_name': f'bad <{checkpoint}>'}}) + + _available_socks = poll.poll(timeout=timeout) + + set_initial = sock.recv_json(flags=zmq.NOBLOCK) + + if checkpoint == 3: + sock.send_string("") + return + elif checkpoint == 4: + sock.send_json({'__uuid__': 'ok'}) + return + else: + sock.send_json({'__uuid__': set_initial['__uuid__'], '__data__': None}) + + for _i in range(8): + _available_socks = poll.poll(timeout=timeout) + game_state = sock.recv_json(flags=zmq.NOBLOCK) + + action = game_state['__action__'] + if action == 'exit': + return + + current_pos = game_state['__data__']['game_state']['team']['bot_positions'][game_state['__data__']['game_state']['bot_turn']] + if checkpoint == 5: + sock.send_string("No json") + return + elif checkpoint == 6: + # This is an acceptable message that will never match a request + # We can send the correct message afterwards and the match continues + sock.send_json({'__uuid__': "Bad", '__return__': "Nothing"}) + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': current_pos}}) + elif checkpoint == 7: + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': [0, 0]}}) + return + elif checkpoint == 8: + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': "NOTHING"}}) + return + elif checkpoint == 9: + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': [0, 0]}}) + return + else: + sock.send_json({'__uuid__': game_state['__uuid__'], '__return__': {'move': current_pos}}) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + players = [] + players.append(executor.submit(dealer_good, q1)) + + if checkpoint == 0: + players.append(executor.submit(dealer_good, q2)) + else: + players.append(executor.submit(dealer_bad, q2)) + + port1 = q1.get() + port2 = q2.get() + + layout = {'walls': ((0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (1, 0), (1, 5), (2, 0), (2, 5), (3, 0), (3, 2), (3, 3), (3, 5), (4, 0), (4, 2), (4, 3), (4, 5), (5, 0), (5, 5), (6, 0), (6, 5), (7, 0), (7, 1), (7, 2), (7, 3), (7, 4), (7, 5)), 'food': [(1, 1), (1, 2), (2, 1), (2, 2), (5, 3), (5, 4), (6, 3), (6, 4)], 'bots': [(1, 3), (6, 2), (1, 4), (6, 1)], 'shape': (8, 6)} + + game_state = setup_game([ + f'pelita://127.0.0.1:{port1}/PLAYER1', + f'pelita://127.0.0.1:{port2}/PLAYER2' + ], + layout_dict=layout, + max_rounds=2, + timeout_length=1, + ) + + # check that the game_state ends in the expected phase + match checkpoint: + case 0|5|6|7|8|9|10: + assert game_state['game_phase'] == 'RUNNING' + case 1|2|3|4: + assert game_state['game_phase'] == 'FAILURE' + + while game_state['game_phase'] == 'RUNNING': + game_state = play_turn(game_state) + + match checkpoint: + case 0|6|10: + assert game_state['game_phase'] == 'FINISHED' + assert game_state['whowins'] == 2 + case 5|7|8|9: + assert game_state['game_phase'] == 'FINISHED' + assert game_state['whowins'] == 0 + case 1|2|3|4: + assert game_state['game_phase'] == 'FAILURE' + + # check that no player had an uncaught exception + for player in concurrent.futures.as_completed(players): + assert player.exception() is None diff --git a/test/test_players.py b/test/test_players.py index b8525b943..9b31b1928 100644 --- a/test/test_players.py +++ b/test/test_players.py @@ -305,5 +305,5 @@ def test_players(player): # ensure that all test players ran correctly assert state['fatal_errors'] == [[], []] # our test players should never return invalid moves - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] diff --git a/test/test_remote_game.py b/test/test_remote_game.py index ac470726c..2a93b7a2e 100644 --- a/test/test_remote_game.py +++ b/test/test_remote_game.py @@ -43,7 +43,7 @@ def test_remote_call_pelita(remote_teams): assert res['fatal_errors'] == [[], []] # errors for call_pelita only contains the last thrown error, hence None # TODO: should be aligned so that call_pelita and run_game return the same thing - assert res['errors'] == [None, None] + assert res['timeouts'] == [None, None] def test_remote_run_game(remote_teams): @@ -57,10 +57,9 @@ def test_remote_run_game(remote_teams): state = pelita.game.run_game(remote_teams, max_rounds=30, layout_dict=pelita.layout.parse_layout(layout)) assert state['whowins'] == 1 assert state['fatal_errors'] == [[], []] - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] -@pytest.mark.xfail(reason="TODO: Fails in CI for macOS. Unclear why.") def test_remote_timeout(): # We have a slow player that also generates a bad move # in its second turn. @@ -83,13 +82,42 @@ def test_remote_timeout(): timeout_length=0.4) assert state['whowins'] == 0 - assert state['fatal_errors'] == [[], []] - assert state['errors'] == [{}, - {(1, 1): {'description': '', 'type': 'PlayerTimeout'}, - (1, 3): {'bot_position': (-2, 0), 'reason': 'illegal move'}, - (2, 1): {'description': '', 'type': 'PlayerTimeout'}, - (2, 3): {'description': '', 'type': 'PlayerTimeout'}, - (3, 1): {'description': '', 'type': 'PlayerTimeout'}}] + assert state['fatal_errors'][0] == [] + assert state['fatal_errors'][1][0]['type'] == 'IllegalPosition' + assert state['fatal_errors'][1][0]['turn'] == 3 + assert state['fatal_errors'][1][0]['round'] == 2 + assert state['timeouts'][0] == {} + assert set(state['timeouts'][1].keys()) == {(1, 1), (1, 3), (2, 1)} + assert state['timeouts'][1][(1, 1)]['type'] == 'timeout' + assert state['timeouts'][1][(1, 3)]['type'] == 'timeout' + assert state['timeouts'][1][(2, 1)]['type'] == 'timeout' + + +@pytest.mark.parametrize("failing_team", [0, 1]) +def test_bad_team_name(failing_team): + layout = """ + ########## + # b y # + #a .. x# + ########## + """ + + failing_player = FIXTURE_DIR / 'player_bad_team_name.py' + good_player = "0" + + if failing_team == 0: + teams = [str(failing_player), str(good_player)] + elif failing_team == 1: + teams = [str(good_player), str(failing_player)] + + state = pelita.game.run_game(teams, + max_rounds=8, + layout_dict=pelita.layout.parse_layout(layout), + timeout_length=0.4) + + assert state['whowins'] == -1 + assert state['fatal_errors'][failing_team][0]['type'] == "RemotePlayerFailure" + assert "longer than 25" in state['fatal_errors'][failing_team][0]['description'] def test_remote_dumps_are_written(): @@ -114,7 +142,7 @@ def test_remote_dumps_are_written(): assert state['whowins'] == 2 assert state['fatal_errors'] == [[], []] - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] path = Path(out_folder.name) blue_lines = (path / 'blue.out').read_text().split('\n') @@ -158,12 +186,12 @@ def test_remote_dumps_with_failure(failing_team): fail_turn = 0 elif failing_team == 1: fail_turn = 1 - assert state['fatal_errors'][failing_team][0] == {'type': 'FatalException', - 'description': 'Exception in client (ZeroDivisionError): division by zero', + assert state['fatal_errors'][failing_team][0] == {'type': 'ZeroDivisionError', + 'description': 'division by zero', 'turn': fail_turn, 'round': 2} assert state['fatal_errors'][1 - failing_team] == [] - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] path = Path(out_folder.name) @@ -231,15 +259,16 @@ def test_remote_move_failures(player_name, is_setup_error, error_type): max_rounds=2, layout_dict=pelita.layout.parse_layout(layout)) - assert state['whowins'] == 1 + assert state['whowins'] == -1 + assert state['game_phase'] == 'FAILURE' - assert state['fatal_errors'][0][0]['type'] == 'PlayerDisconnected' + assert state['fatal_errors'][0][0]['type'] == 'RemotePlayerFailure' assert 'Could not load' in state['fatal_errors'][0][0]['description'] assert error_type in state['fatal_errors'][0][0]['description'] assert state['fatal_errors'][0][0]['turn'] == 0 assert state['fatal_errors'][0][0]['round'] is None assert state['fatal_errors'][1] == [] - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] else: state = pelita.game.run_game([str(failing_player), str(good_player)], @@ -247,10 +276,10 @@ def test_remote_move_failures(player_name, is_setup_error, error_type): layout_dict=pelita.layout.parse_layout(layout)) assert state['whowins'] == 1 + assert state['game_phase'] == 'FINISHED' - assert state['fatal_errors'][0][0]['type'] == 'FatalException' - assert f'Exception in client ({error_type})' in state['fatal_errors'][0][0]['description'] + assert state['fatal_errors'][0][0]['type'] == error_type assert state['fatal_errors'][0][0]['turn'] == 0 assert state['fatal_errors'][0][0]['round'] == 1 assert state['fatal_errors'][1] == [] - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] diff --git a/test/test_team.py b/test/test_team.py index e3f367d02..49877ca1e 100644 --- a/test/test_team.py +++ b/test/test_team.py @@ -39,7 +39,7 @@ def test_stopping(self): stopping, round_counting ] - state = run_game(team, max_rounds=1, layout_dict=parse_layout(test_layout), allow_exceptions=True) + state = run_game(team, max_rounds=1, layout_dict=parse_layout(test_layout), raise_bot_exceptions=True) assert state['bots'][0] == (1, 1) assert state['bots'][1] == (10, 1) assert round_counting._storage['rounds'] == 1 @@ -49,7 +49,7 @@ def test_stopping(self): stopping, round_counting ] - state = run_game(team, max_rounds=3, layout_dict=parse_layout(test_layout), allow_exceptions=True) + state = run_game(team, max_rounds=3, layout_dict=parse_layout(test_layout), raise_bot_exceptions=True) assert state['bots'][0] == (1, 1) assert state['bots'][1] == (10, 1) assert round_counting._storage['rounds'] == 3 @@ -240,7 +240,7 @@ def move(bot, state): # otherwise return current position return new_pos - state = run_game([move, move], max_rounds=3, layout_dict=parse_layout(layout), allow_exceptions=True) + state = run_game([move, move], max_rounds=3, layout_dict=parse_layout(layout), raise_bot_exceptions=True) # assertions might have been caught in run_game # check that all is good assert state['fatal_errors'] == [[], []] @@ -322,7 +322,7 @@ def move(bot, state): # otherwise return current position return new_pos - state = run_game([move, move], max_rounds=3, layout_dict=parse_layout(layout), allow_exceptions=True) + state = run_game([move, move], max_rounds=3, layout_dict=parse_layout(layout), raise_bot_exceptions=True) # assertions might have been caught in run_game # check that all is good assert state['fatal_errors'] == [[], []] @@ -418,7 +418,7 @@ def asserting_team(bot, state): state = run_game([asserting_team, asserting_team], max_rounds=1, layout_dict=parsed) # assertions might have been caught in run_game # check that all is good - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] def test_bot_graph(): @@ -448,7 +448,7 @@ def rough_bot(bot, state): state = run_game([rough_bot, stopping], max_rounds=1, layout_dict=parse_layout(layout)) # assertions might have been caught in run_game # check that all is good - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] def test_bot_graph_is_half_mutable(): @@ -491,7 +491,7 @@ def red(bot, state): state = run_game([blue, red], max_rounds=2, layout_dict=parse_layout(layout)) # assertions might have been caught in run_game # check that all is good - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] assert "".join(observer) == 'BRbrbrbr' @@ -527,12 +527,12 @@ def team_2(bot, state): state = play_turn(state) # check that player did not fail - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] state = play_turn(state) # check that player did not fail - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] @@ -594,7 +594,7 @@ def team_2(bot, state): return bot.position - state = setup_game([team_1, team_2], layout_dict=parse_layout(test_layout), max_rounds=3, allow_exceptions=True) + state = setup_game([team_1, team_2], layout_dict=parse_layout(test_layout), max_rounds=3, raise_bot_exceptions=True) state = play_turn(state) assert state['team_time'][0] >= 0 @@ -627,7 +627,7 @@ def team_2(bot, state): assert check == old_time # check that player did not fail - assert state['errors'] == [{}, {}] + assert state['timeouts'] == [{}, {}] assert state['fatal_errors'] == [[], []] assert state['team_time'][0] >= 1.0 assert state['team_time'][1] >= 2.0 @@ -661,7 +661,7 @@ def asserting_team(bot, state): return bot.position state = run_game([asserting_team, asserting_team], max_rounds=1, layout_dict=parsed, - allow_exceptions=True) + raise_bot_exceptions=True) # assertions might have been caught in run_game # check that all is good assert state['fatal_errors'] == [[], []] @@ -690,7 +690,7 @@ def asserting_team(bot, state): return bot.position state = run_game([asserting_team, asserting_team], max_rounds=1, layout_dict=parsed, - allow_exceptions=True) + raise_bot_exceptions=True) # assertions might have been caught in run_game # check that all is good assert state['fatal_errors'] == [[], []] @@ -713,16 +713,16 @@ def test_bot_repr(): def asserting_team(bot, state): bot_repr = repr(bot) if bot.is_blue and bot.round == 1: - assert bot_repr == f"" + assert bot_repr == f"" elif not bot.is_blue and bot.round == 1: - assert bot_repr == f"" + assert bot_repr == f"" else: assert False, "Should never be here." return bot.position state = run_game([asserting_team, asserting_team], max_rounds=1, layout_dict=parsed, - allow_exceptions=True) + raise_bot_exceptions=True) # assertions might have been caught in run_game # check that all is good assert state['fatal_errors'] == [[], []] @@ -756,7 +756,7 @@ def speaking_team(bot, state): bot.say(say) return bot.position - state = setup_game([speaking_team, speaking_team], max_rounds=1, layout_dict=parsed, allow_exceptions=True) + state = setup_game([speaking_team, speaking_team], max_rounds=1, layout_dict=parsed, raise_bot_exceptions=True) idx = 0 while not state["gameover"]: state = play_turn(state)