aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/client
diff options
context:
space:
mode:
authorPhilip Paquette <pcpaquette@gmail.com>2018-09-26 07:48:55 -0400
committerPhilip Paquette <pcpaquette@gmail.com>2019-04-18 11:14:24 -0400
commit6187faf20384b0c5a4966343b2d4ca47f8b11e45 (patch)
tree151ccd21aea20180432c13fe4b58240d3d9e98b6 /diplomacy/client
parent96b7e2c03ed98705754f13ae8efa808b948ee3a8 (diff)
Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License
Diffstat (limited to 'diplomacy/client')
-rw-r--r--diplomacy/client/__init__.py16
-rw-r--r--diplomacy/client/channel.py156
-rw-r--r--diplomacy/client/connection.py474
-rw-r--r--diplomacy/client/game_instances_set.py81
-rw-r--r--diplomacy/client/network_game.py199
-rw-r--r--diplomacy/client/notification_managers.py266
-rw-r--r--diplomacy/client/response_managers.py335
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)