diff options
Diffstat (limited to 'diplomacy/client')
-rw-r--r-- | diplomacy/client/__init__.py | 16 | ||||
-rw-r--r-- | diplomacy/client/channel.py | 156 | ||||
-rw-r--r-- | diplomacy/client/connection.py | 474 | ||||
-rw-r--r-- | diplomacy/client/game_instances_set.py | 81 | ||||
-rw-r--r-- | diplomacy/client/network_game.py | 199 | ||||
-rw-r--r-- | diplomacy/client/notification_managers.py | 266 | ||||
-rw-r--r-- | diplomacy/client/response_managers.py | 335 |
7 files changed, 1527 insertions, 0 deletions
diff --git a/diplomacy/client/__init__.py b/diplomacy/client/__init__.py new file mode 100644 index 0000000..4f2769f --- /dev/null +++ b/diplomacy/client/__init__.py @@ -0,0 +1,16 @@ +# ============================================================================== +# 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/>. +# ============================================================================== diff --git a/diplomacy/client/channel.py b/diplomacy/client/channel.py new file mode 100644 index 0000000..0403e7f --- /dev/null +++ b/diplomacy/client/channel.py @@ -0,0 +1,156 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Channel + - The channel object represents an authenticated connection over a socket. + - It has a token that it sends with every request to authenticate itself. +""" +import logging + +from tornado import gen + +from diplomacy.communication import requests +from diplomacy.utils import strings + +LOGGER = logging.getLogger(__name__) + +def req_fn(request_class, local_req_fn=None, **request_args): + """ Create channel request method that sends request with channel token. + :param request_class: class of request to send with channel request method. + :param local_req_fn: (optional) Channel method to use locally to try retrieving a data + instead of sending a request. If provided, local_req_fn is called with request args: + - if it returns anything else than None, then returned data is returned by channel request method. + - else, request class is still sent and channel request method follows standard path + (request sent, response received, response handler called and final handler result returned). + :param request_args: arguments to pass to request class to create the request object. + :return: a Channel method. + """ + + @gen.coroutine + def func(self, game_object=None, **kwargs): + """ Send an instance of request_class with given kwargs and game object. + :param self: Channel object who sends the request. + :param game_object: (optional) a NetworkGame object (required for game requests). + :param kwargs: request arguments. + :return: Data returned after response is received and handled by associated response manager. + See module diplomacy.client.response_managers about responses management. + :type game_object: diplomacy.client.network_game.NetworkGame + """ + kwargs.update(request_args) + if request_class.level == strings.GAME: + assert game_object is not None + kwargs[strings.TOKEN] = self.token + kwargs[strings.GAME_ID] = game_object.game_id + kwargs[strings.GAME_ROLE] = game_object.role + kwargs[strings.PHASE] = game_object.current_short_phase + else: + assert game_object is None + if request_class.level == strings.CHANNEL: + kwargs[strings.TOKEN] = self.token + if local_req_fn is not None: + local_ret = local_req_fn(self, **kwargs) + if local_ret is not None: + return local_ret + request = request_class(**kwargs) + return (yield self.connection.send(request, game_object)) + + return func + +class Channel(): + """ Channel - Represents an authenticated connection over a physical socket """ + __slots__ = ['connection', 'token', 'game_id_to_instances', '__weakref__'] + + def __init__(self, connection, token): + """ Initialize a channel. + :param connection: a Connection object. + :param token: Channel token. + :type connection: diplomacy.Connection + """ + self.connection = connection + self.token = token + self.game_id_to_instances = {} # {game id => GameInstances} + + def local_join_game(self, **kwargs): + """ Look for a local game with given kwargs intended to be used to build a JoinGame request. + Return None if no local game found, else local game found. + Game is identified with game ID and power name (optional). + If power name is None, we look for a "special" game (observer or omniscient game) + loaded locally. Note that there is at most 1 special game per channel + game ID: + either observer or omniscient, not both. + """ + game_id = kwargs[strings.GAME_ID] + power_name = kwargs.get(strings.POWER_NAME, None) + if game_id in self.game_id_to_instances: + if power_name is not None: + return self.game_id_to_instances[game_id].get(power_name) + return self.game_id_to_instances[game_id].get_special() + return None + + # =================== + # Public channel API. + # =================== + + create_game = req_fn(requests.CreateGame) + get_available_maps = req_fn(requests.GetAvailableMaps) + get_playable_powers = req_fn(requests.GetPlayablePowers) + join_game = req_fn(requests.JoinGame, local_req_fn=local_join_game) + join_powers = req_fn(requests.JoinPowers) + list_games = req_fn(requests.ListGames) + get_games_info = req_fn(requests.GetGamesInfo) + + # User Account API. + delete_account = req_fn(requests.DeleteAccount) + logout = req_fn(requests.Logout) + + # Admin / Moderator API. + make_omniscient = req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.PROMOTE) + remove_omniscient = req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.DEMOTE) + promote_administrator = req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.PROMOTE) + demote_administrator = req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.DEMOTE) + promote_moderator = req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.PROMOTE) + demote_moderator = req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.DEMOTE) + + # ================ + # Public game API. + # ================ + + get_dummy_waiting_powers = req_fn(requests.GetDummyWaitingPowers) + get_phase_history = req_fn(requests.GetPhaseHistory) + leave_game = req_fn(requests.LeaveGame) + send_game_message = req_fn(requests.SendGameMessage) + set_orders = req_fn(requests.SetOrders) + + clear_centers = req_fn(requests.ClearCenters) + clear_orders = req_fn(requests.ClearOrders) + clear_units = req_fn(requests.ClearUnits) + + wait = req_fn(requests.SetWaitFlag, wait=True) + no_wait = req_fn(requests.SetWaitFlag, wait=False) + vote = req_fn(requests.Vote) + save = req_fn(requests.SaveGame) + synchronize = req_fn(requests.Synchronize) + + # Admin / Moderator API. + delete_game = req_fn(requests.DeleteGame) + kick_powers = req_fn(requests.SetDummyPowers) + set_state = req_fn(requests.SetGameState) + process = req_fn(requests.ProcessGame) + query_schedule = req_fn(requests.QuerySchedule) + start = req_fn(requests.SetGameStatus, status=strings.ACTIVE) + pause = req_fn(requests.SetGameStatus, status=strings.PAUSED) + resume = req_fn(requests.SetGameStatus, status=strings.ACTIVE) + cancel = req_fn(requests.SetGameStatus, status=strings.CANCELED) + draw = req_fn(requests.SetGameStatus, status=strings.COMPLETED) diff --git a/diplomacy/client/connection.py b/diplomacy/client/connection.py new file mode 100644 index 0000000..f9eb628 --- /dev/null +++ b/diplomacy/client/connection.py @@ -0,0 +1,474 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Connection object, handling an internal websocket tornado connection. """ +import logging +import weakref +from datetime import timedelta + +from tornado import gen, ioloop +from tornado.concurrent import Future +from tornado.iostream import StreamClosedError +from tornado.locks import Event +from tornado.websocket import websocket_connect, WebSocketClosedError + +import ujson as json + +from diplomacy.client import notification_managers +from diplomacy.client.response_managers import RequestFutureContext, handle_response +from diplomacy.communication import notifications, requests, responses +from diplomacy.utils import exceptions, strings, constants + +LOGGER = logging.getLogger(__name__) + +class MessageWrittenCallback(): + """ Helper class representing callback to call on a connection when a request is written in a websocket. """ + __slots__ = ['request_context'] + + def __init__(self, request_context): + """ Initialize the callback object. + :param request_context: a request context + :type request_context: RequestFutureContext + """ + self.request_context = request_context + + def callback(self, msg_future): + """ Called when request is effectively written on socket, and move the request + from `request to send` to `request assumed sent`. + """ + # Remove request context from `requests to send` in any case. + connection = self.request_context.connection # type: Connection + request_id = self.request_context.request_id + exception = msg_future.exception() + if exception is not None: + if isinstance(exception, (WebSocketClosedError, StreamClosedError)): + # Connection suddenly closed. + # Request context was stored in connection.requests_to_send + # and will be re-sent when reconnection succeeds. + # For more details, see method Connection.write_request(). + LOGGER.error('Connection was closed when sending a request. Silently waiting for a reconnection.') + else: + LOGGER.error('Fatal error occurred while writing a request.') + self.request_context.future.set_exception(exception) + else: + connection.requests_waiting_responses[request_id] = self.request_context + +class Reconnection(): + """ Class performing reconnection work for a given connection. + + Class properties: + ================= + + - connection: Connection object to reconnect. + + - games_phases: dictionary mapping each game address (game ID + game role) to server game info: + {game ID => {game role => responses.DataGamePhase}} + Server game info is a DataGamePhase response sent by server as response to a Synchronize request. + It contains 3 fields: game ID, current server game phase and current server game timestamp. + We currently use only game phase. + + - n_expected_games: number of games registered in games_phases. + + - n_synchronized_games: number of games already synchronized. + + Reconnection procedure: + ======================= + + - Mark all waiting responses as `re-sent` (may be useful on server-side) and + move them back to responses_to_send. + + - Remove all previous synchronization requests that are not yet sent. We will send new synchronization + requests with latest games timestamps. Future associated to removed requests will raise an exception. + + - Initialize games_phases associating None to each game object currently opened in connection. + + - Send synchronization request for each game object currently opened in connection. For each game: + + - server will send a response describing current server game phase (current phase and timestamp). This info + will be used to check local requests to send. Note that concrete synchronization is done via notifications. + Thus, when server responses is received, game synchronization may not be yet terminated, but at least + we will now current server game phase. + + - Server response is saved in games_phases (replacing None associated to game object). + + - n_synchronized_games is incremented. + + - When sync responses are received for all games registered in games_phases + (n_expected_games == n_synchronized_games), we can finalize reconnection: + + - Remove every phase-dependent game request not yet sent for which phase does not match + server game phase. Futures associated to removed request will raise an exception. + + - Finally send all remaining requests. + + These requests may be marked as re-sent. + For these requests, server is (currently) responsible for checking if they don't represent + a duplicated query. + + """ + + __slots__ = ['connection', 'games_phases', 'n_expected_games', 'n_synchronized_games'] + + def __init__(self, connection): + """ Initialize reconnection data/ + :param connection: connection to reconnect. + :type connection: Connection + """ + self.connection = connection + self.games_phases = {} + self.n_expected_games = 0 + self.n_synchronized_games = 0 + + def reconnect(self): + """ Perform concrete reconnection work. """ + + # Mark all waiting responses as `re-sent` and move them back to responses_to_send. + for waiting_context in self.connection.requests_waiting_responses.values(): # type: RequestFutureContext + waiting_context.request.re_sent = True + self.connection.requests_to_send.update(self.connection.requests_waiting_responses) + self.connection.requests_waiting_responses.clear() + + # Remove all previous synchronization requests. + requests_to_send_updated = {} + for context in self.connection.requests_to_send.values(): # type: RequestFutureContext + if isinstance(context.request, requests.Synchronize): + context.future.set_exception(exceptions.DiplomacyException( + 'Sync request invalidated for game ID %s.' % context.request.game_id)) + else: + requests_to_send_updated[context.request.request_id] = context + self.connection.requests_to_send = requests_to_send_updated + + # Count games to synchronize. + for channel in self.connection.channels.values(): + for game_instance_set in channel.game_id_to_instances.values(): + for game in game_instance_set.get_games(): + self.games_phases.setdefault(game.game_id, {})[game.role] = None + self.n_expected_games += 1 + + if self.n_expected_games: + # Synchronize games. + for channel in self.connection.channels.values(): + for game_instance_set in channel.game_id_to_instances.values(): + for game in game_instance_set.get_games(): + game.synchronize().add_done_callback(self.generate_sync_callback(game)) + else: + # No game to sync, finish sync now. + self.sync_done() + + def generate_sync_callback(self, game): + """ Generate callback to call when response to sync request is received for given game. + :param game: game + :return: a callback. + :type game: diplomacy.client.network_game.NetworkGame + """ + + def on_sync(future): + """ Callback. If exception occurs, print it as logging error. Else, register server response, + and move forward to final reconnection work if all games received sync responses. + """ + exception = future.exception() + if exception is not None: + LOGGER.error(str(exception)) + else: + self.games_phases[game.game_id][game.role] = future.result() + self.n_synchronized_games += 1 + if self.n_synchronized_games == self.n_expected_games: + self.sync_done() + + return on_sync + + def sync_done(self): + """ Final reconnection work. Remove obsolete game requests and send remaining requests. """ + + # All sync requests sent have finished. + # Remove all obsolete game requests from connection. + # A game request is obsolete if it's phase-dependent and if its phase does not match current game phase. + + request_to_send_updated = {} + for context in self.connection.requests_to_send.values(): # type: RequestFutureContext + keep = True + if context.request.level == strings.GAME and context.request.phase_dependent: + request_phase = context.request.phase + server_phase = self.games_phases[context.request.game_id][context.request.game_role].phase + if request_phase != server_phase: + # Request is obsolete. + context.future.set_exception(exceptions.DiplomacyException( + 'Game %s: request %s: request phase %s does not match current server game phase %s.' + % (context.request.game_id, context.request.name, request_phase, server_phase))) + keep = False + if keep: + request_to_send_updated[context.request.request_id] = context + + LOGGER.debug('Keep %d/%d old requests to send.', + len(request_to_send_updated), len(self.connection.requests_to_send)) + + # All requests to send are stored in request_to_send_updated. + # Then we can empty connection.requests_to_send. + # If we fail to send a request, it will be re-added again. + self.connection.requests_to_send.clear() + + # Send requests. + for request_to_send in request_to_send_updated.values(): # type: RequestFutureContext + self.connection.write_request(request_to_send).add_done_callback( + MessageWrittenCallback(request_to_send).callback) + + # We are reconnected. + self.connection.is_reconnecting.set() + + LOGGER.info('Done reconnection work.') + +class Connection(): + """ Connection class. Properties: + - hostname: hostname to connect (e.g. 'localhost') + - port: port to connect (e.g. 8888) + - use_ssl: boolean telling if connection should be securized (True) or not (False). + - url (auto): websocket url to connect (generated with hostname and port) + - connection: a tornado websocket connection object + - connection_count: number of successful connections from this Connection object. + Used to check if message callbacks is already launched (if count > 0). + - connection_lock: a tornado lock used to access tornado websocket connection object + - is_connecting: a tornado Event used to keep connection status. + No request can be sent while is_connecting. + If connected, Synchronize requests can be sent immediately even if is_reconnecting. + Other requests must wait full reconnection. + - is_reconnecting: a tornado Event used to keep re-connection status. + Non-synchronize request cannot be sent while is_reconnecting. + If reconnected, all requests can be sent. + - channels: a WeakValueDictionary mapping channel token to Channel object. + - requests_to_send: a dictionary mapping a request ID to the context of a request + **not sent**. If we are disconnected when trying to send a request, then request + context is added to this dictionary to be send later once reconnected. + - requests_waiting_responses: a dictionary mapping a request ID to the context of a + request **sent**. Contains requests that are waiting for a server response. + """ + __slots__ = ['hostname', 'port', 'use_ssl', 'connection', 'is_connecting', 'is_reconnecting', 'connection_count', + 'channels', 'requests_to_send', 'requests_waiting_responses'] + + def __init__(self, hostname, port, use_ssl=False): + self.hostname = hostname + self.port = port + self.use_ssl = bool(use_ssl) + + self.connection = None + self.is_connecting = Event() + self.is_reconnecting = Event() + + self.connection_count = 0 + + self.channels = weakref.WeakValueDictionary() # {token => Channel} + + self.requests_to_send = {} # type: dict{str, RequestFutureContext} + self.requests_waiting_responses = {} # type: dict{str, RequestFutureContext} + + # When connection is created, we are not yet connected, but reconnection does not matter + # (we consider we are reconnected). + self.is_reconnecting.set() + + url = property(lambda self: '%s://%s:%d' % ('wss' if self.use_ssl else 'ws', self.hostname, self.port)) + + @gen.coroutine + def _connect(self): + """ Create (force) a tornado websocket connection. Try NB_CONNECTION_ATTEMPTS attempts, + waiting for ATTEMPT_DELAY_SECONDS seconds between 2 attempts. + Raise an exception if it cannot connect. + """ + + # We are connecting. + self.is_connecting.clear() + + # Create a connection (currently using websockets). + self.connection = None + for attempt_index in range(constants.NB_CONNECTION_ATTEMPTS): + try: + future_connection = websocket_connect(self.url) + self.connection = yield gen.with_timeout( + timedelta(seconds=constants.ATTEMPT_DELAY_SECONDS), future_connection) + break + except (gen.TimeoutError, ConnectionAbortedError, ConnectionError, + ConnectionRefusedError, ConnectionResetError) as ex: + if attempt_index + 1 == constants.NB_CONNECTION_ATTEMPTS: + raise ex + LOGGER.warning('Connection failing (attempt %d), retrying.', attempt_index + 1) + yield gen.sleep(constants.ATTEMPT_DELAY_SECONDS) + + if not self.connection_count: + # Start receiving messages as soon as we are connected. + ioloop.IOLoop.current().add_callback(self._handle_socket_messages) + + # We are connected. + self.connection_count += 1 + self.is_connecting.set() + + LOGGER.info('Connection succeeds.') + + @gen.coroutine + def _reconnect(self): + """ Reconnect. """ + LOGGER.info('Trying to reconnect.') + # We are reconnecting. + self.is_reconnecting.clear() + yield self._connect() + # We will be reconnected when method Reconnection.sync_done() will finish. + Reconnection(self).reconnect() + + def _register_to_send(self, request_context): + """ Register given request context as a request to send as soon as possible. + :param request_context: context of request to send. + :type request_context: RequestFutureContext + """ + self.requests_to_send[request_context.request_id] = request_context + + def write_request(self, request_context): + """ Write a request into internal connection object. + :param request_context: context of request to send. + :type request_context: RequestFutureContext + """ + future = Future() + request = request_context.request + + def on_message_written(write_future): + """ 3) Writing returned, set future as done (with writing result) or with writing exception. """ + exception = write_future.exception() + if exception is not None: + future.set_exception(exception) + else: + future.set_result(write_future.result()) + + def on_connected(reconnected_future): + """ 2) Send request. """ + exception = reconnected_future.exception() + if exception is not None: + LOGGER.error('Fatal (re)connection error occurred while sending a request.') + future.set_exception(exception) + else: + try: + if self.connection is None: + raise WebSocketClosedError() + write_future = self.connection.write_message(request.json()) + except (WebSocketClosedError, StreamClosedError) as exc: + # We were disconnected. + # Save request context as a request to send. + # We will re-try to send it later once reconnected. + self._register_to_send(request_context) + # Transfer exception to returned future. + future.set_exception(exc) + else: + write_future.add_done_callback(on_message_written) + + # 1) Synchronize requests just wait for connection. + # Other requests wait for reconnection (which also implies connection). + if isinstance(request, requests.Synchronize): + self.is_connecting.wait().add_done_callback(on_connected) + else: + self.is_reconnecting.wait().add_done_callback(on_connected) + + return future + + @gen.coroutine + def connect(self): + """ Effectively connect this object. """ + LOGGER.info('Trying to connect.') + yield self._connect() + + def _on_socket_message(self, socket_message): + """ Manage given socket_message (string), + that may be a string representation of either a request or a notification. + """ + + # Check response format and run callback (if defined). + try: + json_message = json.loads(socket_message) + except ValueError: + LOGGER.exception('Unable to parse JSON from a socket message.') + return + + if not isinstance(json_message, dict): + LOGGER.error("Unable to convert a JSON string to a dictionary.") + return + request_id = json_message.get(strings.REQUEST_ID, None) + notification_id = json_message.get(strings.NOTIFICATION_ID, None) + + if request_id: + if request_id not in self.requests_waiting_responses: + LOGGER.error('Unknown request.') + return + request_context = self.requests_waiting_responses.pop(request_id) # type: RequestFutureContext + try: + response = responses.parse_dict(json_message) + managed_data = handle_response(request_context, response) + request_context.future.set_result(managed_data) + except exceptions.ResponseException as ex: + LOGGER.error('Error received for request %s', request_context.request.name) + request_context.future.set_exception(ex) + + elif notification_id: + notification = notifications.parse_dict(json_message) + if notification.token not in self.channels: + LOGGER.error('Unknown notification: %s', notification.name) + return + notification_managers.handle_notification(self, notification) + else: + LOGGER.error('Unknown socket message.') + + @gen.coroutine + def _handle_socket_messages(self): + """ Main looping method used to received connection messages. """ + while True: + msg = yield self.connection.read_message() + if msg is None: + # Reconnect. + LOGGER.error('Disconnected.') + yield self._reconnect() + else: + # Check response format and run callback (if defined). + self._on_socket_message(msg) + + # Public methods. + + @gen.coroutine + def authenticate(self, username, password, create_user=False): + """ Send a SignIn request. + :param username: username + :param password: password + :param create_user: boolean indicating if you want to create a user or login to and existing user. + :return: a Channel object representing the authentication. + """ + request = requests.SignIn(username=username, password=password, create_user=create_user) + return (yield self.send(request)) + + def send(self, request, for_game=None): + """ Send a request. + :param request: request object. + :param for_game: (optional) NetworkGame object (required for game requests). + :return: a Future that returns the response handler result of this request. + """ + request_future = Future() + request_context = RequestFutureContext(request=request, future=request_future, connection=self, game=for_game) + + self.write_request(request_context).add_done_callback(MessageWrittenCallback(request_context).callback) + return gen.with_timeout(timedelta(seconds=constants.REQUEST_TIMEOUT_SECONDS), request_future) + +@gen.coroutine +def connect(hostname, port): + """ Connect to given hostname and port. + :param hostname: a hostname + :param port: a port + :return: a Connection object connected. + :rtype: Connection + """ + connection = Connection(hostname, port) + yield connection.connect() + return connection diff --git a/diplomacy/client/game_instances_set.py b/diplomacy/client/game_instances_set.py new file mode 100644 index 0000000..f8c22b9 --- /dev/null +++ b/diplomacy/client/game_instances_set.py @@ -0,0 +1,81 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Set of games instances (NetworkGame objects) for a same game ID for 1 channel. + Contains at most 1 game instance per map power + 1 "special" game which is either + an observer game or an omniscient game. A game instance set cannot contain both + an observer game and an omniscient game because 1 channel (ie. 1 user connected + with 1 token) can be either an observer or an omniscient, + but not both at same time. +""" +import weakref + +from diplomacy.engine.game import Game +from diplomacy.utils import exceptions + +class GameInstancesSet(): + """ Game Instances Set class. """ + __slots__ = ['game_id', 'games', 'current_observer_type'] + + def __init__(self, game_id): + """ Initialize a game instances set. + :param game_id: game ID of game instances to store. + """ + self.game_id = game_id + self.games = weakref.WeakValueDictionary() # {power name => NetworkGame} + self.current_observer_type = None + + def get_games(self): + """ Return a sequence of stored game instances. """ + return self.games.values() + + def get(self, power_name): + """ Return game instance associated to given power name. """ + return self.games.get(power_name, None) + + def get_special(self): + """ Return stored special game, or None if no special game found. """ + return self.games.get(self.current_observer_type, None) if self.current_observer_type else None + + def remove(self, role): + """ Remove game instance associated to given game role. """ + return self.games.pop(role, None) + + def remove_special(self): + """ Remove special gme. """ + self.games.pop(self.current_observer_type, None) + + def add(self, game): + """ Add given game. + :param game: a NetworkGame object. + :type game: diplomacy.client.network_game.NetworkGame + """ + assert self.game_id == game.game_id + if Game.is_player_game(game): + if game.role in self.games: + raise exceptions.DiplomacyException('Power name %s already in game instances set.' % game.role) + elif Game.is_observer_game(game): + if self.current_observer_type is not None: + raise exceptions.DiplomacyException('Previous special game %s must be removed before adding new one.' + % self.current_observer_type) + self.current_observer_type = game.role + else: + assert Game.is_omniscient_game(game) + if self.current_observer_type is not None: + raise exceptions.DiplomacyException('Previous special game %s must be removed before adding new one.' + % self.current_observer_type) + self.current_observer_type = game.role + self.games[game.role] = game diff --git a/diplomacy/client/network_game.py b/diplomacy/client/network_game.py new file mode 100644 index 0000000..e75687f --- /dev/null +++ b/diplomacy/client/network_game.py @@ -0,0 +1,199 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Game object used on client side. """ +import logging + +from diplomacy.client.channel import Channel +from diplomacy.communication import notifications +from diplomacy.engine.game import Game +from diplomacy.utils.exceptions import DiplomacyException +from diplomacy.utils.game_phase_data import GamePhaseData + +LOGGER = logging.getLogger(__name__) + +def game_request_method(channel_method): + """Create a game request method that calls channel counterpart.""" + + def func(self, **kwargs): + """ Call channel-related method to send a game request with given kwargs. """ + # NB: Channel method returns a future. + if not self.channel: + raise DiplomacyException('Invalid client game.') + return channel_method(self.channel, game_object=self, **kwargs) + + return func + +def callback_setting_method(notification_class): + """ Create a callback setting method for a given notification class. """ + + def func(self, notification_callback): + """ Add given callback for this game notification class. """ + self.add_notification_callback(notification_class, notification_callback) + + return func + +def callback_clearing_method(notification_class): + """ Create a callback clearing method for a given notification class. """ + + def func(self): + """ Clear user callbacks for this game notification class. """ + self.clear_notification_callbacks(notification_class) + + return func + +class NetworkGame(Game): + """ NetworkGame class. Properties: + - channel: associated Channel object. + - notification_callbacks: dict mapping a notification class name to a callback to be called + when a corresponding game notification is received. + """ + __slots__ = ['channel', 'notification_callbacks', 'data', '__weakref__'] + + def __init__(self, channel, received_game): + """ Initialize network game object with a channel and a game object sent by server. + :param channel: a Channel object. + :param received_game: a Game object. + :type channel: diplomacy.client.channel.Channel + :type received_game: diplomacy.Game + """ + self.channel = channel + self.notification_callbacks = {} # {notification_class => [callback(game, notification)]} + self.data = None + # Initialize parent class with Jsonable attributes from received game. + # Received game should contain a valid `initial_state` attribute that will be used + # to set client game state. + super(NetworkGame, self).__init__(**{key: getattr(received_game, key) for key in received_game.get_model()}) + + # =========== + # Public API. + # =========== + + # NB: Method get_all_possible_orders() is only local in Python code, + # but is still a network call from web interface. + get_phase_history = game_request_method(Channel.get_phase_history) + leave = game_request_method(Channel.leave_game) + send_game_message = game_request_method(Channel.send_game_message) + set_orders = game_request_method(Channel.set_orders) + + clear_centers = game_request_method(Channel.clear_centers) + clear_orders = game_request_method(Channel.clear_orders) + clear_units = game_request_method(Channel.clear_units) + + wait = game_request_method(Channel.wait) + no_wait = game_request_method(Channel.no_wait) + vote = game_request_method(Channel.vote) + save = game_request_method(Channel.save) + + def synchronize(self): + """ Send a Synchronize request to synchronize this game with associated server game. """ + if not self.channel: + raise DiplomacyException('Invalid client game.') + return self.channel.synchronize(game_object=self, timestamp=self.get_latest_timestamp()) + + # Admin / Moderator API. + delete = game_request_method(Channel.delete_game) + kick_powers = game_request_method(Channel.kick_powers) + set_state = game_request_method(Channel.set_state) + process = game_request_method(Channel.process) + query_schedule = game_request_method(Channel.query_schedule) + start = game_request_method(Channel.start) + pause = game_request_method(Channel.pause) + resume = game_request_method(Channel.resume) + cancel = game_request_method(Channel.cancel) + draw = game_request_method(Channel.draw) + + # =============================== + # Notification callback settings. + # =============================== + + add_on_cleared_centers = callback_setting_method(notifications.ClearedCenters) + add_on_cleared_orders = callback_setting_method(notifications.ClearedOrders) + add_on_cleared_units = callback_setting_method(notifications.ClearedUnits) + add_on_game_deleted = callback_setting_method(notifications.GameDeleted) + add_on_game_message_received = callback_setting_method(notifications.GameMessageReceived) + add_on_game_processed = callback_setting_method(notifications.GameProcessed) + add_on_game_phase_update = callback_setting_method(notifications.GamePhaseUpdate) + add_on_game_status_update = callback_setting_method(notifications.GameStatusUpdate) + add_on_omniscient_updated = callback_setting_method(notifications.OmniscientUpdated) + add_on_power_orders_flag = callback_setting_method(notifications.PowerOrdersFlag) + add_on_power_orders_update = callback_setting_method(notifications.PowerOrdersUpdate) + add_on_power_vote_updated = callback_setting_method(notifications.PowerVoteUpdated) + add_on_power_wait_flag = callback_setting_method(notifications.PowerWaitFlag) + add_on_powers_controllers = callback_setting_method(notifications.PowersControllers) + add_on_vote_count_updated = callback_setting_method(notifications.VoteCountUpdated) + add_on_vote_updated = callback_setting_method(notifications.VoteUpdated) + + clear_on_cleared_centers = callback_clearing_method(notifications.ClearedCenters) + clear_on_cleared_orders = callback_clearing_method(notifications.ClearedOrders) + clear_on_cleared_units = callback_clearing_method(notifications.ClearedUnits) + clear_on_game_deleted = callback_clearing_method(notifications.GameDeleted) + clear_on_game_message_received = callback_clearing_method(notifications.GameMessageReceived) + clear_on_game_processed = callback_clearing_method(notifications.GameProcessed) + clear_on_game_phase_update = callback_clearing_method(notifications.GamePhaseUpdate) + clear_on_game_status_update = callback_clearing_method(notifications.GameStatusUpdate) + clear_on_omniscient_updated = callback_clearing_method(notifications.OmniscientUpdated) + clear_on_power_orders_flag = callback_clearing_method(notifications.PowerOrdersFlag) + clear_on_power_orders_update = callback_clearing_method(notifications.PowerOrdersUpdate) + clear_on_power_vote_updated = callback_clearing_method(notifications.PowerVoteUpdated) + clear_on_power_wait_flag = callback_clearing_method(notifications.PowerWaitFlag) + clear_on_powers_controllers = callback_clearing_method(notifications.PowersControllers) + clear_on_vote_count_updated = callback_clearing_method(notifications.VoteCountUpdated) + clear_on_vote_updated = callback_clearing_method(notifications.VoteUpdated) + + def add_notification_callback(self, notification_class, notification_callback): + """ Add a callback for a notification. + :param notification_class: a notification class + :param notification_callback: callback to add. + """ + assert callable(notification_callback) + if notification_class not in self.notification_callbacks: + self.notification_callbacks[notification_class] = [notification_callback] + else: + self.notification_callbacks[notification_class].append(notification_callback) + + def clear_notification_callbacks(self, notification_class): + """ Remove all user callbacks for a notification. + :param notification_class: a notification class + """ + self.notification_callbacks.pop(notification_class, None) + + def notify(self, notification): + """ Notify game with given notification (call associated callbacks if defined). """ + for callback in self.notification_callbacks.get(type(notification), ()): + callback(self, notification) + + def set_phase_data(self, phase_data, clear_history=True): + """ Overwrite base method to prevent call to channel methods. """ + if not phase_data: + return + if isinstance(phase_data, GamePhaseData): + phase_data = [phase_data] + elif not isinstance(phase_data, list): + phase_data = list(phase_data) + + if clear_history: + self._clear_history() + + for game_phase_data in phase_data[:-1]: # type: GamePhaseData + Game.extend_phase_history(self, game_phase_data) + + current_phase_data = phase_data[-1] # type: GamePhaseData + Game.set_state(self, current_phase_data.state, clear_history=clear_history) + for power_name, power_orders in current_phase_data.orders.items(): + Game.set_orders(self, power_name, power_orders) + self.messages = current_phase_data.messages.copy() + # We ignore 'results' for current phase data. diff --git a/diplomacy/client/notification_managers.py b/diplomacy/client/notification_managers.py new file mode 100644 index 0000000..52b0b14 --- /dev/null +++ b/diplomacy/client/notification_managers.py @@ -0,0 +1,266 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Notification managers (client side). """ +# pylint: disable=unused-argument +import logging + +from diplomacy.client.network_game import NetworkGame +from diplomacy.communication import notifications +from diplomacy.engine.game import Game +from diplomacy.utils import exceptions, strings + +LOGGER = logging.getLogger(__name__) + +def _get_game_to_notify(connection, notification): + """ Get notified game from connection using notification parameters. + :param connection: connection that receives the notification. + :param notification: notification received. + :return: a NetWorkGame instance, or None if no game found. + :type connection: diplomacy.Connection + :type notification: diplomacy.communication.notifications._GameNotification + """ + channel = connection.channels.get(notification.token, None) + if channel and notification.game_id in channel.game_id_to_instances: + return channel.game_id_to_instances[notification.game_id].get(notification.game_role) + return None + +def on_account_deleted(channel, notification): + """ Manage notification AccountDeleted. + :param channel: channel associated to received notification. + :param notification: received notification. + :type channel: diplomacy.client.channel.Channel + """ + # We remove channel from related connection. + channel.connection.channels.pop(channel.token) + +def on_cleared_centers(game, notification): + """ Manage notification ClearedCenters. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.ClearedCenters + """ + Game.clear_centers(game, notification.power_name) + +def on_cleared_orders(game, notification): + """ Manage notification ClearedOrders. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.ClearedOrders + """ + Game.clear_orders(game, notification.power_name) + +def on_cleared_units(game, notification): + """ Manage notification ClearedUnits. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.ClearedUnits + """ + Game.clear_units(game, notification.power_name) + +def on_powers_controllers(game, notification): + """ Manage notification PowersControllers. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.PowersControllers + """ + if Game.is_player_game(game) and notification.powers[game.power.name] != game.power.get_controller(): + # Player is now invalid. We just remove game from related channel. + game.channel.game_id_to_instances[game.game_id].remove(game.power.name) + else: + # In any other case, update powers controllers. + Game.update_powers_controllers(game, notification.powers, notification.timestamps) + +def on_game_deleted(game, notification): + """ Manage notification GameDeleted. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + """ + # We remove game from related channel. + if Game.is_player_game(game): + game.channel.game_id_to_instances[game.game_id].remove(game.power.name) + else: + game.channel.game_id_to_instances[game.game_id].remove_special() + +def on_game_message_received(game, notification): + """ Manage notification GameMessageReceived.. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.GameMessageReceived + """ + Game.add_message(game, notification.message) + +def on_game_processed(game, notification): + """ Manage notification GamePhaseUpdate (for omniscient and observer games). + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.GameProcessed + """ + game.set_phase_data([notification.previous_phase_data, notification.current_phase_data], clear_history=False) + +def on_game_phase_update(game, notification): + """ Manage notification GamePhaseUpdate. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.GamePhaseUpdate + """ + if notification.phase_data_type == strings.STATE_HISTORY: + Game.extend_phase_history(game, notification.phase_data) + else: + game.set_phase_data(notification.phase_data) + +def on_game_status_update(game, notification): + """ Manage notification GameStatusUpdate. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.GameStatusUpdate + """ + Game.set_status(game, notification.status) + +def on_omniscient_updated(game, notification): + """ Manage notification OmniscientUpdated. + :param game: game associated to received notification. + :param notification: received notification. + :type game: diplomacy.client.network_game.NetworkGame + :type notification: notifications.OmniscientUpdated + """ + assert not Game.is_player_game(game) + if Game.is_observer_game(game): + assert notification.grade_update == strings.PROMOTE + assert notification.game.is_omniscient_game() + else: + assert notification.grade_update == strings.DEMOTE + assert notification.game.is_observer_game() + # Save client game channel and invalidate client game. + channel = game.channel + game.channel = None + channel.game_id_to_instances[notification.game_id].remove(game.role) + # Create a new client game with previous client game channel game sent by server. + new_game = NetworkGame(channel, notification.game) + new_game.notification_callbacks.update({key: value.copy() for key, value in game.notification_callbacks.items()}) + new_game.data = game.data + channel.game_id_to_instances[notification.game_id].add(new_game) + +def on_power_orders_update(game, notification): + """ Manage notification PowerOrdersUpdate. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.PowerOrdersUpdate + """ + Game.set_orders(game, notification.power_name, notification.orders) + +def on_power_orders_flag(game, notification): + """ Manage notification PowerOrdersFlag. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.PowerOrdersFlag + """ + # A power should not receive an order flag notification for itself. + assert game.is_player_game() and game.power.name != notification.power_name + game.get_power(notification.power_name).order_is_set = notification.order_is_set + +def on_power_vote_updated(game, notification): + """ Manage notification PowerVoteUpdated (for power game). + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.PowerVoteUpdated + """ + assert Game.is_player_game(game) + game.power.vote = notification.vote + +def on_power_wait_flag(game, notification): + """ Manage notification PowerWaitFlag. + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.PowerWaitFlag + """ + Game.set_wait(game, notification.power_name, notification.wait) + +def on_vote_count_updated(game, notification): + """ Manage notification VoteCountUpdated (for observer game). + :param game: game associated to received notification. + :param notification: received notification. + :type game: diplomacy.client.network_game.NetworkGame + """ + assert Game.is_observer_game(game) + +def on_vote_updated(game, notification): + """ Manage notification VoteUpdated (for omniscient game). + :param game: a Network game + :param notification: notification received + :type game: diplomacy.client.network_game.NetworkGame + :type notification: diplomacy.communication.notifications.VoteUpdated + """ + assert Game.is_omniscient_game(game) + for power_name, vote in notification.vote.items(): + Game.get_power(game, power_name).vote = vote + +# Mapping dictionary from notification class to notification handler function. +MAPPING = { + notifications.AccountDeleted: on_account_deleted, + notifications.ClearedCenters: on_cleared_centers, + notifications.ClearedOrders: on_cleared_orders, + notifications.ClearedUnits: on_cleared_units, + notifications.GameDeleted: on_game_deleted, + notifications.GameMessageReceived: on_game_message_received, + notifications.GameProcessed: on_game_processed, + notifications.GamePhaseUpdate: on_game_phase_update, + notifications.GameStatusUpdate: on_game_status_update, + notifications.OmniscientUpdated: on_omniscient_updated, + notifications.PowerOrdersFlag: on_power_orders_flag, + notifications.PowerOrdersUpdate: on_power_orders_update, + notifications.PowersControllers: on_powers_controllers, + notifications.PowerVoteUpdated: on_power_vote_updated, + notifications.PowerWaitFlag: on_power_wait_flag, + notifications.VoteCountUpdated: on_vote_count_updated, + notifications.VoteUpdated: on_vote_updated, +} + +def handle_notification(connection, notification): + """ Call appropriate handler for given notification received by given connection. + :param connection: recipient connection. + :param notification: received notification. + :type connection: diplomacy.Connection + :type notification: notifications._AbstractNotification | notifications._GameNotification + """ + if notification.level == strings.CHANNEL: + object_to_notify = connection.channels.get(notification.token, None) + else: + object_to_notify = _get_game_to_notify(connection, notification) + if object_to_notify is None: + LOGGER.error('Unknown notification: %s', notification.name) + else: + LOGGER.info('Notification received: %s', notification.name) + handler = MAPPING.get(type(notification), None) + if not handler: + raise exceptions.DiplomacyException( + 'No handler available for notification class %s' % type(notification).__name__) + handler(object_to_notify, notification) + if notification.level == strings.GAME: + object_to_notify.notify(notification) diff --git a/diplomacy/client/response_managers.py b/diplomacy/client/response_managers.py new file mode 100644 index 0000000..5183ac4 --- /dev/null +++ b/diplomacy/client/response_managers.py @@ -0,0 +1,335 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# 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/>. +# ============================================================================== +""" Response managers (client side). One manager corresponds to one request, except for requests that don't need + specific manager (in such case, method default_manager() is used). + Each manager is a function with name format "on_<request name in snake case>", expecting a request context + and a response as parameters. +""" +# pylint: disable=unused-argument +from diplomacy.client.game_instances_set import GameInstancesSet +from diplomacy.client.network_game import NetworkGame +from diplomacy.client.channel import Channel +from diplomacy.communication import requests, responses +from diplomacy.engine.game import Game +from diplomacy.utils import exceptions +from diplomacy.utils.game_phase_data import GamePhaseData + +class RequestFutureContext(): + """ Helper class to store a context around a request + (with future for response management, related connection and optional related game). + """ + __slots__ = ['request', 'future', 'connection', 'game'] + + def __init__(self, request, future, connection, game=None): + """ Initialize a request future context. + :param request: a request object (see diplomacy.communication.requests about possible classes). + :param future: a tornado Future object. + :param connection: a diplomacy.Connection object. + :param game: (optional) a NetworkGame object (from module diplomacy.client.network_game). + :type request: requests._AbstractRequest | requests._AbstractGameRequest + :type future: tornado.concurrent.Future + :type connection: diplomacy.Connection + :type game: diplomacy.client.network_game.NetworkGame + """ + self.request = request + self.future = future + self.connection = connection + self.game = game + + request_id = property(lambda self: self.request.request_id) + token = property(lambda self: self.request.token) + channel = property(lambda self: self.connection.channels[self.request.token]) + + def new_channel(self, token): + """ Create, store (in associated connection), and return a new channel with given token. """ + channel = Channel(self.connection, token) + self.connection.channels[token] = channel + return channel + + def new_game(self, received_game): + """ Create, store (in associated connection) and return a new network game wrapping given game data. + Returned game is already in appropriate type (observer game, omniscient game or power game). + :param received_game: game sent by server (Game object) + :type received_game: Game + """ + game = NetworkGame(self.channel, received_game) + if game.game_id not in self.channel.game_id_to_instances: + self.channel.game_id_to_instances[game.game_id] = GameInstancesSet(game.game_id) + self.channel.game_id_to_instances[game.game_id].add(game) + return game + + def remove_channel(self): + """ Remove associated channel (inferred from request token) from associated connection. """ + del self.connection.channels[self.channel.token] + + def delete_game(self): + """ Delete local game instances corresponding to game ID in associated request. """ + assert hasattr(self.request, 'game_id') + assert self.game is not None and self.game.game_id == self.request.game_id + if self.request.game_id in self.channel.game_id_to_instances: + del self.channel.game_id_to_instances[self.request.game_id] + +def default_manager(context, response): + """ Default manager called for requests that don't have specific management. + If response is OK, return None. + If response is a UniqueData, return response data field. + Else, return response. + Expect response to be either OK or a UniqueData + (containing only 1 field intended to be returned by server for associated request). + :param context: request context + :param response: response received + :return: None, or data if response is a UniqueData. + """ + if isinstance(response, responses.UniqueData): + return response.data + if isinstance(response, responses.Ok): + return None + return response + +def on_create_game(context, response): + """ Manage response for request CreateGame. + :param context: request context + :param response: response received + :return: a new network game + :type context: RequestFutureContext + :type response: responses.DataGame + """ + return context.new_game(response.data) + +def on_delete_account(context, response): + """ Manage response for request DeleteAccount. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + context.remove_channel() + +def on_delete_game(context, response): + """ Manage response for request DeleteGame. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + context.delete_game() + +def on_get_phase_history(context, response): + """ Manage response for request GetPhaseHistory. + :param context: request context + :param response: response received + :return: a list of game states + :type context: RequestFutureContext + :type response: responses.DataGamePhases + """ + phase_history = response.data + for game_phase in phase_history: # type: diplomacy.utils.game_phase_data.GamePhaseData + Game.extend_phase_history(context.game, game_phase) + return phase_history + +def on_join_game(context, response): + """ Manage response for request JoinGame. + :param context: request context + :param response: response received + :return: a new network game + :type response: responses.DataGame + """ + return context.new_game(response.data) + +def on_leave_game(context, response): + """ Manage response for request LeaveGame. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + context.delete_game() + +def on_logout(context, response): + """ Manage response for request Logout. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + context.remove_channel() + +def on_send_game_message(context, response): + """ Manage response for request SendGameMessage. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + :type response: responses.DataTimeStamp + """ + request = context.request # type: requests.SendGameMessage + message = request.message + message.time_sent = response.data + Game.add_message(context.game, message) + +def on_set_game_state(context, response): + """ Manage response for request SetGameState. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.SetGameState + context.game.set_phase_data(GamePhaseData(name=request.state['name'], + state=request.state, + orders=request.orders, + messages=request.messages, + results=request.results)) + +def on_set_game_status(context, response): + """ Manage response for request SetGameStatus. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.SetGameStatus + Game.set_status(context.game, request.status) + +def on_set_orders(context, response): + """ Manage response for request SetOrders. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.SetOrders + orders = request.orders + if Game.is_player_game(context.game): + assert context.game.power.name == context.request.game_role + Game.set_orders(context.game, request.game_role, orders) + else: + Game.set_orders(context.game, request.power_name, orders) + +def on_clear_orders(context, response): + """ Manage response for request ClearOrders. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearOrders + Game.clear_orders(context.game, request.power_name) + +def on_clear_centers(context, response): + """ Manage response for request ClearCenters. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearCenters + Game.clear_centers(context.game, request.power_name) + +def on_clear_units(context, response): + """ Manage response for request ClearUnits. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearUnits + Game.clear_units(context.game, request.power_name) + +def on_set_wait_flag(context, response): + """ Manage response for request SetWaitFlag. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.SetWaitFlag + wait = request.wait + if Game.is_player_game(context.game): + assert context.game.power.name == context.request.game_role + Game.set_wait(context.game, request.game_role, wait) + else: + Game.set_wait(context.game, request.power_name, wait) + +def on_sign_in(context, response): + """ Manage response for request SignIn. + :param context: request context + :param response: response received + :return: a new channel + :type context: RequestFutureContext + :type response: responses.DataToken + """ + return context.new_channel(response.data) + +def on_vote(context, response): + """ Manage response for request VoteAboutDraw. + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.Vote + vote = request.vote + assert Game.is_player_game(context.game) + assert context.game.power.name == context.request.game_role + context.game.power.vote = vote + +# Mapping dictionary from request class to response handler function. +MAPPING = { + requests.ClearCenters: on_clear_centers, + requests.ClearOrders: on_clear_orders, + requests.ClearUnits: on_clear_units, + requests.CreateGame: on_create_game, + requests.DeleteAccount: on_delete_account, + requests.DeleteGame: on_delete_game, + requests.GetAllPossibleOrders: default_manager, + requests.GetAvailableMaps: default_manager, + requests.GetDummyWaitingPowers: default_manager, + requests.GetPlayablePowers: default_manager, + requests.GetPhaseHistory: on_get_phase_history, + requests.JoinGame: on_join_game, + requests.JoinPowers: default_manager, + requests.LeaveGame: on_leave_game, + requests.ListGames: default_manager, + requests.GetGamesInfo: default_manager, + requests.Logout: on_logout, + requests.ProcessGame: default_manager, + requests.QuerySchedule: default_manager, + requests.SaveGame: default_manager, + requests.SendGameMessage: on_send_game_message, + requests.SetDummyPowers: default_manager, + requests.SetGameState: on_set_game_state, + requests.SetGameStatus: on_set_game_status, + requests.SetGrade: default_manager, + requests.SetOrders: on_set_orders, + requests.SetWaitFlag: on_set_wait_flag, + requests.SignIn: on_sign_in, + requests.Synchronize: default_manager, + requests.Vote: on_vote, +} + +def handle_response(context, response): + """ Call appropriate handler for given response with given request context. + :param context: request context. + :param response: response received. + :return: value returned by handler. + """ + handler = MAPPING.get(type(context.request), None) + if not handler: + raise exceptions.DiplomacyException( + 'No response handler available for request class %s' % type(context.request).__name__) + return handler(context, response) |