diff options
Diffstat (limited to 'diplomacy/tests/network/test_real_game.py')
-rw-r--r-- | diplomacy/tests/network/test_real_game.py | 605 |
1 files changed, 605 insertions, 0 deletions
diff --git a/diplomacy/tests/network/test_real_game.py b/diplomacy/tests/network/test_real_game.py new file mode 100644 index 0000000..a16e4f6 --- /dev/null +++ b/diplomacy/tests/network/test_real_game.py @@ -0,0 +1,605 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see <https://www.gnu.org/licenses/>. +# ============================================================================== +""" Test server game in real environment with test data in files `{15, 20, 23}.json`. """ +# pylint: disable=unused-argument +import logging +import os +import random + +from tornado import gen +from tornado.concurrent import Future +from tornado.ioloop import IOLoop + +import ujson as json + +from diplomacy.client.connection import connect +from diplomacy.server.server import Server +from diplomacy.engine.game import Game +from diplomacy.engine.map import Map +from diplomacy.engine.message import GLOBAL, Message as EngineMessage +from diplomacy.utils import common, exceptions, constants, strings + +LOGGER = logging.getLogger('diplomacy.tests.network.test_real_game') + +DEFAULT_HOSTNAME = 'localhost' + +DEFAULT_PORT = random.randint(9000, 10000) + +class ExpectedPhase(): + """ Helper class to manage data from an expected phase. """ + __slots__ = ['name', 'state', 'orders', 'messages'] + + def __init__(self, json_phase): + """ Initialize expected phase. + :param json_phase: JSON dict representing a phase. Expected fields: name, state, orders, messages. + """ + self.name = json_phase['name'] + self.state = json_phase['state'] + self.orders = json_phase['orders'] + self.messages = [EngineMessage(**json_message) for json_message in json_phase['messages']] + + self.messages.sort(key=lambda msg: msg.time_sent) + + def get_power_orders(self, power_name): + """ Return expected orders for given power name. """ + return self.orders[power_name] + + def get_power_related_messages(self, power_name): + """ Return expected messages for given power name. """ + return [message for message in self.messages + if message.sender == power_name or message.recipient in (power_name, GLOBAL)] + +class ExpectedMessages(): + """ Expected list of messages sent and received by a power name. """ + __slots__ = ['power_name', 'messages', 'next_messages_to_send'] + + def __init__(self, power_name, messages): + """ Initialize the expected messages. + :param power_name: power name which exchanges these messages + :param messages: messages exchanged + """ + self.power_name = power_name + self.messages = messages # type: [EngineMessage] + self.next_messages_to_send = [] + + def has_messages_to_receive(self): + """ Return True if messages list still contains messages to receive. """ + return any(message.sender != self.power_name for message in self.messages) + + def has_messages_to_send(self): + """ Return True if messages list still contains messages to send. """ + return any(message.sender == self.power_name for message in self.messages) + + def move_forward(self): + """ Move next messages to send from messages list to sending queue (self.next_messages_to_send). """ + self.next_messages_to_send.clear() + if self.messages: + if self.messages[0].sender != self.power_name: + # First message in stack is a message to receive. We cannot send any message + # until all messages to receive at top of stack were indeed received. + return + next_message_to_receive = len(self.messages) + for index, message in enumerate(self.messages): + if message.sender != self.power_name: + next_message_to_receive = index + break + self.next_messages_to_send.extend(self.messages[:next_message_to_receive]) + del self.messages[:next_message_to_receive] + +class ExpectedData(): + """ Expected data for a power in a game. """ + + __slots__ = ['messages', 'phases', '__phase_index', 'playing'] + + def __init__(self, power_name, phases, phase_index): + """ Initialize expected data for a game power. + :param power_name: name of power for which those data are expected. + :param phases: list of expected phases. + :param phase_index: index of current expected phase in given phases. + :type power_name: str + :type phases: list[ExpectedPhase] + :type phase_index: int + """ + self.messages = ExpectedMessages(power_name, phases[phase_index].get_power_related_messages(power_name)) + self.phases = phases + self.__phase_index = phase_index + self.playing = False + + power_name = property(lambda self: self.messages.power_name) + phase_index = property(lambda self: self.__phase_index) + expected_phase = property(lambda self: self.phases[self.__phase_index]) + + def move_forward(self): + """ Move to next expected phase. """ + self.__phase_index += 1 + if self.__phase_index < len(self.phases): + self.messages = ExpectedMessages( + self.messages.power_name, self.phases[self.__phase_index].get_power_related_messages(self.power_name)) + +class CaseData(): + """ Helper class to store test data. """ + FILE_FOLDER_NAME = os.path.abspath(os.path.dirname(__file__)) + + def __init__(self, case_file_name, hostname=DEFAULT_HOSTNAME, port=DEFAULT_PORT): + """ Initialize game test. + :param case_file_name: File name of JSON file containing expected game data. + JSON file must be located in folder FILE_FOLDER_NAME. + :param hostname: hostname to use to load server. + :param port: port to use to load server. + """ + full_file_path = os.path.join(self.FILE_FOLDER_NAME, case_file_name) + with open(full_file_path, 'rb') as file: + data = json.load(file) + self.case_name = case_file_name + self.map_name = data['map'] + self.phases = [ExpectedPhase(json_phase) for json_phase in data['phases']] + self.rules = set(data['rules']) + self.rules.add('POWER_CHOICE') + self.rules.add('REAL_TIME') + + self.test_server = None + self.io_loop = None # type: IOLoop + self.connection = None + self.admin_channel = None + self.admin_game = None + self.user_games = {} + self.future_games_ended = {} # type: dict{str, Future} + + self.hostname = hostname + self.port = port + + def terminate_game(self, power_name): + """ Tell Tornado that a power game is finished. """ + self.future_games_ended[power_name].set_result(None) + + @gen.coroutine + def on_power_phase_update(self, game, notification=None): + """ User game notification callback for game phase updated. + :param game: game + :param notification: notification + :type game: NetworkGame + :type notification: diplomacy.communication.notifications.GameProcessed | None + """ + print('We changed phase for power', game.power.name) + expected_data = game.data # type: ExpectedData + expected_data.move_forward() + if expected_data.phase_index >= len(expected_data.phases): + assert expected_data.phase_index == len(expected_data.phases) + self.terminate_game(game.data.power_name) + print('Game fully terminated at phase', game.phase) + else: + yield verify_current_phase(game) + + @gen.coroutine + def on_power_state_update(self, game, notification): + """ User game notification callback for game state update. + :param game: game + :param notification: notification + :type game: NetworkGame + :type notification: diplomacy.communication.notifications.GamePhaseUpdate + """ + if notification.phase_data_type == strings.PHASE: + yield self.on_power_phase_update(game, None) + +@gen.coroutine +def send_messages_if_needed(game, expected_messages): + """ Take messages to send in top of given messages list and send them. + :param game: a NetworkGame object. + :param expected_messages: an instance of ExpectedMessages. + :type game: NetworkGame + :type expected_messages: ExpectedMessages + """ + power_name = game.power.name + + if expected_messages.messages: + expected_messages.move_forward() + for message in expected_messages.next_messages_to_send: + if message.recipient == GLOBAL: + print('%s/sending global message (time %d)' % (power_name, message.time_sent)) + yield game.send_game_message(message=game.new_global_message(message.message)) + print('%s/sent global message (time %d)' % (power_name, message.time_sent)) + else: + print('%s/sending message to %s (time %d)' % (power_name, message.recipient, message.time_sent)) + yield game.send_game_message(message=game.new_power_message( + message.recipient, message.message)) + print('%s/sent message to %s (time %d)' % (power_name, message.recipient, message.time_sent)) + expected_messages.next_messages_to_send.clear() + +@gen.coroutine +def send_current_orders(game): + """ Send expected orders for current phase. + :param game: a Network game object. + :type game: NetworkGame + """ + expected_data = game.data # type: ExpectedData + orders_to_send = expected_data.expected_phase.get_power_orders(expected_data.power_name) + if orders_to_send is None: + orders_to_send = [] + print('%s/sending %d orders for phase %s: %s' % (expected_data.power_name, len(orders_to_send), + expected_data.expected_phase.name, orders_to_send)) + yield game.set_orders(orders=orders_to_send) + print('%s/sent orders for phase %s' % (expected_data.power_name, expected_data.expected_phase.name)) + +def on_message_received(game, notification): + """ User game notification callback for messages received. + :param game: a NetworkGame object, + :param notification: a notification received by this game. + :type game: NetworkGame + :type notification: diplomacy.communication.notifications.GameMessageReceived + """ + power_name = game.power.name + messages = game.data.messages # type: ExpectedMessages + + if not messages.has_messages_to_receive(): + raise AssertionError('%s/should not receive more messages.' % power_name) + + power_from = notification.message.sender + index_found = None + for index, expected_message in enumerate(messages.messages): + if expected_message.recipient == power_from: + raise AssertionError( + '%s/there are still messages to send to %s (%d) before receiving messages from him. Received: %s' + % (power_name, power_from, expected_message.time_sent, notification.message.message)) + elif expected_message.sender == power_from: + if notification.message.is_global(): + if not (expected_message.recipient == GLOBAL + and expected_message.message == notification.message.message): + raise AssertionError( + '%s/first expected message from %s does not match received global message: %s' + % (power_name, power_from, notification.message.message)) + else: + if not (expected_message.recipient == notification.message.recipient + and expected_message.message == notification.message.message): + raise AssertionError( + '%s/first expected message from %s does not match received power message: to %s: %s' + % (power_name, power_from, notification.message.recipient, notification.message.message)) + index_found = index + break + + if index_found is None: + raise AssertionError('%s/Received unknown message from %s to %s: %s' % ( + power_name, notification.message.sender, notification.message.recipient, notification.message.message)) + + expected_message = messages.messages.pop(index_found) + + print('%s/checked message (time %d)' % (power_name, expected_message.time_sent)) + +def on_admin_game_phase_update(admin_game, notification=None): + """ Admin game notification callback for game phase update. + :param admin_game: admin game + :param notification: notification + :type admin_game: NetworkGame + :type notification: diplomacy.communication.notifications.GameProcessed | None + """ + assert admin_game.is_omniscient_game() + expected_data = admin_game.data # type: ExpectedData + expected_data.move_forward() + print('=' * 80) + print('We changed phase for admin game, moving from phase', expected_data.phase_index, + 'to phase', (expected_data.phase_index + 1), '/', len(expected_data.phases)) + print('=' * 80) + + # state_history must not be empty. + assert len(admin_game.state_history) == expected_data.phase_index, ( + len(admin_game.state_history), expected_data.phase_index) + + # Verify previous game state. + if admin_game.state_history: + expected_state = expected_data.phases[expected_data.phase_index - 1].state + expected_engine = Game(initial_state=expected_state) + given_state = admin_game.state_history.last_value() + given_engine = Game(initial_state=given_state) + + print('Verifying expected previous phase', expected_engine.get_current_phase()) + print('Verifying game processing from previous phase to next phase.') + + other_expected_engine = Game(initial_state=expected_state) + other_expected_engine.process() + other_given_engine = Game(initial_state=given_state) + other_given_engine.rules.append('SOLITAIRE') + other_given_engine.process() + assert other_expected_engine.get_current_phase() == other_given_engine.get_current_phase(), ( + 'Computed expected next phase %s, got computed given next phase %s' + % (other_expected_engine.get_current_phase(), other_given_engine.get_current_phase()) + ) + + assert expected_engine.map_name == given_engine.map_name + assert expected_engine.get_current_phase() == given_engine.get_current_phase() + + expected_orders = expected_engine.get_orders() + given_orders = given_engine.get_orders() + assert len(expected_orders) == len(given_orders), (expected_orders, given_orders) + for power_name in given_orders: + assert power_name in expected_orders, power_name + given_power_orders = list(sorted(given_orders[power_name])) + expected_power_orders = list(sorted(expected_orders[power_name])) + assert expected_power_orders == given_power_orders, ( + 'Power orders for %s\nExpected: %s\nGiven: %s\nAll given: %s\n' + % (power_name, expected_power_orders, given_power_orders, given_orders)) + + expected_units = expected_engine.get_units() + given_units = expected_engine.get_units() + assert len(expected_units) == len(given_units) + for power_name in given_units: + assert power_name in expected_units, (power_name, expected_units, given_units) + expected_power_units = list(sorted(expected_units[power_name])) + given_power_units = list(sorted(given_units[power_name])) + assert expected_power_units == given_power_units, ( + power_name, expected_power_units, given_power_units, given_units) + + expected_centers = expected_engine.get_centers() + given_centers = given_engine.get_centers() + assert len(expected_centers) == len(given_centers), (expected_centers, given_centers) + for power_name in given_centers: + assert power_name in expected_centers + expected_power_centers = list(sorted(expected_centers[power_name])) + given_power_centers = list(sorted(given_centers[power_name])) + assert expected_power_centers == given_power_centers, ( + power_name, expected_power_centers, given_power_centers) + + assert expected_engine.get_hash() == given_engine.get_hash(), ( + expected_engine.get_hash(), given_engine.get_hash()) + + if expected_data.phase_index >= len(expected_data.phases): + assert expected_data.phase_index == len(expected_data.phases) + assert admin_game.state_history.last_value()['name'] == expected_data.phases[-1].name, ( + 'Wrong last phase, expected %s, got %s' + % (admin_game.state_history.last_value()['name'], expected_data.phases[-1].name) + ) + print('Admin game terminated.') + +def on_admin_game_state_update(admin_game, notification): + """ Admin game notification callback for game state update. + :param admin_game: admin game + :param notification: notification + :type admin_game: NetworkGame + :type notification: diplomacy.communication.notifications.GamePhaseUpdate + """ + if notification.phase_data_type == strings.PHASE: + on_admin_game_phase_update(admin_game, None) + +def on_admin_powers_controllers(admin_game, notification): + """ Admin game notification callback for powers controllers received (unexpected). + :param admin_game: game + :param notification: notification + :type admin_game: NetworkGame + :type notification: diplomacy.communication.notifications.PowersControllers + """ + LOGGER.warning('%d dummy power(s).', + len([controller for controller in notification.powers.values() if controller == strings.DUMMY])) + +def on_admin_game_status_update(admin_game, notification): + """ Admin game notification callback for game status update. + :param admin_game: admin game + :param notification: notification + :type admin_game: NetworkGame + """ + print('(admin game) game status of %s updated to %s' % (admin_game.role, admin_game.status)) + +@gen.coroutine +def play_phase(game, expected_messages): + """ Play a phase for a user game: + 1) Send messages + 2) wait for messages to receive + 3) send current orders. + :param game: user game + :param expected_messages: expected messages + :type game: NetworkGame + :type expected_messages: ExpectedMessages + """ + while expected_messages.has_messages_to_send(): + yield gen.sleep(10e-6) + yield send_messages_if_needed(game, expected_messages) + while expected_messages.has_messages_to_receive(): + yield gen.sleep(10e-6) + yield send_current_orders(game) + +@gen.coroutine +def on_game_status_update(game, notification): + """ User game notification callback for game status update. + Used to start the game locally when game started on server. + :param game: game + :param notification: notification + :type game: NetworkGame + :type notification: diplomacy.communication.notifications.GameStatusUpdate + """ + LOGGER.warning('Game status of %s updated to %s', game.role, game.status) + expected_data = game.data # type: ExpectedData + if not expected_data.playing and game.is_game_active: + # Game started on server. + expected_data.playing = True + print('Playing.') + yield play_phase(game, expected_data.messages) + +@gen.coroutine +def verify_current_phase(game): + """ Check and play current phase. + :param game: a NetWork game object. + :type game: NetworkGame + """ + expected_data = game.data # type: ExpectedData + + # Verify current phase. + expected_messages = expected_data.messages + print('=' * 80) + print('Checking expected phase', expected_data.expected_phase.name, + '(%d/%d) for' % (expected_data.phase_index + 1, len(expected_data.phases)), expected_data.power_name, + 'with', len(expected_data.messages.messages), 'messages.') + print('=' * 80) + # Verify phase name. + if game.current_short_phase != str(expected_data.expected_phase.name): + raise AssertionError(str(expected_data.expected_phase.name), str(game.current_short_phase)) + + if game.is_game_active: + yield play_phase(game, expected_messages) + +def get_user_game_fn(case_data, power_name): + """ Return a coroutine procedure that loads and play a user game for given power name. + :param case_data: case data + :param power_name: str + :return: a procedure. + :type case_data: CaseData + """ + + @gen.coroutine + def load_fn(): + """ Coroutine for loading power game for given power name. """ + yield load_power_game(case_data, power_name) + + return load_fn + +def get_future_game_done_fn(power_name): + """ Return a callback to call when a power game is finished. + Callback currently just prints a message to tell that power game is terminated. + :param power_name: power name of associated game. + :return: a callable that receives the future done when game is finished. + """ + + def game_done_fn(future): + """ Function called when related game is done. """ + print('Game ended (%s).' % power_name) + + return game_done_fn + +@gen.coroutine +def load_power_game(case_data, power_name): + """ Load and play a power game from admin game for given power name. + :type case_data: CaseData + """ + print('Loading game for power', power_name) + + username = 'user_%s' % power_name + password = 'password_%s' % power_name + try: + user_channel = yield case_data.connection.authenticate(username, password, create_user=True) + except exceptions.ResponseException: + print('User', username, 'seems to already exists. Try to login.') + user_channel = yield case_data.connection.authenticate(username, password, create_user=False) + print('User', username, 'connected.') + + user_game = yield user_channel.join_game(game_id=case_data.admin_game.game_id, power_name=power_name) + assert user_game.is_player_game() + assert user_game.power.name == power_name + + case_data.user_games[power_name] = user_game + print('Game created for user %s.' % username, len(user_game.messages), len(user_game.state_history)) + + # Set notification callback for user game to manage messages received. + user_game.add_on_game_status_update(on_game_status_update) + user_game.add_on_game_message_received(on_message_received) + user_game.add_on_game_processed(case_data.on_power_phase_update) + user_game.add_on_game_phase_update(case_data.on_power_state_update) + + # Save expected data into attribute user_game.data. + user_game.data = ExpectedData(power_name=power_name, phases=case_data.phases, phase_index=0) + # Start to play and test game. + yield verify_current_phase(user_game) + +@gen.coroutine +def main(case_data): + """ Test real game environment with one game and all power controlled (no dummy powers). + This method may be called form a non-test code to run a real game case. + :param case_data: test data + :type case_data: CaseData + """ + # ================ + # Initialize test. + # ================ + if case_data.admin_channel is None: + LOGGER.info('Creating connection, admin channel and admin game.') + case_data.connection = yield connect(case_data.hostname, case_data.port) + case_data.admin_channel = yield case_data.connection.authenticate('admin', 'password', create_user=False) + # NB: For all test cases, first game state should be default game engine state when starting. + # So, we don't need to pass game state of first expected phase when creating a server game. + case_data.admin_game = yield case_data.admin_channel.create_game( + map_name=case_data.map_name, rules=case_data.rules, deadline=0) + assert case_data.admin_game.power_choice + assert case_data.admin_game.real_time + case_data.admin_game.data = ExpectedData(power_name='', phases=case_data.phases, phase_index=0) + case_data.admin_game.add_on_game_status_update(on_admin_game_status_update) + case_data.admin_game.add_on_game_processed(on_admin_game_phase_update) + case_data.admin_game.add_on_game_phase_update(on_admin_game_state_update) + case_data.admin_game.add_on_powers_controllers(on_admin_powers_controllers) + + # ========== + # Test game. + # ========== + + # Get available maps to retrieve map power names. + available_maps = yield case_data.admin_channel.get_available_maps() + print('Map: %s, powers:' % case_data.map_name, + ', '.join(power_name for power_name in sorted(available_maps[case_data.map_name]))) + # Load one game per power name. + for power_name in available_maps[case_data.map_name]['powers']: + case_data.future_games_ended[power_name] = Future() + case_data.future_games_ended[power_name].add_done_callback(get_future_game_done_fn(power_name)) + case_data.io_loop.add_callback(get_user_game_fn(case_data, power_name)) + + # Wait to let power games play. + print('Running ...') + yield case_data.future_games_ended + print('All game terminated. Just wait a little ...') + yield gen.sleep(2) + print('End running.') + +def run(case_data, **server_kwargs): + """ Real test function called for a given case data. + Load a server (with optional given server kwargs), + call function main(case_data) as client code + and wait for main function to terminate. + :type case_data: CaseData + """ + + print() + io_loop = IOLoop() + io_loop.make_current() + common.Tornado.stop_loop_on_callback_error(io_loop) + case_data.io_loop = io_loop + case_data.test_server = Server(**server_kwargs) + + @gen.coroutine + def coroutine_func(): + """ Concrete call to main function. """ + yield main(case_data) + case_data.io_loop.stop() + print('Finished', case_data.case_name, 'at', common.timestamp_microseconds()) + + io_loop.add_callback(coroutine_func) + case_data.test_server.start(case_data.port, io_loop) + case_data.io_loop.clear_current() + case_data.io_loop.close() + case_data.test_server.backend.http_server.stop() + +def test_maps(): + """ Building required maps to avoid timeout on the primary test """ + for map_name in ('ancmed', 'colonial', 'empire', 'known_world_901', 'modern', 'standard', + 'standard_france_austria', 'standard_germany_italy', 'world'): + Map(map_name) + +def test_3(): + """ Test case 3. """ + case_data = CaseData('3.json') + run(case_data, ping_seconds=constants.DEFAULT_PING_SECONDS) + # We must clear server caches to allow to re-create a Server with same test case but different server attributes. + Server.__cache__.clear() + +def test_3_ping_1s(): + """ Test case 3 with small ping (1 second). """ + case_data = CaseData('3.json') + run(case_data, ping_seconds=1) + # We must clear server caches to allow to re-create a Server with same test case but different server attributes. + Server.__cache__.clear() |