aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/server
diff options
context:
space:
mode:
Diffstat (limited to 'diplomacy/server')
-rw-r--r--diplomacy/server/__init__.py16
-rw-r--r--diplomacy/server/connection_handler.py111
-rw-r--r--diplomacy/server/notifier.py333
-rw-r--r--diplomacy/server/request_manager_utils.py267
-rw-r--r--diplomacy/server/request_managers.py1181
-rwxr-xr-xdiplomacy/server/run.py35
-rw-r--r--diplomacy/server/scheduler.py265
-rw-r--r--diplomacy/server/server.py797
-rw-r--r--diplomacy/server/server_game.py465
-rw-r--r--diplomacy/server/user.py37
-rw-r--r--diplomacy/server/users.py234
11 files changed, 3741 insertions, 0 deletions
diff --git a/diplomacy/server/__init__.py b/diplomacy/server/__init__.py
new file mode 100644
index 0000000..acc0ee4
--- /dev/null
+++ b/diplomacy/server/__init__.py
@@ -0,0 +1,16 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
diff --git a/diplomacy/server/connection_handler.py b/diplomacy/server/connection_handler.py
new file mode 100644
index 0000000..0089db3
--- /dev/null
+++ b/diplomacy/server/connection_handler.py
@@ -0,0 +1,111 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Tornado connection handler class, used internally to manage data received by server application. """
+import logging
+
+from urllib.parse import urlparse
+from tornado import gen
+from tornado.websocket import WebSocketHandler, WebSocketClosedError
+
+import ujson as json
+
+from diplomacy.communication import responses, requests
+from diplomacy.server import request_managers
+from diplomacy.utils import exceptions, strings
+
+
+LOGGER = logging.getLogger(__name__)
+
+class ConnectionHandler(WebSocketHandler):
+ """ ConnectionHandler class. Properties:
+ - server: server object representing running server.
+ """
+ # pylint: disable=abstract-method
+
+ def __init__(self, *args, **kwargs):
+ self.server = None
+ super(ConnectionHandler, self).__init__(*args, **kwargs)
+
+ def initialize(self, server=None):
+ """ Initialize the connection handler.
+ :param server: a Server object.
+ :type server: diplomacy.Server
+ """
+ # pylint: disable=arguments-differ
+ if self.server is None:
+ self.server = server
+
+ def get_compression_options(self):
+ """ Return compression options for the connection (see parent method).
+ Non-None enables compression with default options.
+ """
+ return {}
+
+ def check_origin(self, origin):
+ """ Return True if we should accept connexion from given origin (str). """
+
+ # It seems origin may be 'null', e.g. if client is a web page loaded from disk (`file:///my_test_file.html`).
+ # Accept it.
+ if origin == 'null':
+ return True
+
+ # Try to check if origin matches host (without regarding port).
+ # Adapted from parent method code (tornado 4.5.3).
+ parsed_origin = urlparse(origin)
+ origin = parsed_origin.netloc.split(':')[0]
+ origin = origin.lower()
+ # Split host with ':' and keep only first piece to ignore eventual port.
+ host = self.request.headers.get("Host").split(':')[0]
+ return origin == host
+
+ def on_close(self):
+ """ Invoked when the socket is closed (see parent method).
+ Detach this connection handler from server users.
+ """
+ self.server.users.remove_connection(self, remove_tokens=False)
+ LOGGER.info("Removed connection. Remaining %d connection(s).", self.server.users.count_connections())
+
+ @gen.coroutine
+ def on_message(self, message):
+ """ Parse given message and manage parsed data (expected a string representation of a request). """
+ try:
+ json_request = json.loads(message)
+ if not isinstance(json_request, dict):
+ raise ValueError("Unable to convert a JSON string to a dictionary.")
+ except ValueError as exc:
+ # Error occurred because either message is not a JSON string or parsed JSON object is not a dict.
+ response = responses.Error(message='%s/%s' % (type(exc).__name__, str(exc)))
+ else:
+ try:
+ request = requests.parse_dict(json_request)
+
+ if request.level is not None:
+ # Link request token to this connection handler.
+ self.server.users.attach_connection_handler(request.token, self)
+
+ response = yield request_managers.handle_request(self.server, request, self)
+ if response is None:
+ response = responses.Ok(request_id=request.request_id)
+
+ except exceptions.ResponseException as exc:
+ response = responses.Error(message='%s/%s' % (type(exc).__name__, exc.message),
+ request_id=json_request.get(strings.REQUEST_ID, None))
+
+ try:
+ yield self.write_message(response.json())
+ except WebSocketClosedError:
+ LOGGER.error('Websocket is closed.')
diff --git a/diplomacy/server/notifier.py b/diplomacy/server/notifier.py
new file mode 100644
index 0000000..1c05335
--- /dev/null
+++ b/diplomacy/server/notifier.py
@@ -0,0 +1,333 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Server notifier class. Used to send server notifications, allowing to ignore some addresses. """
+from tornado import gen
+
+from diplomacy.communication import notifications
+from diplomacy.utils import strings
+
+class Notifier():
+ """ Server notifier class. """
+ __slots__ = ['server', 'ignore_tokens', 'ignore_addresses']
+
+ def __init__(self, server, ignore_tokens=None, ignore_addresses=None):
+ """ Initialize a server notifier. You can specify some tokens or addresses to ignore using
+ ignore_tokens or ignore_addresses. Note that these parameters are mutually exclusive
+ (you can use either none of them or only one of them).
+ :param server: a server object.
+ :param ignore_tokens: (optional) sequence of tokens to ignore.
+ :param ignore_addresses: (optional) sequence of couples (power name, token) to ignore.
+ :type server: diplomacy.Server
+ """
+ self.server = server
+ self.ignore_tokens = None
+ self.ignore_addresses = None
+ if ignore_tokens and ignore_addresses:
+ raise AssertionError('Notifier cannot ignore both tokens and addresses.')
+
+ # Expect a sequence of tokens to ignore.
+ # Convert it to a set.
+ elif ignore_tokens:
+ self.ignore_tokens = set(ignore_tokens)
+
+ # Expect a sequence of tuples (power name, token) to ignore.
+ # Convert it to a dict {power name => {token}} (each power name with all associated ignored tokens).
+ elif ignore_addresses:
+ self.ignore_addresses = {}
+ for power_name, token in ignore_addresses:
+ if power_name not in self.ignore_addresses:
+ self.ignore_addresses[power_name] = set()
+ self.ignore_addresses[power_name].add(token)
+
+ def ignores(self, notification):
+ """ Return True if given notification must be ignored.
+ :param notification:
+ :return: a boolean
+ :type notification: notifications._AbstractNotification | notifications._GameNotification
+ """
+ if self.ignore_tokens:
+ return notification.token in self.ignore_tokens
+ if self.ignore_addresses and notification.level == strings.GAME:
+ # We can ignore addresses only for game requests (as other requests only have a token, not a full address).
+ return (notification.game_role in self.ignore_addresses
+ and notification.token in self.ignore_addresses[notification.game_role])
+ return False
+
+ @gen.coroutine
+ def _notify(self, notification):
+ """ Register a notification to send.
+ :param notification: a notification instance.
+ :type notification: notifications._AbstractNotification | notifications._GameNotification
+ """
+ connection_handler = self.server.users.get_connection_handler(notification.token)
+ if not self.ignores(notification) and connection_handler:
+ yield self.server.notifications.put((connection_handler, notification))
+
+ @gen.coroutine
+ def _notify_game(self, server_game, notification_class, **kwargs):
+ """ Send a game notification.
+ Game token, game ID and game role will be automatically provided to notification object.
+ :param server_game: game to notify
+ :param notification_class: class of notification to send
+ :param kwargs: (optional) other notification parameters
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ for game_role, token in server_game.get_reception_addresses():
+ yield self._notify(notification_class(token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ **kwargs))
+
+ @gen.coroutine
+ def _notify_power(self, game_id, power, notification_class, **kwargs):
+ """ Send a notification to all tokens of a power.
+ Automatically add token, game ID and game role to notification parameters.
+ :param game_id: power game ID.
+ :param power: power to send notification.
+ :param notification_class: class of notification to send.
+ :param kwargs: (optional) other notification parameters.
+ :type power: diplomacy.Power
+ """
+ for token in power.tokens:
+ yield self._notify(notification_class(token=token,
+ game_id=game_id,
+ game_role=power.name,
+ **kwargs))
+
+ @gen.coroutine
+ def notify_game_processed(self, server_game, previous_phase_data, current_phase_data):
+ """ Notify all game tokens about a game phase update (game processing).
+ :param server_game: game to notify
+ :param previous_phase_data: game phase data before phase update
+ :param current_phase_data: game phase data after phase update
+ :type server_game: diplomacy.server.server_game.ServerGame
+ :type previous_phase_data: diplomacy.utils.game_phase_data.GamePhaseData
+ :type current_phase_data: diplomacy.utils.game_phase_data.GamePhaseData
+ """
+ # Send game updates to observers ans omniscient observers..
+ for game_role, token in server_game.get_observer_addresses():
+ yield self._notify(notifications.GameProcessed(
+ token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ previous_phase_data=server_game.filter_phase_data(previous_phase_data, strings.OBSERVER_TYPE, False),
+ current_phase_data=server_game.filter_phase_data(current_phase_data, strings.OBSERVER_TYPE, True)
+ ))
+ for game_role, token in server_game.get_omniscient_addresses():
+ yield self._notify(notifications.GameProcessed(
+ token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ previous_phase_data=server_game.filter_phase_data(previous_phase_data, strings.OMNISCIENT_TYPE, False),
+ current_phase_data=server_game.filter_phase_data(current_phase_data, strings.OMNISCIENT_TYPE, True)))
+ # Send game updates to powers.
+ for power in server_game.powers.values():
+ yield self._notify_power(server_game.game_id, power, notifications.GameProcessed,
+ previous_phase_data=server_game.filter_phase_data(
+ previous_phase_data, power.name, False),
+ current_phase_data=server_game.filter_phase_data(
+ current_phase_data, power.name, True))
+
+ @gen.coroutine
+ def notify_account_deleted(self, username):
+ """ Notify all tokens of given username about account deleted. """
+ for token_to_notify in self.server.users.get_tokens(username):
+ yield self._notify(notifications.AccountDeleted(token=token_to_notify))
+
+ @gen.coroutine
+ def notify_game_deleted(self, server_game):
+ """ Notify all game tokens about game deleted.
+ :param server_game: game to notify
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.GameDeleted)
+
+ @gen.coroutine
+ def notify_game_powers_controllers(self, server_game):
+ """ Notify all game tokens about current game powers controllers.
+ :param server_game: game to notify
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.PowersControllers,
+ powers=server_game.get_controllers(),
+ timestamps=server_game.get_controllers_timestamps())
+
+ @gen.coroutine
+ def notify_game_status(self, server_game):
+ """ Notify all game tokens about current game status.
+ :param server_game: game to notify
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.GameStatusUpdate, status=server_game.status)
+
+ @gen.coroutine
+ def notify_game_phase_data(self, server_game):
+ """ Notify all game tokens about current game state.
+ :param server_game: game to notify
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ phase_data = server_game.get_phase_data()
+ state_type = strings.STATE
+ # Notify omniscient tokens.
+ yield self.notify_game_addresses(server_game.game_id,
+ server_game.get_omniscient_addresses(),
+ notifications.GamePhaseUpdate,
+ phase_data=server_game.filter_phase_data(
+ phase_data, strings.OMNISCIENT_TYPE, is_current=True),
+ phase_data_type=state_type)
+ # Notify observer tokens.
+ yield self.notify_game_addresses(server_game.game_id,
+ server_game.get_observer_addresses(),
+ notifications.GamePhaseUpdate,
+ phase_data=server_game.filter_phase_data(
+ phase_data, strings.OBSERVER_TYPE, is_current=True),
+ phase_data_type=state_type)
+ # Notify power addresses.
+ for power_name in server_game.get_map_power_names():
+ yield self.notify_game_addresses(server_game.game_id,
+ server_game.get_power_addresses(power_name),
+ notifications.GamePhaseUpdate,
+ phase_data=server_game.filter_phase_data(
+ phase_data, power_name, is_current=True),
+ phase_data_type=state_type)
+
+ @gen.coroutine
+ def notify_game_vote_updated(self, server_game):
+ """ Notify all game tokens about current game vote.
+ Send relevant notifications to each type of tokens.
+ :param server_game: game to notify
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ # Notify observers about vote count changed.
+ for game_role, token in server_game.get_observer_addresses():
+ yield self._notify(notifications.VoteCountUpdated(token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ count_voted=server_game.count_voted(),
+ count_expected=server_game.count_controlled_powers()))
+ # Notify omniscient observers about power vote changed.
+ for game_role, token in server_game.get_omniscient_addresses():
+ yield self._notify(notifications.VoteUpdated(token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ vote={power.name: power.vote
+ for power in server_game.powers.values()}))
+ # Notify each power about its own changes.
+ for power in server_game.powers.values():
+ yield self._notify_power(server_game.game_id, power, notifications.PowerVoteUpdated,
+ count_voted=server_game.count_voted(),
+ count_expected=server_game.count_controlled_powers(),
+ vote=power.vote)
+
+ @gen.coroutine
+ def notify_power_orders_update(self, server_game, power, orders):
+ """ Notify all power tokens and all observers about new orders for given power.
+ :param server_game: game to notify
+ :param power: power to notify
+ :param orders: new power orders
+ :type server_game: diplomacy.server.server_game.ServerGame
+ :type power: diplomacy.Power
+ """
+ yield self._notify_power(server_game.game_id, power, notifications.PowerOrdersUpdate,
+ power_name=power.name, orders=orders)
+ addresses = list(server_game.get_omniscient_addresses()) + list(server_game.get_observer_addresses())
+ yield self.notify_game_addresses(server_game.game_id, addresses,
+ notifications.PowerOrdersUpdate,
+ power_name=power.name, orders=orders)
+ other_powers_addresses = []
+ for other_power_name in server_game.powers:
+ if other_power_name != power.name:
+ other_powers_addresses.extend(server_game.get_power_addresses(other_power_name))
+ yield self.notify_game_addresses(server_game.game_id, other_powers_addresses,
+ notifications.PowerOrdersFlag,
+ power_name=power.name, order_is_set=power.order_is_set)
+
+ @gen.coroutine
+ def notify_power_wait_flag(self, server_game, power, wait_flag):
+ """ Notify all power tokens about new wait flag for given power.
+ :param server_game: game to notify
+ :param power: power to notify
+ :param wait_flag: new wait flag
+ :type power: diplomacy.Power
+ """
+ yield self._notify_game(server_game, notifications.PowerWaitFlag, power_name=power.name, wait=wait_flag)
+
+ @gen.coroutine
+ def notify_cleared_orders(self, server_game, power_name):
+ """ Notify all game tokens about game orders cleared for a given power name.
+ :param server_game: game to notify
+ :param power_name: name of power for which orders were cleared.
+ None means all power orders were cleared.
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.ClearedOrders, power_name=power_name)
+
+ @gen.coroutine
+ def notify_cleared_units(self, server_game, power_name):
+ """ Notify all game tokens about game units cleared for a given power name.
+ :param server_game: game to notify
+ :param power_name: name of power for which units were cleared.
+ None means all power units were cleared.
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.ClearedUnits, power_name=power_name)
+
+ @gen.coroutine
+ def notify_cleared_centers(self, server_game, power_name):
+ """ Notify all game tokens about game centers cleared for a given power name.
+ :param server_game: game to notify
+ :param power_name: name of power for which centers were cleared.
+ None means all power centers were cleared.
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ yield self._notify_game(server_game, notifications.ClearedCenters, power_name=power_name)
+
+ @gen.coroutine
+ def notify_game_message(self, server_game, game_message):
+ """ Notify relevant users about a game message received.
+ :param server_game: Game data who handles this game message.
+ :param game_message: the game message received.
+ :return: None
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ if game_message.is_global():
+ yield self._notify_game(server_game, notifications.GameMessageReceived, message=game_message)
+ else:
+ power_from = server_game.get_power(game_message.sender)
+ power_to = server_game.get_power(game_message.recipient)
+ yield self._notify_power(
+ server_game.game_id, power_from, notifications.GameMessageReceived, message=game_message)
+ yield self._notify_power(
+ server_game.game_id, power_to, notifications.GameMessageReceived, message=game_message)
+ for game_role, token in server_game.get_omniscient_addresses():
+ yield self._notify(notifications.GameMessageReceived(token=token,
+ game_id=server_game.game_id,
+ game_role=game_role,
+ message=game_message))
+
+ @gen.coroutine
+ def notify_game_addresses(self, game_id, addresses, notification_class, **kwargs):
+ """ Notify addresses of a game with a notification.
+ Game ID is automatically provided to notification.
+ Token and game role are automatically provided to notifications from given addresses.
+ :param game_id: related game ID
+ :param addresses: addresses to notify. Sequence of couples (game role, token).
+ :param notification_class: class of notification to send
+ :param kwargs: (optional) other parameters for notification
+ """
+ for game_role, token in addresses:
+ yield self._notify(notification_class(token=token, game_id=game_id, game_role=game_role, **kwargs))
diff --git a/diplomacy/server/request_manager_utils.py b/diplomacy/server/request_manager_utils.py
new file mode 100644
index 0000000..9ea8264
--- /dev/null
+++ b/diplomacy/server/request_manager_utils.py
@@ -0,0 +1,267 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Utility classes and functions used for request management.
+ Put here to avoid having file request_managers.py with too many lines.
+"""
+from collections.__init__ import namedtuple
+
+from diplomacy.communication import notifications
+from diplomacy.server.notifier import Notifier
+
+from diplomacy.utils import strings, exceptions
+
+class SynchronizedData(namedtuple('SynchronizedData', ('timestamp', 'order', 'type', 'data'))):
+ """ Small class used to store and sort data to synchronize for a game. Properties:
+ - timestamp (int): timestamp of related data to synchronize.
+ - order (int): rank of data to synchronize.
+ - type (str): type name of data to synchronize. Possible values:
+ - 'message': data is a game message. Order is 0.
+ - 'state_history': data is a game state for history. Order is 1.
+ - 'state': data is current game state. Order is 2.
+ - data: proper data to synchronize.
+ Synchronized data are sorted using timestamp then order, meaning that:
+ - data are synchronized from former to later timestamps
+ - for a same timestamp, messages are synchronized first, then states for history, then current state.
+ """
+
+class GameRequestLevel():
+ """ Describe a game level retrieved from a game request. Used by some game requests managers
+ to determine user rights in a game. Possible game levels: power, observer, omniscient and master.
+ """
+ __slots__ = ['game', 'power_name', '__action_level']
+
+ def __init__(self, game, action_level, power_name):
+ """ Initialize a game request level.
+ :param game: related game data
+ :param action_level: action level, either:
+ - 'power'
+ - 'observer'
+ - 'omniscient'
+ - 'master'
+ :param power_name: (optional) power name specified in game request. Required if level is 'power'.
+ :type game: diplomacy.server.server_game.ServerGame
+ :type action_level: str
+ :type power_name: str
+ """
+ assert action_level in {'power', 'observer', 'omniscient', 'master'}
+ self.game = game
+ self.power_name = power_name # type: str
+ self.__action_level = action_level # type: str
+
+ def is_power(self):
+ """ Return True if game level is power. """
+ return self.__action_level == 'power'
+
+ def is_observer(self):
+ """ Return True if game level is observer. """
+ return self.__action_level == 'observer'
+
+ def is_omniscient(self):
+ """ Return True if game level is omniscient. """
+ return self.__action_level == 'omniscient'
+
+ def is_master(self):
+ """ Return True if game level is master. """
+ return self.__action_level == 'master'
+
+ @classmethod
+ def power_level(cls, game, power_name):
+ """ Create and return a game power level with given game data and power name. """
+ return cls(game, 'power', power_name)
+
+ @classmethod
+ def observer_level(cls, game, power_name):
+ """ Create and return a game observer level with given game data and power name. """
+ return cls(game, 'observer', power_name)
+
+ @classmethod
+ def omniscient_level(cls, game, power_name):
+ """ Create and return a game omniscient level with given game data and power name. """
+ return cls(game, 'omniscient', power_name)
+
+ @classmethod
+ def master_level(cls, game, power_name):
+ """ Create and return a game master level with given game data and power name. """
+ return cls(game, 'master', power_name)
+
+def verify_request(server, request, connection_handler,
+ omniscient_role=True, observer_role=True, power_role=True, require_power=False, require_master=True):
+ """ Verify request token, and game role and rights if request is a game request.
+ Ignore connection requests (e.g. SignIn), as such requests don't have any token.
+ Verifying token:
+ - check if server knows request token
+ - check if request token is still valid.
+ - Update token lifetime. See method Server.assert_token() for more details.
+ Verifying game role and rights:
+ - check if server knows request game ID.
+ - check if request token is allowed to have request game role in associated game ID.
+ If request is a game request, return a GameRequestLevel containing:
+ - the server game object
+ - the level of rights (power, observer or master) allowed for request sender.
+ - the power name associated to request (if present), representing which power is queried by given request.
+ See class GameRequestLevel for more details.
+ :param server: server which receives the request
+ :param request: request received by server
+ :param connection_handler: connection handler which receives the request
+ :param omniscient_role: (for game requests) Indicate if omniscient role is accepted for this request.
+ :param observer_role: (for game requests) Indicate if observer role is accepted for this request.
+ :param power_role: (for game requests) Indicate if power role is accepted for this request.
+ :param require_power: (for game requests) Indicate if a power name is required for this request.
+ If true, either game role must be power role, or request must have a non-null `power_name` request.
+ :param require_master: (for game requests) Indicate if an omniscient must be a master.
+ If true and if request role is omniscient, then request token must be a master token for related game.
+ :return: a GameRequestLevel object for game requests, else None.
+ :rtype: diplomacy.server.request_manager_utils.GameRequestLevel
+ :type server: diplomacy.Server
+ :type request: requests._AbstractRequest | requests._AbstractGameRequest
+ :type connection_handler: diplomacy.server.connection_handler.ConnectionHandler
+ """
+
+ # A request may be a connection request, a channel request or a game request.
+ # For connection request, field level is None.
+ # For channel request, field level is CHANNEL. Channel request has a `token` field.
+ # For game request, field level is GAME. Game request is a channel request with supplementary fields
+ # `game_role` and `game_id`.
+
+ # No permissions to check for connection requests (e.g. SignIn).
+ if not request.level:
+ return None
+
+ # Check token for channel and game requests.
+ server.assert_token(request.token, connection_handler)
+
+ # No more permissions to check for non-game requests.
+ if request.level != strings.GAME:
+ return None
+
+ # Check and get game.
+ server_game = server.get_game(request.game_id)
+
+ power_name = getattr(request, 'power_name', None)
+
+ if strings.role_is_special(request.game_role):
+
+ if request.game_role == strings.OMNISCIENT_TYPE:
+
+ # Check if omniscient role is accepted (for this call).
+ if not omniscient_role:
+ raise exceptions.ResponseException(
+ 'Omniscient role disallowed for request %s' % request.name)
+
+ # Check if request token is known as omniscient token by related game.
+ if not server_game.has_omniscient_token(request.token):
+ raise exceptions.GameTokenException()
+
+ # Check if request token is a master token (if required for this call)
+ # and create game request level.
+ token_is_master = server.token_is_master(request.token, server_game)
+ if require_master and not token_is_master:
+ raise exceptions.GameMasterTokenException()
+ if token_is_master:
+ level = GameRequestLevel.master_level(server_game, power_name)
+ else:
+ level = GameRequestLevel.omniscient_level(server_game, power_name)
+
+ else:
+ # Check if observer role is accepted (for this call).
+ if not observer_role:
+ raise exceptions.ResponseException(
+ 'Observer role disallowed for request %s' % request.game_role)
+
+ # Check if request token is known as observer token by related game.
+ if not server_game.has_observer_token(request.token):
+ raise exceptions.GameTokenException()
+
+ # Create game request level object.
+ level = GameRequestLevel.observer_level(server_game, power_name)
+
+ # Check if we have a valid power name if power name is required (for this call) or given.
+ if power_name is None:
+ if require_power:
+ raise exceptions.MapPowerException(None)
+ elif not server_game.has_power(power_name):
+ raise exceptions.MapPowerException(power_name)
+
+ else:
+ # Check if power role is accepted (for this call).
+ if not power_role:
+ raise exceptions.ResponseException('Power role disallowed for request %s' % request.name)
+
+ # Get power name to check: either given power name if defined, else game role.
+ if power_name is None:
+ power_name = request.game_role
+
+ # Check if given power name is valid.
+ if not server_game.has_power(power_name):
+ raise exceptions.MapPowerException(power_name)
+
+ # Check if request sender is allowed to query given power name.
+ # We don't care anymore if sender token is currently associated to this power,
+ # as long as sender is registered as the controller of this power.
+ if not server_game.is_controlled_by(power_name, server.users.get_name(request.token)):
+ raise exceptions.ResponseException('User %s does not currently control power %s'
+ % (server.users.get_name(request.token), power_name))
+
+ # Create game request level.
+ level = GameRequestLevel.power_level(server_game, power_name)
+
+ return level
+
+def transfer_special_tokens(server_game, server, username, grade_update, from_observation=True):
+ """ Transfer tokens of given username from an observation role to the opposite in given server game,
+ and notify all user tokens about observation role update with given grade update.
+ This method is used in request manager on_set_grade().
+ :param server_game: server game in which tokens roles must be changed.
+ :param server: server from which notifications will be sent.
+ :param username: name of user whom tokens will be transferred. Only user tokens registered in
+ server games as observer tokens or omniscient tokens will be updated.
+ :param grade_update: type of upgrading. Possibles values in strings.ALL_GRADE_UPDATES (PROMOTE or DEMOTE).
+ :param from_observation: indicate transfer direction.
+ If True, we expect to transfer role from observer to omniscient.
+ If False, we expect to transfer role from omniscient to observer.
+ :type server_game: diplomacy.server.server_game.ServerGame
+ :type server: diplomacy.Server
+ """
+ if from_observation:
+ old_role = strings.OBSERVER_TYPE
+ new_role = strings.OMNISCIENT_TYPE
+ token_filter = server_game.has_observer_token
+ else:
+ old_role = strings.OMNISCIENT_TYPE
+ new_role = strings.OBSERVER_TYPE
+ token_filter = server_game.has_omniscient_token
+
+ connected_user_tokens = [user_token for user_token in server.users.get_tokens(username) if token_filter(user_token)]
+
+ if connected_user_tokens:
+
+ # Update observer level for each connected user token.
+ for user_token in connected_user_tokens:
+ server_game.transfer_special_token(user_token)
+
+ addresses = [(old_role, user_token) for user_token in connected_user_tokens]
+ Notifier(server).notify_game_addresses(
+ server_game.game_id, addresses, notifications.OmniscientUpdated,
+ grade_update=grade_update, game=server_game.cast(new_role, username, server.users.has_admin(username)))
+
+def assert_game_not_finished(server_game):
+ """ Check if given game is not yet completed or canceled, otherwise raise a GameFinishedException.
+ :param server_game: server game to check
+ :type server_game: diplomacy.server.server_game.ServerGame
+ """
+ if server_game.is_game_completed or server_game.is_game_canceled:
+ raise exceptions.GameFinishedException()
diff --git a/diplomacy/server/request_managers.py b/diplomacy/server/request_managers.py
new file mode 100644
index 0000000..d17a77b
--- /dev/null
+++ b/diplomacy/server/request_managers.py
@@ -0,0 +1,1181 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Request managers (server side). Remarks:
+ Even if request managers use many server methods which are coroutines, we currently never yield
+ on any of this method because we don't need to wait for them to finish before continuing request
+ management. Thus, current request managers are all normal functions.
+ Server coroutines used here are usually:
+ - game scheduling/unscheduling
+ - game saving
+ - server saving
+ - notifications sending
+"""
+#pylint:disable=too-many-lines
+import logging
+
+from tornado import gen
+from tornado.concurrent import Future
+
+from diplomacy.communication import notifications, requests, responses
+from diplomacy.server.notifier import Notifier
+from diplomacy.server.server_game import ServerGame
+from diplomacy.server.request_manager_utils import (SynchronizedData, verify_request, transfer_special_tokens,
+ assert_game_not_finished)
+from diplomacy.utils import exceptions, strings, constants, export
+from diplomacy.utils.common import hash_password
+from diplomacy.utils.constants import OrderSettings
+from diplomacy.utils.game_phase_data import GamePhaseData
+
+LOGGER = logging.getLogger(__name__)
+
+# =================
+# Request managers.
+# =================
+
+def on_clear_centers(server, request, connection_handler):
+ """ Manage request ClearCenters.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.ClearCenters
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False)
+ assert_game_not_finished(level.game)
+ level.game.clear_centers(level.power_name)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_cleared_centers(level.game, level.power_name)
+
+def on_clear_orders(server, request, connection_handler):
+ """ Manage request ClearOrders.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.ClearOrders
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False)
+ assert_game_not_finished(level.game)
+ level.game.clear_orders(level.power_name)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_cleared_orders(level.game, level.power_name)
+
+def on_clear_units(server, request, connection_handler):
+ """ Manage request ClearUnits.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.ClearUnits
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False)
+ assert_game_not_finished(level.game)
+ level.game.clear_units(level.power_name)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_cleared_units(level.game, level.power_name)
+
+def on_create_game(server, request, connection_handler):
+ """ Manage request CreateGame.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.CreateGame
+ """
+
+ # Check request token.
+ verify_request(server, request, connection_handler)
+ game_id, token, power_name, state = request.game_id, request.token, request.power_name, request.state
+
+ # Check if server still accepts to create new games.
+ if server.cannot_create_more_games():
+ raise exceptions.GameCreationException()
+
+ # Check if given map name is valid and if there is such map.
+ game_map = server.get_map(request.map_name)
+ if not game_map:
+ raise exceptions.MapIdException()
+
+ # If rule SOLITAIRE is required, a power name cannot be queried (as all powers should be dummy).
+ # In such case, game creator can only be omniscient.
+ if request.rules and 'SOLITAIRE' in request.rules and power_name is not None:
+ raise exceptions.GameSolitaireException()
+
+ # If a power name is given, check if it's a valid power name for related map.
+ if power_name is not None and power_name not in game_map['powers']:
+ raise exceptions.MapPowerException(power_name)
+
+ # Create server game.
+ username = server.users.get_name(token)
+ if game_id is None or game_id == '':
+ game_id = server.create_game_id()
+ elif server.has_game_id(game_id):
+ raise exceptions.GameIdException('Game ID already used (%s).' % game_id)
+ server_game = ServerGame(map_name=request.map_name,
+ rules=request.rules,
+ game_id=game_id,
+ initial_state=state,
+ n_controls=request.n_controls,
+ deadline=request.deadline,
+ registration_password=request.registration_password)
+ server_game.server = server
+
+ # Make sure game creator will be a game master (set him as moderator if he's not an admin).
+ if not server.users.has_admin(username):
+ server_game.promote_moderator(username)
+
+ # Register game creator, as either power player or omniscient observer.
+ if power_name:
+ server_game.control(power_name, username, token)
+ client_game = server_game.as_power_game(power_name)
+ else:
+ server_game.add_omniscient_token(token)
+ client_game = server_game.as_omniscient_game(username)
+
+ # Register game on server.
+ server.add_new_game(server_game)
+
+ # Start game immediately if possible (e.g. if it's a solitaire game).
+ if server_game.game_can_start():
+ server.start_game(server_game)
+
+ server.save_game(server_game)
+
+ return responses.DataGame(data=client_game, request_id=request.request_id)
+
+def on_delete_account(server, request, connection_handler):
+ """ Manage request DeleteAccount.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.DeleteAccount
+ """
+
+ # Check request token.
+ verify_request(server, request, connection_handler)
+ token, username = request.token, request.username
+
+ # Get username of account to delete, either from given username or from request token.
+ # If given username is not token username, admin privileges are required to delete account of given username.
+ if not username:
+ username = server.users.get_name(token)
+ elif username != server.users.get_name(token):
+ server.assert_admin_token(token)
+
+ # Delete account.
+ if server.users.has_username(username):
+
+ # Send notification about account deleted to all account tokens.
+ Notifier(server, ignore_tokens=[token]).notify_account_deleted(username)
+
+ # Delete user from server.
+ server.users.remove_user(username)
+
+ # Remove tokens related to this account from loaded server games.
+ # Unregister this account from moderators, omniscient observers and players of loaded games.
+ for server_game in server.games.values(): # type: ServerGame
+ server_game.filter_tokens(server.users.has_token)
+ filter_status = server_game.filter_usernames(server.users.has_username)
+
+ # If this account was a player for this game, notify game about new dummy powers.
+ if filter_status > 0:
+ server.stop_game_if_needed(server_game)
+ Notifier(server, ignore_tokens=[token]).notify_game_powers_controllers(server_game)
+
+ # Require game disk backup.
+ server.save_game(server_game)
+
+ # Require server data disk backup.
+ server.save_data()
+
+def on_delete_game(server, request, connection_handler):
+ """ Manage request DeleteGame.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.DeleteGame
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, power_role=False)
+ server.delete_game(level.game)
+ server.unschedule_game(level.game)
+ Notifier(server, ignore_tokens=[request.token]).notify_game_deleted(level.game)
+
+def on_get_dummy_waiting_powers(server, request, connection_handler):
+ """ Manage request GetAllDummyPowerNames.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: an instance of responses.DataGamesToPowerNames
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetDummyWaitingPowers
+ """
+ verify_request(server, request, connection_handler)
+ return responses.DataGamesToPowerNames(
+ data=server.get_dummy_waiting_power_names(request.buffer_size, request.token), request_id=request.request_id)
+
+def on_get_all_possible_orders(server, request, connection_handler):
+ """ Manage request GetAllPossibleOrders
+ :param server: server which receives the request
+ :param request: request to manage
+ :param connection_handler: connection handler from which the request was sent
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetAllPossibleOrders
+ """
+ level = verify_request(server, request, connection_handler, require_master=False)
+ return responses.DataPossibleOrders(possible_orders=level.game.get_all_possible_orders(),
+ orderable_locations=level.game.get_orderable_locations(),
+ request_id=request.request_id)
+
+def on_get_available_maps(server, request, connection_handler):
+ """ Manage request GetAvailableMaps.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetAvailableMaps
+ """
+ verify_request(server, request, connection_handler)
+ return responses.DataMaps(data=server.available_maps, request_id=request.request_id)
+
+def on_get_playable_powers(server, request, connection_handler):
+ """ Manage request GetPlayablePowers.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetPlayablePowers
+ """
+ verify_request(server, request, connection_handler)
+ return responses.DataPowerNames(
+ data=server.get_game(request.game_id).get_dummy_power_names(), request_id=request.request_id)
+
+def on_get_phase_history(server, request, connection_handler):
+ """ Manage request GetPhaseHistory.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: a DataGamePhases object.
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetPhaseHistory
+ :rtype: diplomacy.communication.responses.DataGamePhases
+ """
+ level = verify_request(server, request, connection_handler, require_master=False)
+ game_phases = level.game.get_phase_history(request.from_phase, request.to_phase, request.game_role)
+ return responses.DataGamePhases(data=game_phases, request_id=request.request_id)
+
+def on_join_game(server, request, connection_handler):
+ """ Manage request JoinGame.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: a Data response with client game data.
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.JoinGame
+ """
+
+ # Check request token.
+ verify_request(server, request, connection_handler)
+ token, power_name, registration_password = request.token, request.power_name, request.registration_password
+
+ # Get related game.
+ server_game = server.get_game(request.game_id) # type: ServerGame
+
+ username = server.users.get_name(token)
+
+ # No power name given, request sender wants to be an observer.
+ if power_name is None:
+
+ # Check given registration password for related game.
+ if not server_game.is_valid_password(registration_password) and not server.token_is_master(token, server_game):
+ raise exceptions.GameRegistrationPasswordException()
+
+ # Request token must not already be a player token.
+ if server_game.has_player_token(token):
+ raise exceptions.GameJoinRoleException()
+
+ # Observations must be allowed for this game, or request sender must be a game master.
+ if server_game.no_observations and not server.token_is_master(token, server_game):
+ raise exceptions.GameObserverException('Disallowed observation for non-master users.')
+
+ # Flag used to check if token was already registered with expected game role
+ # (possibly because of a re-sent request). If True, we can send response
+ # immediately without saving anything.
+ token_already_registered = True
+
+ if server.user_is_omniscient(username, server_game):
+
+ # Request sender is allowed to be omniscient for this game.
+ # Let's set him as an omniscient observer.
+
+ if not server_game.has_omniscient_token(token):
+ # Register request token as omniscient token.
+ server_game.add_omniscient_token(token)
+ token_already_registered = False
+ elif not request.re_sent:
+ # Token already registered but request is a new one.
+ # This should not happen (programming error?).
+ raise exceptions.ResponseException('Token already omniscient from a new request.')
+
+ # Create client game.
+ client_game = server_game.as_omniscient_game(username)
+
+ else:
+
+ # Request sender is not allowed to be omniscient for this game.
+ # Let's set him as an observer.
+
+ # A token should not be registered twice as observer token.
+ if not server_game.has_observer_token(token):
+ # Register request token as observer token.
+ server_game.add_observer_token(token)
+ token_already_registered = False
+ elif not request.re_sent:
+ # Token already registered but request is a new one.
+ # This should not happen (programming error?).
+ raise exceptions.ResponseException('Token already observer.')
+
+ # Create client game.
+ client_game = server_game.as_observer_game(username)
+
+ # If token was already registered, return immediately (no need to save anything).
+ if token_already_registered:
+ return responses.DataGame(data=client_game, request_id=request.request_id)
+
+ # Power name given, request sender wants to be a player.
+ else:
+
+ # Check given registration password for related game.
+ if not (server_game.is_valid_password(registration_password)
+ or server.token_is_master(token, server_game)
+ or username == constants.PRIVATE_BOT_USERNAME):
+ raise exceptions.GameRegistrationPasswordException()
+
+ # No new player allowed if game is ended.
+ if server_game.is_game_completed or server_game.is_game_canceled:
+ raise exceptions.GameFinishedException()
+
+ if not server_game.has_power(power_name):
+ raise exceptions.MapPowerException(power_name)
+
+ if username == constants.PRIVATE_BOT_USERNAME:
+ # Private bot is allowed to control any dummy power after game started
+ # (ie. after reached expected number of real players).
+ # A dummy power controlled by bot is still marked as "dummy", but
+ # has tokens associated.
+ if not server_game.is_game_active and not server_game.is_game_paused:
+ raise exceptions.ResponseException('Game is not active.')
+ if power_name not in server_game.get_dummy_power_names():
+ raise exceptions.ResponseException('Invalid dummy power name %s' % power_name)
+ server_game.get_power(power_name).add_token(token)
+ client_game = server_game.as_power_game(power_name)
+ return responses.DataGame(data=client_game, request_id=request.request_id)
+
+ # Power already controlled by request sender.
+ if server_game.is_controlled_by(power_name, username):
+
+ # Create client game.
+ client_game = server_game.as_power_game(power_name)
+
+ # If token is already registered (probably because of a re-sent request),
+ # then we can send response immediately without saving anything.
+ if server_game.power_has_token(power_name, token):
+ return responses.DataGame(data=client_game, request_id=request.request_id)
+
+ # Otherwise, register token.
+ server_game.get_power(power_name).add_token(token)
+
+ # Power not already controlled by request sender.
+ else:
+
+ # Request token must not be already an observer token or an omniscient token.
+ if server_game.has_observer_token(token) or server_game.has_omniscient_token(token):
+ raise exceptions.GameJoinRoleException()
+
+ # If allowed number of players is already reached, only game masters are allowed to control dummy powers.
+ if server_game.has_expected_controls_count() and not server.token_is_master(token, server_game):
+ raise exceptions.ResponseException(
+ 'Reached maximum number of allowed controlled powers for this game (%d).'
+ % server_game.get_expected_controls_count())
+
+ # If power is already controlled (by someone else), game must allow to select a power randomly.
+ if server_game.is_controlled(power_name) and server_game.power_choice:
+ raise exceptions.ResponseException('You want to control a power that is already controlled,'
+ 'and this game does not allocate powers randomly.')
+
+ # If request sender is already a game player and game does not allow multiple powers per player,
+ # then it cannot register.
+ if server_game.has_player(username) and not server_game.multiple_powers_per_player:
+ raise exceptions.ResponseException('Disallowed multiple powers per player.')
+
+ # If game has no rule POWER_CHOICE, a randomly selected power is assigned to request sender,
+ # whatever be the power he queried.
+ if not server_game.power_choice:
+ power_name = server_game.get_random_power_name()
+
+ # Register sender token as power token.
+ server_game.control(power_name, username, token)
+
+ # Notify other game tokens about new powers controllers.
+ Notifier(server, ignore_addresses=[(power_name, token)]).notify_game_powers_controllers(server_game)
+
+ # Create client game.
+ client_game = server_game.as_power_game(power_name)
+
+ # Start game if it can start.
+ if server_game.game_can_start():
+ server.start_game(server_game)
+
+ # Require game disk backup.
+ server.save_game(server_game)
+
+ return responses.DataGame(data=client_game, request_id=request.request_id)
+
+def on_join_powers(server, request, connection_handler):
+ """ Manage request JoinPowers.
+ Current code does not care about rule POWER_CHOICE. It only
+ checks if queried powers can be joined by request sender.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None.
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.JoinPowers
+ """
+
+ # Check request token.
+ verify_request(server, request, connection_handler)
+ token, power_names = request.token, request.power_names
+ username = server.users.get_name(token)
+
+ if not power_names:
+ raise exceptions.ResponseException('Required at least 1 power name to join powers.')
+
+ # Get related game.
+ server_game = server.get_game(request.game_id) # type: ServerGame
+
+ # No new player allowed if game is ended.
+ if server_game.is_game_completed or server_game.is_game_canceled:
+ raise exceptions.GameFinishedException()
+
+ # Check given registration password for related game.
+ if not (server_game.is_valid_password(request.registration_password)
+ or server.token_is_master(token, server_game)
+ or username == constants.PRIVATE_BOT_USERNAME):
+ raise exceptions.GameRegistrationPasswordException()
+
+ # Check if given power names are valid.
+ for power_name in power_names:
+ if not server_game.has_power(power_name):
+ raise exceptions.MapPowerException(power_name)
+
+ dummy_power_names = server_game.get_dummy_power_names()
+
+ if username == constants.PRIVATE_BOT_USERNAME:
+ # Private bot is allowed to control any dummy power after game started
+ # (ie. after reached expected number of real players).
+ # A dummy power controlled by bot is still marked as "dummy", but
+ # has tokens associated.
+
+ # Check if game is started.
+ if server_game.is_game_forming:
+ raise exceptions.ResponseException('Game is not active.')
+
+ # Check if all given power names are dummy.
+ for power_name in power_names:
+ if power_name not in dummy_power_names:
+ raise exceptions.ResponseException('Invalid dummy power name %s' % power_name)
+
+ # Join bot to each given power name.
+ for power_name in power_names:
+ server_game.get_power(power_name).add_token(token)
+
+ # Done with bot.
+ server.save_game(server_game)
+ return
+
+ # Request token must not be already an observer token or an omniscient token.
+ if server_game.has_observer_token(token) or server_game.has_omniscient_token(token):
+ raise exceptions.GameJoinRoleException()
+
+ # All given powers must be dummy or already controlled by request sender.
+ required_dummy_powers = set()
+ for power_name in power_names:
+ power = server_game.get_power(power_name)
+ if power.is_dummy():
+ required_dummy_powers.add(power_name)
+ elif not power.is_controlled_by(username):
+ raise exceptions.ResponseException('Power %s is controlled by someone else.' % power_name)
+
+ # Nothing to do if all queried powers are already controlled by request sender.
+ if not required_dummy_powers:
+ server.save_game(server_game)
+ return
+
+ # Do additional checks for non-game masters.
+ if not server.token_is_master(token, server_game):
+
+ if len(required_dummy_powers) < len(power_names) and not server_game.multiple_powers_per_player:
+ # Request sender already controls some powers but game does not allow multiple powers per player.
+ raise exceptions.ResponseException('Disallowed multiple powers per player.')
+
+ if server_game.has_expected_controls_count():
+ # Allowed number of players is already reached for this game.
+ raise exceptions.ResponseException(
+ 'Reached maximum number of allowed controlled powers for this game (%d).'
+ % server_game.get_expected_controls_count())
+
+ # Join user to each queried dummy power.
+ for power_name in required_dummy_powers:
+ server_game.control(power_name, username, token)
+
+ # Notify game about new powers controllers.
+
+ Notifier(server).notify_game_powers_controllers(server_game)
+
+ # Start game if it can start.
+ if server_game.game_can_start():
+ server.start_game(server_game)
+
+ # Require game disk backup.
+ server.save_game(server_game)
+
+def on_leave_game(server, request, connection_handler):
+ """ Manage request LeaveGame.
+ If user is an (omniscient) observer, stop observation.
+ Else, stop to control given power name.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.LeaveGame
+ """
+ level = verify_request(server, request, connection_handler, require_master=False)
+ if level.is_power():
+ level.game.set_controlled(level.power_name, None)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_game_powers_controllers(level.game)
+ server.stop_game_if_needed(level.game)
+ else:
+ level.game.remove_special_token(request.game_role, request.token)
+ server.save_game(level.game)
+
+def on_list_games(server, request, connection_handler):
+ """ Manage request ListGames.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: an instance of responses.DataGames
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.ListGames
+ """
+ verify_request(server, request, connection_handler)
+ if request.map_name is not None and server.get_map(request.map_name) is None:
+ raise exceptions.MapIdException()
+ selected_game_indices = []
+ for game_id in server.get_game_indices():
+ if request.game_id and request.game_id not in game_id:
+ continue
+ server_game = server.load_game(game_id)
+ if request.for_omniscience and not server.token_is_omniscient(request.token, server_game):
+ continue
+ if not request.include_protected and server_game.registration_password is not None:
+ continue
+ if request.status and server_game.status != request.status:
+ continue
+ if request.map_name and server_game.map_name != request.map_name:
+ continue
+ username = server.users.get_name(request.token)
+ selected_game_indices.append(responses.DataGameInfo(
+ game_id=server_game.game_id,
+ phase=server_game.current_short_phase,
+ timestamp=server_game.get_latest_timestamp(),
+ map_name=server_game.map_name,
+ observer_level=server_game.get_observer_level(username),
+ controlled_powers=server_game.get_controlled_power_names(username),
+ rules=server_game.rules,
+ status=server_game.status,
+ n_players=server_game.count_controlled_powers(),
+ n_controls=server_game.get_expected_controls_count(),
+ deadline=server_game.deadline,
+ registration_password=bool(server_game.registration_password)
+ ))
+ return responses.DataGames(data=selected_game_indices, request_id=request.request_id)
+
+def on_get_games_info(server, request, connection_handler):
+ """ Manage request GetGamesInfo.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: an instance of responses.DataGames
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.GetGamesInfo
+ """
+ verify_request(server, request, connection_handler)
+ username = server.users.get_name(request.token)
+ games = []
+ for game_id in request.games:
+ try:
+ server_game = server.load_game(game_id)
+ games.append(responses.DataGameInfo(
+ game_id=server_game.game_id,
+ phase=server_game.current_short_phase,
+ timestamp=server_game.get_latest_timestamp(),
+ map_name=server_game.map_name,
+ observer_level=server_game.get_observer_level(username),
+ controlled_powers=server_game.get_controlled_power_names(username),
+ rules=server_game.rules,
+ status=server_game.status,
+ n_players=server_game.count_controlled_powers(),
+ n_controls=server_game.get_expected_controls_count(),
+ deadline=server_game.deadline,
+ registration_password=bool(server_game.registration_password)
+ ))
+ except exceptions.GameIdException:
+ # Invalid game ID, just pass.
+ pass
+ return responses.DataGames(data=games, request_id=request.request_id)
+
+def on_logout(server, request, connection_handler):
+ """ Manage request Logout.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.Logout
+ """
+ verify_request(server, request, connection_handler)
+ server.remove_token(request.token)
+
+def on_process_game(server, request, connection_handler):
+ """ Manage request ProcessGame.
+ Force a game to be processed the sooner.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.ProcessGame
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, power_role=False)
+ assert_game_not_finished(level.game)
+ for power_name in level.game.get_map_power_names():
+ # Force power to not wait and tag it as if it has orders.
+ # (this is valid only for this processing and will be reset for next phase).
+ power = level.game.get_power(power_name)
+ power.order_is_set = OrderSettings.ORDER_SET
+ power.wait = False
+ if level.game.status == strings.FORMING:
+ level.game.set_status(strings.ACTIVE)
+ server.force_game_processing(level.game)
+
+@gen.coroutine
+def on_query_schedule(server, request, connection_handler):
+ """ Manage request QuerySchedule.
+ Force a game to be processed the sooner.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.QuerySchedule
+ """
+ level = verify_request(server, request, connection_handler, require_master=False)
+ schedule_event = yield server.games_scheduler.get_info(level.game)
+ if not schedule_event:
+ raise exceptions.ResponseException('Game not scheduled.')
+ return responses.DataGameSchedule(
+ game_id=level.game.game_id,
+ phase=level.game.current_short_phase,
+ schedule=schedule_event,
+ request_id=request.request_id
+ )
+
+def on_save_game(server, request, connection_handler):
+ """ Manage request SaveGame
+ :param server: server which receives the request
+ :param request: request to manage
+ :param connection_handler: connection handler from which the request was sent
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SaveGame
+ """
+ level = verify_request(server, request, connection_handler, require_master=False)
+ game_json = export.to_saved_game_format(level.game)
+ return responses.DataSavedGame(data=game_json, request_id=request.request_id)
+
+def on_send_game_message(server, request, connection_handler):
+ """ Manage request SendGameMessage.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SendGameMessage
+ """
+ level = verify_request(server, request, connection_handler, omniscient_role=False, observer_role=False)
+ token, message = request.token, request.message
+ assert_game_not_finished(level.game)
+ if level.game.no_press:
+ raise exceptions.ResponseException('Messages not allowed for this game.')
+ if request.game_role != message.sender:
+ raise exceptions.ResponseException('A power can only send its own messages.')
+
+ if not level.game.has_power(message.sender):
+ raise exceptions.MapPowerException(message.sender)
+ if not request.message.is_global():
+ if level.game.public_press:
+ raise exceptions.ResponseException('Only public messages allowed for this game.')
+ if not level.game.is_game_active:
+ raise exceptions.GameNotPlayingException()
+ if level.game.current_short_phase != message.phase:
+ raise exceptions.GamePhaseException(level.game.current_short_phase, message.phase)
+ if not level.game.has_power(message.recipient):
+ raise exceptions.MapPowerException(message.recipient)
+ username = server.users.get_name(token)
+ power_name = message.sender
+ if not level.game.is_controlled_by(power_name, username):
+ raise exceptions.ResponseException('Power name %s is not controlled by given username.' % power_name)
+ if message.sender == message.recipient:
+ raise exceptions.ResponseException('A power cannot send message to itself.')
+
+ if request.re_sent:
+ # Request is re-sent (e.g. after a synchronization). We may have already received this message.
+ # lookup message. WARNING: This may take time if there are many messages. How to improve that ?
+ for archived_message in level.game.messages.reversed_values():
+ if (archived_message.sender == message.sender
+ and archived_message.recipient == message.recipient
+ and archived_message.phase == message.phase
+ and archived_message.message == message.message):
+ # Message found. Send archived time_sent, don't notify anyone.
+ LOGGER.warning('Game message re-sent.')
+ return responses.DataTimeStamp(data=archived_message.time_sent, request_id=request.request_id)
+ # If message not found, consider it as a new message.
+ if message.time_sent is not None:
+ raise exceptions.ResponseException('Server cannot receive a message with a time sent already set.')
+ message.time_sent = level.game.add_message(message)
+ Notifier(server, ignore_addresses=[(request.game_role, token)]).notify_game_message(level.game, message)
+ server.save_game(level.game)
+ return responses.DataTimeStamp(data=message.time_sent, request_id=request.request_id)
+
+def on_set_dummy_powers(server, request, connection_handler):
+ """ Manage request SetDummyPowers.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetDummyPowers
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, power_role=False)
+ assert_game_not_finished(level.game)
+ username, power_names = request.username, request.power_names
+ if username is not None and not server.users.has_username(username):
+ raise exceptions.UserException()
+ if power_names:
+ power_names = [power_name for power_name in power_names if level.game.has_power(power_name)]
+ else:
+ power_names = list(level.game.get_map_power_names())
+ if username is not None:
+ power_names = [power_name for power_name in power_names
+ if level.game.is_controlled_by(power_name, username)]
+ count_before = level.game.count_controlled_powers()
+ level.game.update_dummy_powers(power_names)
+ if count_before != level.game.count_controlled_powers():
+ server.stop_game_if_needed(level.game)
+ Notifier(server).notify_game_powers_controllers(level.game)
+ server.save_game(level.game)
+
+def on_set_game_state(server, request, connection_handler):
+ """ Manage request SetGameState.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetGameState
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, power_role=False)
+ level.game.set_phase_data(GamePhaseData(
+ request.phase, request.state, request.orders, request.results, request.messages))
+ server.stop_game_if_needed(level.game)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_game_phase_data(level.game)
+ server.save_game(level.game)
+
+def on_set_game_status(server, request, connection_handler):
+ """ Manage request SetGameStatus.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetGameStatus
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, power_role=False)
+ status = request.status
+ previous_status = level.game.status
+ if previous_status != status:
+ if previous_status == strings.CANCELED:
+ raise exceptions.GameCanceledException()
+ if previous_status == strings.COMPLETED:
+ raise exceptions.GameFinishedException()
+ level.game.set_status(status)
+ if status == strings.COMPLETED:
+ phase_data_before_draw, phase_data_after_draw = level.game.draw()
+ server.unschedule_game(level.game)
+ Notifier(server).notify_game_processed(level.game, phase_data_before_draw, phase_data_after_draw)
+ else:
+ if status == strings.ACTIVE:
+ server.schedule_game(level.game)
+ elif status == strings.PAUSED:
+ server.unschedule_game(level.game)
+ elif status == strings.CANCELED:
+ server.unschedule_game(level.game)
+ if server.remove_canceled_games:
+ server.delete_game(level.game)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_game_status(level.game)
+ server.save_game(level.game)
+
+def on_set_grade(server, request, connection_handler):
+ """ Manage request SetGrade.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetGrade
+ """
+
+ # Check request token.
+ verify_request(server, request, connection_handler)
+ token, grade, grade_update, username, game_id = (
+ request.token, request.grade, request.grade_update, request.username, request.game_id)
+
+ to_save = False
+
+ if grade == strings.ADMIN:
+
+ # Requested admin grade update.
+
+ # Check if request token is admin.
+ server.assert_admin_token(token)
+
+ # Promote username to administrator only if not already admin.
+ # Demote username from administration only if already admin.
+ if grade_update == strings.PROMOTE:
+ if not server.users.has_admin(username):
+ server.users.add_admin(username)
+ to_save = True
+ elif server.users.has_admin(username):
+ server.users.remove_admin(username)
+ to_save = True
+
+ if to_save:
+
+ # Require server data disk backup.
+ server.save_data()
+
+ # Update each loaded games where user was connected as observer or omniscient
+ # without explicitly allowed to be moderator or omniscient. This means its
+ # observer role has changed (observer -> omniscient or vice versa) in related games.
+ for server_game in server.games.values(): # type: ServerGame
+
+ # We check games where user is not explicitly allowed to be moderator or omniscient.
+ if not server_game.is_moderator(username) and not server_game.is_omniscient(username):
+ transfer_special_tokens(server_game, server, username, grade_update,
+ grade_update == strings.PROMOTE)
+
+ else:
+ # Requested omniscient or moderator grade update for a specific game.
+
+ # Get related game.
+ server_game = server.get_game(game_id)
+
+ # Check if request sender is a game master.
+ server.assert_master_token(token, server_game)
+
+ # We must check if grade update changes omniscient rights for user.
+ # Reminder: a user is omniscient if either server admin, game moderator or game explicit omniscient.
+ # So, even if moderator or explicit omniscient grade is updated for user, his omniscient rights
+ # may not change.
+ user_is_omniscient_before = server.user_is_omniscient(username, server_game)
+
+ if grade == strings.OMNISCIENT:
+
+ # Promote explicitly user to omniscient only if not already explicit omniscient.
+ # Demote explicitly user from omniscience only if already explicit omniscient.
+
+ if grade_update == strings.PROMOTE:
+ if not server_game.is_omniscient(username):
+ server_game.promote_omniscient(username)
+ to_save = True
+ elif server_game.is_omniscient(username):
+ server_game.demote_omniscient(username)
+ to_save = True
+ else:
+
+ # Promote user to moderator if not already moderator.
+ # Demote user from moderation if already moderator.
+
+ if grade_update == strings.PROMOTE:
+ if not server_game.is_moderator(username):
+ server_game.promote_moderator(username)
+ to_save = True
+ elif server_game.is_moderator(username):
+ server_game.demote_moderator(username)
+ to_save = True
+
+ if to_save:
+
+ # Require game disk backup.
+ server.save_game(server_game)
+
+ # Check if user omniscient rights was changed.
+ user_is_omniscient_after = server.user_is_omniscient(username, server_game)
+ if user_is_omniscient_before != user_is_omniscient_after:
+
+ transfer_special_tokens(server_game, server, username, grade_update, user_is_omniscient_after)
+
+def on_set_orders(server, request, connection_handler):
+ """ Manage request SetOrders.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetOrders
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, require_power=True)
+ assert_game_not_finished(level.game)
+ power = level.game.get_power(level.power_name)
+ previous_wait = power.wait
+ power.clear_orders()
+ power.wait = previous_wait
+ level.game.set_orders(level.power_name, request.orders)
+ # Notify other power tokens.
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_power_orders_update(
+ level.game, level.game.get_power(level.power_name), request.orders)
+ if request.wait is not None:
+ level.game.set_wait(level.power_name, request.wait)
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_power_wait_flag(
+ level.game, level.game.get_power(level.power_name), request.wait)
+ if level.game.does_not_wait():
+ server.force_game_processing(level.game)
+ server.save_game(level.game)
+
+def on_set_wait_flag(server, request, connection_handler):
+ """ Manage request SetWaitFlag.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SetWaitFlag
+ """
+ level = verify_request(server, request, connection_handler, observer_role=False, require_power=True)
+ assert_game_not_finished(level.game)
+ level.game.set_wait(level.power_name, request.wait)
+ # Notify other power tokens.
+ Notifier(server, ignore_addresses=[request.address_in_game]).notify_power_wait_flag(
+ level.game, level.game.get_power(level.power_name), request.wait)
+ if level.game.does_not_wait():
+ server.force_game_processing(level.game)
+ server.save_game(level.game)
+
+def on_sign_in(server, request, connection_handler):
+ """ Manage request SignIn.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.SignIn
+ """
+ # No channel/game request verification to do.
+ username, password, create_user = request.username, request.password, request.create_user
+ if create_user:
+ # Register.
+ if not username:
+ raise exceptions.UserException()
+ if not password:
+ raise exceptions.PasswordException()
+ if not server.allow_registrations:
+ raise exceptions.ServerRegistrationException()
+ if server.users.has_username(username):
+ raise exceptions.UserException()
+ server.users.add_user(username, hash_password(password))
+ elif not server.users.has_user(username, password):
+ raise exceptions.UserException()
+ token = server.users.connect_user(username, connection_handler)
+ server.save_data()
+ return responses.DataToken(data=token, request_id=request.request_id)
+
+def on_synchronize(server, request, connection_handler):
+ """ Manage request Synchronize.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.Synchronize
+ """
+
+ level = verify_request(server, request, connection_handler, require_master=False)
+
+ # Get sync data.
+
+ timestamp = request.timestamp
+ if request.game_role == strings.OBSERVER_TYPE:
+ assert level.game.has_observer_token(request.token)
+ elif request.game_role == strings.OMNISCIENT_TYPE:
+ assert level.game.has_omniscient_token(request.token)
+ elif not level.game.power_has_token(request.game_role, request.token):
+ raise exceptions.GamePlayerException()
+ messages = level.game.get_messages(request.game_role, timestamp + 1)
+ if level.is_power():
+ # Don't notify a power about messages she sent herself.
+ messages = {message.time_sent: message for message in messages.values()
+ if message.sender != level.power_name}
+ phase_data_list = level.game.phase_history_from_timestamp(timestamp + 1)
+ current_phase_data = None
+ if phase_data_list:
+ # If there is no new state history, then current state should have not changed
+ # and does not need to be sent. Otherwise current state is a new state
+ # got after a processing, and must be sent.
+ current_phase_data = level.game.get_phase_data()
+ data_to_send = [SynchronizedData(message.time_sent, 0, 'message', message) for message in messages.values()]
+ data_to_send += [SynchronizedData(phase_data.state['timestamp'], 1, 'state_history', phase_data)
+ for phase_data in phase_data_list]
+ if current_phase_data:
+ data_to_send.append(SynchronizedData(current_phase_data.state['timestamp'], 2, 'phase', current_phase_data))
+ data_to_send.sort(key=lambda x: (x.timestamp, x.order))
+
+ # Send sync data.
+
+ notifier = Notifier(server)
+ if strings.role_is_special(request.game_role):
+ addresses = [request.address_in_game]
+ else:
+ addresses = list(level.game.get_power_addresses(request.game_role))
+
+ for data in data_to_send:
+ if data.type == 'message':
+ notifier.notify_game_addresses(
+ level.game.game_id, addresses, notifications.GameMessageReceived, message=data.data)
+ else:
+ if data.type not in ('state_history', 'phase'):
+ raise AssertionError('Unknown synchronized data.')
+ phase_data = level.game.filter_phase_data(data.data, request.game_role, is_current=(data.type == 'phase'))
+ notifier.notify_game_addresses(level.game.game_id, addresses, notifications.GamePhaseUpdate,
+ phase_data=phase_data, phase_data_type=data.type)
+ # Send game status.
+ notifier.notify_game_addresses(level.game.game_id, addresses, notifications.GameStatusUpdate,
+ status=level.game.status)
+ return responses.DataGameInfo(game_id=level.game.game_id,
+ phase=level.game.current_short_phase,
+ timestamp=level.game.get_latest_timestamp(),
+ request_id=request.request_id)
+
+def on_vote(server, request, connection_handler):
+ """ Manage request Vote.
+ :param server: server which receives the request.
+ :param request: request to manage.
+ :param connection_handler: connection handler from which the request was sent.
+ :return: None
+ :type server: diplomacy.Server
+ :type request: diplomacy.communication.requests.Vote
+ """
+ level = verify_request(server, request, connection_handler,
+ omniscient_role=False, observer_role=False, require_power=True)
+ assert_game_not_finished(level.game)
+ power = level.game.get_power(level.power_name)
+ if power.is_eliminated():
+ raise exceptions.ResponseException('Power %s is eliminated.' % power.name)
+ if not power.is_controlled_by(server.users.get_name(request.token)):
+ raise exceptions.GamePlayerException()
+ power.vote = request.vote
+ Notifier(server).notify_game_vote_updated(level.game)
+ if level.game.has_draw_vote():
+ # Votes allows to draw the game.
+ phase_data_before_draw, phase_data_after_draw = level.game.draw()
+ server.unschedule_game(level.game)
+ Notifier(server).notify_game_processed(level.game, phase_data_before_draw, phase_data_after_draw)
+ server.save_game(level.game)
+
+
+# Mapping dictionary from request class to request 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.GetDummyWaitingPowers: on_get_dummy_waiting_powers,
+ requests.GetAllPossibleOrders: on_get_all_possible_orders,
+ requests.GetAvailableMaps: on_get_available_maps,
+ requests.GetPlayablePowers: on_get_playable_powers,
+ requests.GetPhaseHistory: on_get_phase_history,
+ requests.JoinGame: on_join_game,
+ requests.JoinPowers: on_join_powers,
+ requests.LeaveGame: on_leave_game,
+ requests.ListGames: on_list_games,
+ requests.GetGamesInfo: on_get_games_info,
+ requests.Logout: on_logout,
+ requests.ProcessGame: on_process_game,
+ requests.QuerySchedule: on_query_schedule,
+ requests.SaveGame: on_save_game,
+ requests.SendGameMessage: on_send_game_message,
+ requests.SetDummyPowers: on_set_dummy_powers,
+ requests.SetGameState: on_set_game_state,
+ requests.SetGameStatus: on_set_game_status,
+ requests.SetGrade: on_set_grade,
+ requests.SetOrders: on_set_orders,
+ requests.SetWaitFlag: on_set_wait_flag,
+ requests.SignIn: on_sign_in,
+ requests.Synchronize: on_synchronize,
+ requests.Vote: on_vote,
+}
+
+def handle_request(server, request, connection_handler):
+ """ (coroutine) Find request handler function for associated request, run it and return its result.
+ :param server: a Server object to pass to handler function.
+ :param request: a request object to pass to handler function.
+ See diplomacy.communication.requests for possible requests.
+ :param connection_handler: a ConnectionHandler object to pass to handler function.
+ :return: (future) either None or a response object.
+ See module diplomacy.communication.responses for possible responses.
+ """
+ request_handler_fn = MAPPING.get(type(request), None)
+ if not request_handler_fn:
+ raise exceptions.RequestException()
+ if gen.is_coroutine_function(request_handler_fn):
+ # Throw the future returned by this coroutine.
+ return request_handler_fn(server, request, connection_handler)
+ # Create and return a future.
+ future = Future()
+ try:
+ result = request_handler_fn(server, request, connection_handler)
+ future.set_result(result)
+ except exceptions.DiplomacyException as exc:
+ future.set_exception(exc)
+ return future
diff --git a/diplomacy/server/run.py b/diplomacy/server/run.py
new file mode 100755
index 0000000..f47ed4f
--- /dev/null
+++ b/diplomacy/server/run.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Small module script to quickly start a server with pretty log-printing.
+ You can stop the server with keyboard interruption (Ctrl+C). Usage:
+ python -m diplomacy.server.run # run on port 8432.
+ python -m diplomacy.server.run --port=<given port> # run on given port.
+"""
+import argparse
+from diplomacy import Server
+from diplomacy.utils import constants
+
+PARSER = argparse.ArgumentParser(description='Run server.')
+PARSER.add_argument('--port', '-p', type=int, default=constants.DEFAULT_PORT,
+ help='run on the given port (default: %s)' % constants.DEFAULT_PORT)
+ARGS = PARSER.parse_args()
+
+try:
+ Server().start(port=ARGS.port)
+except KeyboardInterrupt:
+ print('Keyboard interruption.')
diff --git a/diplomacy/server/scheduler.py b/diplomacy/server/scheduler.py
new file mode 100644
index 0000000..28bee74
--- /dev/null
+++ b/diplomacy/server/scheduler.py
@@ -0,0 +1,265 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Scheduler used by server to run games.
+
+ Scheduler is configured with a task manager (callback function) and a step time (in seconds)
+ which indicates how long it must wait at each step before checking tasks to process.
+ Then, to add a task, user must specify a data to process and a delay (in number of step times).
+ Deadline is computed using given delay + scheduler step when data was added.
+
+ To set unit as a minute, create Scheduler with unit_in_seconds = 60.
+ In such case, a task with deadline 2 means 2 minutes to wait to process this task.
+ TO set unit as a second, create Scheduler with unit_in_seconds = 1.
+ In such case, a task with deadline 2 means 2 seconds to wait to process this task.
+"""
+from tornado import gen
+from tornado.locks import Lock
+from tornado.queues import Queue
+
+from diplomacy.utils.scheduler_event import SchedulerEvent
+from diplomacy.utils import exceptions
+from diplomacy.utils.priority_dict import PriorityDict
+
+class _Deadline():
+ """ (internal) Deadline value, defined by a start time and a delay, such that deadline = start time + delay. """
+ __slots__ = ['start_time', 'delay']
+
+ def __init__(self, start_time, delay):
+ """ Initialize a deadline with start time and delay, so that deadline = start time + delay.
+ :param start_time: (int)
+ :param delay: (int)
+ """
+ self.start_time = start_time
+ self.delay = delay
+
+ @property
+ def deadline(self):
+ """ Compute and return deadline. """
+ return self.start_time + self.delay
+
+ def __str__(self):
+ return 'Deadline(%d + %d = %d)' % (self.start_time, self.delay, self.deadline)
+
+ def __lt__(self, other):
+ return self.deadline < other.deadline
+
+class _Task():
+ """ (internal) Task class used by scheduler to order scheduled data. It allows auto-rescheduling
+ of a task after it was processed, until either:
+ - task delay is 0.
+ - task manager return a True boolean value (means "data fully processed").
+ - scheduler is explicitly required to remove associated data.
+ """
+ __slots__ = ['data', 'deadline', 'valid']
+
+ def __init__(self, data, deadline):
+ """ Initialize a task.
+ :param data: data to process.
+ :param deadline: Deadline object.
+ :type deadline: _Deadline
+ """
+ self.data = data
+ self.deadline = deadline
+ self.valid = True # Used to ease task removal from Tornado queue.
+
+ def __str__(self):
+ return '%s(%s, %s)' % (self.__class__.__name__, type(self.data).__name__, self.deadline)
+
+ def update_delay(self, new_delay):
+ """ Change deadline delay with given new delay. """
+ self.deadline.delay = new_delay
+
+class _ImmediateTask(_Task):
+ """ (internal) Represents a task intended to be processed as soon as possible the first time,
+ and then scheduled as a normal task for next times. As deadline does not matter for first
+ processing, an immediate task needs a processing validator called the first
+ time to check if it must still be processed. Note that, if validation returns False,
+ the task is not processed the first time and not even added to scheduler for next times.
+ """
+ __slots__ = ['validator']
+
+ def __init__(self, data, future_delay, processing_validator):
+ """ Initialize an immediate task.
+ :param data: data to process.
+ :param future_delay: delay to use to reschedule that task after first processing.
+ :param processing_validator: either a Bool or a callable receiving the data and
+ returning a Bool: processing_validator(data) -> Bool.
+ Validator is used only for the first processing. If evaluated to True, task is
+ processed and then rescheduled for next processing with given future delay.
+ If evaluated to False, task is drop (neither processed nor rescheduled).
+ """
+ super(_ImmediateTask, self).__init__(data, _Deadline(-future_delay, future_delay))
+ if isinstance(processing_validator, bool):
+ self.validator = lambda: processing_validator
+ elif callable(processing_validator):
+ self.validator = lambda: processing_validator(data)
+ else:
+ raise RuntimeError('Validator for immediate task must be either a boolean or a callback(data).')
+
+ def can_still_process(self):
+ """ Return True if this immediate task can still be processed for the first time.
+ If False is returned, task is drop and never processed (not even for a first time).
+ """
+ return self.validator()
+
+ def update_delay(self, new_delay):
+ self.deadline.start_time = -new_delay
+ self.deadline.delay = new_delay
+
+class Scheduler():
+ """ (public) Scheduler class. """
+ __slots__ = ['unit', 'current_time', 'callback_process', 'data_in_queue', 'data_in_heap', 'tasks_queue', 'lock']
+
+ def __init__(self, unit_in_seconds, callback_process):
+ """ Initialize a scheduler.
+ :param unit_in_seconds: number of seconds to wait for each step.
+ :param callback_process: callback to call on every task.
+ Signature:
+ task_callback(task.data) -> bool
+ If callback return True, task is considered done and is removed from scheduler.
+ Otherwise, task is rescheduled for another delay.
+ """
+ assert isinstance(unit_in_seconds, int) and unit_in_seconds > 0
+ assert callable(callback_process)
+ self.unit = unit_in_seconds
+ self.current_time = 0
+ self.callback_process = callback_process
+ self.data_in_heap = PriorityDict() # data => Deadline
+ self.data_in_queue = {} # type: dict{object, _Task} # data => associated Task in queue
+ self.tasks_queue = Queue()
+ # Lock to modify this object safely inside one Tornado thread:
+ # http://www.tornadoweb.org/en/stable/locks.html
+ self.lock = Lock()
+
+ def _enqueue(self, task):
+ """ Put a task in queue of tasks to process now. """
+ self.data_in_queue[task.data] = task
+ self.tasks_queue.put_nowait(task)
+
+ @gen.coroutine
+ def has_data(self, data):
+ """ Return True if given data is associated to any task. """
+ with (yield self.lock.acquire()):
+ return data in self.data_in_heap or data in self.data_in_queue
+
+ @gen.coroutine
+ def get_info(self, data):
+ """ Return info about scheduling for given data, or None if data is not found. """
+ with (yield self.lock.acquire()):
+ deadline = None # type: _Deadline
+ if data in self.data_in_heap:
+ deadline = self.data_in_heap[data]
+ if data in self.data_in_queue:
+ deadline = self.data_in_queue[data].deadline
+ if deadline:
+ return SchedulerEvent(time_unit=self.unit,
+ time_added=deadline.start_time,
+ delay=deadline.delay,
+ current_time=self.current_time)
+ return None
+
+ @gen.coroutine
+ def add_data(self, data, nb_units_to_wait):
+ """ Add data with a non-null deadline. For null deadlines, use no_wait().
+ :param data: data to add
+ :param nb_units_to_wait: time to wait (in number of units)
+ """
+ if not isinstance(nb_units_to_wait, int) or nb_units_to_wait <= 0:
+ raise exceptions.NaturalIntegerNotNullException()
+ with (yield self.lock.acquire()):
+ if data in self.data_in_heap or data in self.data_in_queue:
+ raise exceptions.AlreadyScheduledException()
+ # Add task to scheduler.
+ self.data_in_heap[data] = _Deadline(self.current_time, nb_units_to_wait)
+
+ @gen.coroutine
+ def no_wait(self, data, nb_units_to_wait, processing_validator):
+ """ Add a data to be processed the sooner.
+ :param data: data to add
+ :param nb_units_to_wait: time to wait (in number of units) for data tasks after first task is executed.
+ If null (0), data is processed once (first time) and then dropped.
+ :param processing_validator: validator used to check if data can still be processed for the first time.
+ See documentation of class _ImmediateTask for more details.
+ """
+ if not isinstance(nb_units_to_wait, int) or nb_units_to_wait < 0:
+ raise exceptions.NaturalIntegerException()
+ with (yield self.lock.acquire()):
+ if data in self.data_in_heap:
+ # Move data from heap to queue with new delay.
+ del self.data_in_heap[data]
+ self._enqueue(_ImmediateTask(data, nb_units_to_wait, processing_validator))
+ elif data in self.data_in_queue:
+ # Change delay for future scheduling.
+ self.data_in_queue[data].update_delay(nb_units_to_wait)
+ else:
+ # Add data to queue.
+ self._enqueue(_ImmediateTask(data, nb_units_to_wait, processing_validator))
+
+ @gen.coroutine
+ def remove_data(self, data):
+ """ Remove a data (and all associated tasks) from scheduler. """
+ with (yield self.lock.acquire()):
+ if data in self.data_in_heap:
+ del self.data_in_heap[data]
+ elif data in self.data_in_queue:
+ # Remove task from data_in_queue and invalidate it in queue.
+ self.data_in_queue.pop(data).valid = False
+
+ @gen.coroutine
+ def _step(self):
+ """ Compute a step (check and enqueue tasks to run now) in scheduler. """
+ with (yield self.lock.acquire()):
+ self.current_time += 1
+ while self.data_in_heap:
+ deadline, data = self.data_in_heap.smallest()
+ if deadline.deadline > self.current_time:
+ break
+ del self.data_in_heap[data]
+ self._enqueue(_Task(data, deadline))
+
+ @gen.coroutine
+ def schedule(self):
+ """ Main scheduler method (callback to register in ioloop). Wait for unit seconds and
+ run tasks after each wait time.
+ """
+ while True:
+ yield gen.sleep(self.unit)
+ yield self._step()
+
+ @gen.coroutine
+ def process_tasks(self):
+ """ Main task processing method (callback to register in ioloop). Consume and process tasks in queue
+ and reschedule processed tasks when relevant.
+ A task is processed if associated data was not removed from scheduler.
+ A task is rescheduler if processing callback returns False (True meaning `task definitively done`)
+ AND if task deadline is not null.
+ """
+ while True:
+ task = yield self.tasks_queue.get() # type: _Task
+ try:
+ if task.valid and (not isinstance(task, _ImmediateTask) or task.can_still_process()):
+ if gen.is_coroutine_function(self.callback_process):
+ remove_data = yield self.callback_process(task.data)
+ else:
+ remove_data = self.callback_process(task.data)
+ remove_data = remove_data or not task.deadline.delay
+ with (yield self.lock.acquire()):
+ del self.data_in_queue[task.data]
+ if not remove_data:
+ self.data_in_heap[task.data] = _Deadline(self.current_time, task.deadline.delay)
+ finally:
+ self.tasks_queue.task_done()
diff --git a/diplomacy/server/server.py b/diplomacy/server/server.py
new file mode 100644
index 0000000..5763991
--- /dev/null
+++ b/diplomacy/server/server.py
@@ -0,0 +1,797 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Concret standalone server object. Manages and save server data and games on disk, send notifications,
+ receives requests and send responses.
+
+ Example:
+ >>> from diplomacy import Server
+ >>> Server().start(port=1234) # If port is not given, a random port will be selected.
+
+ You can interrupt server by sending a keyboard interrupt signal (Ctrl+C).
+ >>> from diplomacy import Server
+ >>> try:
+ >>> Server().start()
+ >>> except KeyboardInterrupt:
+ >>> print('Server interrupted.')
+
+ You can also configure some server attributes when instantiating it:
+ >>> from diplomacy import Server
+ >>> server = Server(backup_delay_seconds=5)
+ >>> server.start()
+
+ These are public configurable server attributes. They are saved on disk at each server backup:
+ - allow_user_registrations: (bool) indicate if server accepts users registrations
+ (default True)
+ - backup_delay_seconds: (int) number of seconds to wait between two consecutive full server backup on disk
+ (default 10 minutes)
+ - ping_seconds: (int) ping period used by server to check is connected sockets are alive.
+ - max_games: (int) maximum number of games server accepts to create. If there are at least such number of games on
+ server, server will not accept further game creation requests. If 0, no limit.
+ (default 0)
+ - remove_canceled_games: (bool) indicate if games must be deleted from server database when they are canceled
+ (default False)
+
+"""
+import atexit
+import logging
+import os
+import signal
+import uuid
+
+import tornado
+import tornado.web
+from tornado import gen
+from tornado.ioloop import IOLoop
+from tornado.queues import Queue
+from tornado.websocket import WebSocketClosedError
+
+import ujson as json
+
+import diplomacy.settings
+from diplomacy.communication import notifications
+from diplomacy.server.connection_handler import ConnectionHandler
+from diplomacy.server.notifier import Notifier
+from diplomacy.server.scheduler import Scheduler
+from diplomacy.server.server_game import ServerGame
+from diplomacy.server.users import Users
+from diplomacy.engine.map import Map
+from diplomacy.utils import common, exceptions, strings, constants
+
+LOGGER = logging.getLogger(__name__)
+
+def get_absolute_path(directory=None):
+ """ Return absolute path of given directory.
+ If given directory is None, return absolute path of current directory.
+ """
+ return os.path.abspath(directory or os.getcwd())
+
+def get_backup_filename(filename):
+ """ Return a backup filename from given filename (given filename with a special suffix). """
+ return '%s.backup' % filename
+
+def save_json_on_disk(filename, json_dict):
+ """ Save given JSON dictionary into given filename and back-up previous file version if exists. """
+ if os.path.exists(filename):
+ os.rename(filename, get_backup_filename(filename))
+ with open(filename, 'w') as file:
+ json.dump(json_dict, file)
+
+def load_json_from_disk(filename):
+ """ Return a JSON dictionary loaded from given filename.
+ If JSON parsing fail for given filename, try to load JSON dictionary for a backup file (if present)
+ and rename backup file to given filename (backup file becomes current file versions).
+ :rtype: dict
+ """
+ try:
+ with open(filename, 'rb') as file:
+ json_dict = json.load(file)
+ except ValueError as exception:
+ backup_filename = get_backup_filename(filename)
+ if not os.path.isfile(backup_filename):
+ raise exception
+ with open(backup_filename, 'rb') as backup_file:
+ json_dict = json.load(backup_file)
+ os.rename(backup_filename, filename)
+ return json_dict
+
+def ensure_path(folder_path):
+ """ Make sure given folder path exists and return given path.
+ Raises an exception if path does not exists, cannot be created or is not a folder.
+ """
+ if not os.path.exists(folder_path):
+ LOGGER.info('Creating folder %s', folder_path)
+ os.makedirs(folder_path, exist_ok=True)
+ if not os.path.exists(folder_path) or not os.path.isdir(folder_path):
+ raise exceptions.FolderException(folder_path)
+ return folder_path
+
+class InterruptionHandler():
+ """ Helper class used to save server when a system interruption signal is sent (e.g. KeyboardInterrupt). """
+ __slots__ = ['server', 'previous_handler']
+
+ def __init__(self, server):
+ """ Initializer the handler.
+ :param server: server to save
+ """
+ self.server = server # type: Server
+ self.previous_handler = signal.getsignal(signal.SIGINT)
+
+ def handler(self, signum, frame):
+ """ Handler function.
+ :param signum: system signal received
+ :param frame: frame received
+ """
+ if signum == signal.SIGINT:
+ self.server.backup_now(force=True)
+ if self.previous_handler:
+ self.previous_handler(signum, frame)
+
+class _ServerBackend():
+ """ Class representing tornado objects used to run a server. Properties:
+ - port: (integer) port where server runs.
+ - application: tornado web Application object.
+ - http_server: tornado HTTP server object running server code.
+ - io_loop: tornado IO loop where server runs.
+ """
+ #pylint: disable=too-few-public-methods
+ __slots__ = ['port', 'application', 'http_server', 'io_loop']
+
+
+ def __init__(self):
+ """ Initialize server backend. """
+ self.port = None
+ self.application = None
+ self.http_server = None
+ self.io_loop = None
+
+class Server():
+ """ Server class. """
+ __slots__ = ['data_path', 'games_path', 'available_maps', 'maps_mtime', 'notifications',
+ 'games_scheduler', 'allow_registrations', 'max_games', 'remove_canceled_games', 'users', 'games',
+ 'backup_server', 'backup_games', 'backup_delay_seconds', 'ping_seconds',
+ 'interruption_handler', 'backend', 'games_with_dummy_powers', 'dispatched_dummy_powers']
+
+ # Servers cache.
+ __cache__ = {} # {absolute path of working folder => Server}
+
+ def __new__(cls, server_dir=None, **kwargs):
+ #pylint: disable=unused-argument
+ server_dir = get_absolute_path(server_dir)
+ if server_dir in cls.__cache__:
+ server = cls.__cache__[server_dir]
+ else:
+ server = object.__new__(cls)
+ return server
+
+ def __init__(self, server_dir=None, **kwargs):
+ """ Initialize the server.
+ :param server_dir: path of folder in (from) which server data will be saved (loaded).
+ If None, working directory (where script is executed) will be used.
+ :param kwargs: (optional) values for some public configurable server attributes.
+ Given values will overwrite values saved on disk.
+ Server data is stored in folder `<working directory>/data`.
+ """
+
+ # File paths and attributes related to database.
+ server_dir = get_absolute_path(server_dir)
+ if server_dir in self.__class__.__cache__:
+ return
+ if not os.path.exists(server_dir) or not os.path.isdir(server_dir):
+ raise exceptions.ServerDirException(server_dir)
+ self.data_path = os.path.join(server_dir, 'data')
+ self.games_path = os.path.join(self.data_path, 'games')
+
+ # Data in memory (not stored on disk).
+ self.notifications = Queue()
+ self.games_scheduler = Scheduler(1, self._process_game)
+ self.backup_server = None
+ self.backup_games = {}
+ self.interruption_handler = InterruptionHandler(self)
+ # Backend objects used to run server. If None, server is not yet started.
+ # Initialized when you call Server.start() (see method below).
+ self.backend = None # type: _ServerBackend
+
+ # Database (stored on disk).
+ self.allow_registrations = True
+ self.max_games = 0
+ self.remove_canceled_games = False
+ self.backup_delay_seconds = constants.DEFAULT_BACKUP_DELAY_SECONDS
+ self.ping_seconds = constants.DEFAULT_PING_SECONDS
+ self.users = None # type: Users # Users and administrators usernames.
+ self.available_maps = {} # type: dict{str, set()} # {"map_name" => set("map_power")}
+ self.maps_mtime = 0 # Latest maps modification date (used to manage maps cache in server object).
+
+ # Server games loaded on memory (stored on disk).
+ # Saved separately (each game in one JSON file).
+ # Each game also stores tokens connected (player tokens, observer tokens, omniscient tokens).
+ self.games = {} # type: dict{str, ServerGame}
+
+ # Dictionary mapping game IDs to dummy power names.
+ self.games_with_dummy_powers = {} # type: dict{str, set}
+
+ # Dictionary mapping a game ID present in games_with_dummy_powers, to
+ # a couple of associated bot token and time when bot token was associated to this game ID.
+ # If there is no bot token associated, couple is (None, None).
+ self.dispatched_dummy_powers = {} # type: dict{str, tuple}
+
+ # Load data on memory.
+ self._load()
+
+ # If necessary, updated server configurable attributes from kwargs.
+ self.allow_registrations = bool(kwargs.pop(strings.ALLOW_REGISTRATIONS, self.allow_registrations))
+ self.max_games = int(kwargs.pop(strings.MAX_GAMES, self.max_games))
+ self.remove_canceled_games = bool(kwargs.pop(strings.REMOVE_CANCELED_GAMES, self.remove_canceled_games))
+ self.backup_delay_seconds = int(kwargs.pop(strings.BACKUP_DELAY_SECONDS, self.backup_delay_seconds))
+ self.ping_seconds = int(kwargs.pop(strings.PING_SECONDS, self.ping_seconds))
+ assert not kwargs
+ LOGGER.debug('Ping : %s', self.ping_seconds)
+ LOGGER.debug('Backup delay: %s', self.backup_delay_seconds)
+
+ # Add server on servers cache.
+ self.__class__.__cache__[server_dir] = self
+
+ @property
+ def port(self):
+ """ Property: return port where this server currently runs, or None if server is not yet started. """
+ return self.backend.port if self.backend else None
+
+ def _load_available_maps(self):
+ """ Load a dictionary (self.available_maps) mapping every map name to a dict of map info.
+ for all maps available in diplomacy package.
+ """
+ diplomacy_map_dir = os.path.join(diplomacy.settings.PACKAGE_DIR, strings.MAPS)
+ new_maps_mtime = self.maps_mtime
+ for filename in os.listdir(diplomacy_map_dir):
+ if filename.endswith('.map'):
+ map_filename = os.path.join(diplomacy_map_dir, filename)
+ map_mtime = os.path.getmtime(map_filename)
+ map_name = filename[:-4]
+ if map_name not in self.available_maps or map_mtime > self.maps_mtime:
+ # Either it's a new map file or map file was modified.
+ available_map = Map(map_name)
+ self.available_maps[map_name] = {
+ 'powers': set(available_map.powers),
+ 'supply_centers': set(available_map.scs),
+ 'loc_type': available_map.loc_type.copy(),
+ 'loc_abut': available_map.loc_abut.copy(),
+ 'aliases': available_map.aliases.copy()
+ }
+ new_maps_mtime = max(new_maps_mtime, map_mtime)
+ self.maps_mtime = new_maps_mtime
+
+ def _get_server_data_filename(self):
+ """ Return path to server data file name (server.json, making sure that data folder exists.
+ Raises an exception if data folder does not exists and cannot be created.
+ """
+ return os.path.join(ensure_path(self.data_path), 'server.json')
+
+ def _load(self):
+ """ Load database from disk. """
+ LOGGER.info("Loading database.")
+ ensure_path(self.data_path) # <server dir>/data
+ ensure_path(self.games_path) # <server dir>/data/games
+ server_data_filename = self._get_server_data_filename() # <server dir>/data/server.json
+ if os.path.exists(server_data_filename):
+ LOGGER.info("Loading server.json.")
+ server_info = load_json_from_disk(server_data_filename)
+ self.allow_registrations = server_info[strings.ALLOW_REGISTRATIONS]
+ self.backup_delay_seconds = server_info[strings.BACKUP_DELAY_SECONDS]
+ self.ping_seconds = server_info[strings.PING_SECONDS]
+ self.max_games = server_info[strings.MAX_GAMES]
+ self.remove_canceled_games = server_info[strings.REMOVE_CANCELED_GAMES]
+ self.users = Users.from_dict(server_info[strings.USERS])
+ self.available_maps = server_info[strings.AVAILABLE_MAPS]
+ self.maps_mtime = server_info[strings.MAPS_MTIME]
+ # games and map are loaded from disk.
+ else:
+ LOGGER.info("Creating server.json.")
+ self.users = Users()
+ self.backup_now(force=True)
+ # Add default accounts.
+ for (username, password) in (
+ ('admin', 'password'),
+ (constants.PRIVATE_BOT_USERNAME, constants.PRIVATE_BOT_PASSWORD)
+ ):
+ if not self.users.has_username(username):
+ self.users.add_user(username, common.hash_password(password))
+ # Set default admin account.
+ self.users.add_admin('admin')
+
+ self._load_available_maps()
+
+ LOGGER.info('Server loaded.')
+
+ def _backup_server_data_now(self, force=False):
+ """ Save latest backed-up version of server data on disk. This does not save games.
+ :param force: if True, force to save current server data even if it was not modified recently.
+ """
+ if force:
+ self.save_data()
+ if self.backup_server:
+ save_json_on_disk(self._get_server_data_filename(), self.backup_server)
+ self.backup_server = None
+ LOGGER.info("Saved server.json.")
+
+ def _backup_games_now(self, force=False):
+ """ Save latest backed-up versions of loaded games on disk.
+ :param force: if True, force to save all games currently loaded in memory
+ even if they were not modified recently.
+ """
+ ensure_path(self.games_path)
+ if force:
+ for server_game in self.games.values():
+ self.save_game(server_game)
+ for game_id, game_dict in self.backup_games.items():
+ game_path = os.path.join(self.games_path, '%s.json' % game_id)
+ save_json_on_disk(game_path, game_dict)
+ LOGGER.info('Game data saved: %s', game_id)
+ self.backup_games.clear()
+
+ def backup_now(self, force=False):
+ """ Save backup of server data and loaded games immediately.
+ :param force: if True, force to save server data and all loaded games even if there are no recent changes.
+ """
+ self._backup_server_data_now(force=force)
+ self._backup_games_now(force=force)
+
+ @gen.coroutine
+ def _process_game(self, server_game):
+ """ Process given game and send relevant notifications.
+ :param server_game: server game to process
+ :return: A boolean indicating if we must stop game.
+ :type server_game: ServerGame
+ """
+ LOGGER.debug('Processing game %s (status %s).', server_game.game_id, server_game.status)
+ previous_phase_data, current_phase_data, kicked_powers = server_game.process()
+ self.save_game(server_game)
+
+ if previous_phase_data is None and kicked_powers is None:
+ # Game must be unscheduled immediately.
+ return True
+
+ notifier = Notifier(self)
+ # In any case, we notify game tokens about changes in power controllers.
+ yield notifier.notify_game_powers_controllers(server_game)
+
+ if kicked_powers:
+ # Game was not processed because of kicked powers.
+ # We notify those kicked powers and game must be unscheduled immediately.
+ kicked_addresses = [(power_name, token)
+ for (power_name, tokens) in kicked_powers.items()
+ for token in tokens]
+ # Notify kicked players.
+ notifier.notify_game_addresses(
+ server_game.game_id,
+ kicked_addresses,
+ notifications.PowersControllers,
+ powers=server_game.get_controllers(),
+ timestamps=server_game.get_controllers_timestamps()
+ )
+ return True
+
+ # Game was processed normally.
+ # Send game updates to powers, observers and omniscient observers.
+ yield notifier.notify_game_processed(server_game, previous_phase_data, current_phase_data)
+ return not server_game.is_game_active
+
+ @gen.coroutine
+ def _task_save_database(self):
+ """ IO loop callable: save database and loaded games periodically.
+ Data to save are checked every BACKUP_DELAY_SECONDS seconds.
+ """
+ LOGGER.info('Waiting for save events.')
+ while True:
+ yield gen.sleep(self.backup_delay_seconds)
+ self.backup_now()
+
+ @gen.coroutine
+ def _task_send_notifications(self):
+ """ IO loop callback: consume notifications and send it. """
+ LOGGER.info('Waiting for notifications to send.')
+ while True:
+ connection_handler, notification = yield self.notifications.get()
+ try:
+ yield connection_handler.write_message(notification.json())
+ except WebSocketClosedError:
+ LOGGER.error('Websocket was closed while sending a notification.')
+ finally:
+ self.notifications.task_done()
+
+ def set_tasks(self, io_loop: IOLoop):
+ """ Set server callbacks on given IO loop. Must be called once per server before starting IO loop. """
+ io_loop.add_callback(self._task_save_database)
+ io_loop.add_callback(self._task_send_notifications)
+ # These both coroutines are used to manage games.
+ io_loop.add_callback(self.games_scheduler.process_tasks)
+ io_loop.add_callback(self.games_scheduler.schedule)
+ # Set callback on KeyboardInterrupt.
+ signal.signal(signal.SIGINT, self.interruption_handler.handler)
+ atexit.register(self.backup_now)
+
+ def start(self, port=None, io_loop=None):
+ """ Start server if not yet started. Raise an exception if server is already started.
+ :param port: (optional) port where server must run. If not provided, try to start on a random
+ selected port. Use property `port` to get current server port.
+ :param io_loop: (optional) tornado IO lopp where server must run. If not provided, get
+ default IO loop instance (tornado.ioloop.IOLoop.instance()).
+ """
+ if self.backend is not None:
+ raise exceptions.DiplomacyException('Server is already running on port %s.' % self.backend.port)
+ if port is None:
+ port = 8432
+ if io_loop is None:
+ io_loop = tornado.ioloop.IOLoop.instance()
+ handlers = [
+ tornado.web.url(r"/", ConnectionHandler, {'server': self}),
+ ]
+ settings = {
+ 'cookie_secret': common.generate_token(),
+ 'xsrf_cookies': True,
+ 'websocket_ping_interval': self.ping_seconds,
+ 'websocket_ping_timeout': 2 * self.ping_seconds,
+ 'websocket_max_message_size': 64 * 1024 * 1024
+ }
+ self.backend = _ServerBackend()
+ self.backend.application = tornado.web.Application(handlers, **settings)
+ self.backend.http_server = self.backend.application.listen(port)
+ self.backend.io_loop = io_loop
+ self.backend.port = port
+ self.set_tasks(io_loop)
+ LOGGER.info('Running on port %d', self.backend.port)
+ io_loop.start()
+
+ def get_game_indices(self):
+ """ Iterate over all game indices in server database.
+ Convenient method to iterate over all server games (by calling load_game() on each game index).
+ """
+ for game_id in self.games:
+ yield game_id
+ if os.path.isdir(self.games_path):
+ for filename in os.listdir(self.games_path):
+ if filename.endswith('.json'):
+ game_id = filename[:-5]
+ if game_id not in self.games:
+ yield game_id
+
+ def count_server_games(self):
+ """ Return number of server games in server database. """
+ count = 0
+ if os.path.isdir(self.games_path):
+ for filename in os.listdir(self.games_path):
+ if filename.endswith('.json'):
+ count += 1
+ return count
+
+ def save_data(self):
+ """ Update on-memory backup of server data. """
+ self.backup_server = {
+ strings.ALLOW_REGISTRATIONS: self.allow_registrations,
+ strings.BACKUP_DELAY_SECONDS: self.backup_delay_seconds,
+ strings.PING_SECONDS: self.ping_seconds,
+ strings.MAX_GAMES: self.max_games,
+ strings.REMOVE_CANCELED_GAMES: self.remove_canceled_games,
+ strings.USERS: self.users.to_dict(),
+ strings.AVAILABLE_MAPS: self.available_maps,
+ strings.MAPS_MTIME: self.maps_mtime,
+ }
+
+ def save_game(self, server_game):
+ """ Update on-memory version of given server game.
+ :param server_game: server game
+ :type server_game: ServerGame
+ """
+ self.backup_games[server_game.game_id] = server_game.to_dict()
+ # Check dummy powers for a game every time we have to save it.
+ self.register_dummy_power_names(server_game)
+
+ def register_dummy_power_names(self, server_game):
+ """ Update internal registry of dummy power names waiting for orders
+ for given server games.
+ :param server_game: server game to check
+ :type server_game: ServerGame
+ """
+ updated = False
+ if server_game.is_game_active or server_game.is_game_paused:
+ dummy_power_names = []
+ for power_name in server_game.get_dummy_power_names():
+ power = server_game.get_power(power_name)
+ if power.is_dummy() and not power.is_eliminated() and not power.does_not_wait():
+ # This dummy power needs either orders, or wait flag to be set to False.
+ dummy_power_names.append(power_name)
+ if dummy_power_names:
+ # Update registry of dummy powers.
+ self.games_with_dummy_powers[server_game.game_id] = dummy_power_names
+ # Every time we update registry of dummy powers,
+ # then we also update bot time in registry of dummy powers associated to bot tokens.
+ bot_token, _ = self.dispatched_dummy_powers.get(server_game.game_id, (None, None))
+ self.dispatched_dummy_powers[server_game.game_id] = (bot_token, common.timestamp_microseconds())
+ updated = True
+ if not updated:
+ # Registry not updated for this game, meaning that there is no
+ # dummy powers waiting for orders or 'no wait' for this game.
+ self.games_with_dummy_powers.pop(server_game.game_id, None)
+ # We remove game from registry of dummy powers associated to bot tokens only if game is terminated.
+ # Otherwise, game will remain associated to a previous bot token, until bot failed to order powers.
+ if server_game.is_game_completed or server_game.is_game_canceled:
+ self.dispatched_dummy_powers.pop(server_game.game_id, None)
+
+ def get_dummy_waiting_power_names(self, buffer_size, bot_token):
+ """ Return names of dummy powers waiting for orders for current loaded games.
+ This query is allowed only for bot tokens.
+ :param buffer_size: maximum number of powers queried.
+ :param bot_token: bot token
+ :return: a dictionary mapping game IDs to lists of power names.
+ """
+ if self.users.get_name(bot_token) != constants.PRIVATE_BOT_USERNAME:
+ raise exceptions.ResponseException('Invalid bot token %s' % bot_token)
+ selected_size = 0
+ selected_games = {}
+ for game_id in sorted(list(self.games_with_dummy_powers.keys())):
+ registered_token, registered_time = self.dispatched_dummy_powers[game_id]
+ if registered_token is not None:
+ time_elapsed_seconds = (common.timestamp_microseconds() - registered_time) / 1000000
+ if time_elapsed_seconds > constants.PRIVATE_BOT_TIMEOUT_SECONDS or registered_token == bot_token:
+ # This game still has dummy powers but time allocated to previous bot token is over.
+ # Forget previous bot token.
+ registered_token = None
+ if registered_token is None:
+ # This game is not associated to any bot token.
+ # Let current bot token handle it if buffer size is not reached.
+ dummy_power_names = self.games_with_dummy_powers[game_id]
+ nb_powers = len(dummy_power_names)
+ if selected_size + nb_powers > buffer_size:
+ # Buffer size would be exceeded. We stop to collect games now.
+ break
+ # Otherwise we collect this game.
+ selected_games[game_id] = dummy_power_names
+ selected_size += nb_powers
+ self.dispatched_dummy_powers[game_id] = (bot_token, common.timestamp_microseconds())
+ return selected_games
+
+ def has_game_id(self, game_id):
+ """ Return True if server database contains such game ID. """
+ if game_id in self.games:
+ return True
+ expected_game_path = os.path.join(self.games_path, '%s.json' % game_id)
+ return os.path.exists(expected_game_path) and os.path.isfile(expected_game_path)
+
+ def load_game(self, game_id):
+ """ Return a game matching given game ID from server database.
+ Raise an exception if such game does not exists.
+ If such game is already stored in server object, return it.
+ Else, load it from disk but ** does not store it in server object **.
+ To load and immediately store a game object in server object, please use method get_game().
+ Method load_game() is convenient where you want to iterate over all games in server database
+ without taking memory space.
+ :param game_id: ID of game to load.
+ :return: a ServerGame object
+ :rtype: ServerGame
+ """
+ if game_id in self.games:
+ return self.games[game_id]
+ game_filename = os.path.join(ensure_path(self.games_path), '%s.json' % game_id)
+ if not os.path.isfile(game_filename):
+ raise exceptions.GameIdException()
+ try:
+ server_game = ServerGame.from_dict(load_json_from_disk(game_filename)) # type: ServerGame
+ server_game.server = self
+ server_game.filter_usernames(self.users.has_username)
+ server_game.filter_tokens(self.users.has_token)
+ return server_game
+ except ValueError as exc:
+ # Error occurred while parsing JSON file: bad JSON file.
+ try:
+ os.remove(game_filename)
+ finally:
+ # This should be an internal server error.
+ raise exc
+
+ def add_new_game(self, server_game):
+ """ Add a new game data on server in memory. This does not save the game on disk.
+ :type server_game: ServerGame
+ """
+ self.games[server_game.game_id] = server_game
+
+ def get_game(self, game_id):
+ """ Return game saved on server matching given game ID. Raise an exception if game ID not found.
+ Return game if already loaded on memory, else load it from disk, store it and return it.
+ :param game_id: ID of game to load.
+ :return: a ServerGame object.
+ :rtype: ServerGame
+ """
+ server_game = self.load_game(game_id)
+ if game_id not in self.games:
+ LOGGER.debug('Game loaded: %s', game_id)
+ # Check dummy powers for this game as soon as it's loaded from disk.
+ self.register_dummy_power_names(server_game)
+ self.games[server_game.game_id] = server_game
+ # We have just loaded game from disk. Start it if necessary.
+ if not server_game.start_master and server_game.has_expected_controls_count():
+ # We may have to start game.
+ stop = False
+ if server_game.does_not_wait():
+ # We must process game.
+ process_result = server_game.process()
+ stop = process_result is None or process_result[-1]
+ self.save_game(server_game)
+ if not stop:
+ LOGGER.debug('Game loaded and scheduled: %s', server_game.game_id)
+ self.schedule_game(server_game)
+ return server_game
+
+ def delete_game(self, server_game):
+ """ Delete given game from server (both from memory and disk).
+ :param server_game: game to delete
+ :type server_game: ServerGame
+ """
+ if not (server_game.is_game_canceled or server_game.is_game_completed):
+ server_game.set_status(strings.CANCELED)
+ game_filename = os.path.join(self.games_path, '%s.json' % server_game.game_id)
+ if os.path.isfile(game_filename):
+ os.remove(game_filename)
+ self.games.pop(server_game.game_id, None)
+ self.games_with_dummy_powers.pop(server_game.game_id, None)
+ self.dispatched_dummy_powers.pop(server_game.game_id, None)
+
+ @gen.coroutine
+ def schedule_game(self, server_game):
+ """ Add a game to scheduler only if game has a deadline and is not already scheduled.
+ To add games without deadline, use force_game_processing().
+ :param server_game: game
+ :type server_game: ServerGame
+ """
+ if not (yield self.games_scheduler.has_data(server_game)) and server_game.deadline:
+ yield self.games_scheduler.add_data(server_game, server_game.deadline)
+
+ @gen.coroutine
+ def unschedule_game(self, server_game):
+ """ Remove a game from scheduler.
+ :param server_game: game
+ :type server_game: ServerGame
+ """
+ if (yield self.games_scheduler.has_data(server_game)):
+ yield self.games_scheduler.remove_data(server_game)
+
+ @gen.coroutine
+ def force_game_processing(self, server_game):
+ """ Add a game to scheduler to be processed as soon as possible.
+ Use this method instead of schedule_game() to explicitly add games with null deadline.
+ :param server_game: game
+ :type server_game: ServerGame
+ """
+ yield self.games_scheduler.no_wait(server_game, server_game.deadline, lambda g: g.does_not_wait())
+
+ def start_game(self, server_game):
+ """ Start given server game.
+ :param server_game: server game
+ :type server_game: ServerGame
+ """
+ server_game.set_status(strings.ACTIVE)
+ self.schedule_game(server_game)
+ Notifier(self).notify_game_status(server_game)
+
+ def stop_game_if_needed(self, server_game):
+ """ Stop game if it has not required number of controlled powers. Notify game if status changed.
+ :param server_game: game to check
+ :param server_game: game
+ :type server_game: ServerGame
+ """
+ if server_game.is_game_active and (
+ server_game.count_controlled_powers() < server_game.get_expected_controls_count()):
+ server_game.set_status(strings.FORMING)
+ self.unschedule_game(server_game)
+ Notifier(self).notify_game_status(server_game)
+
+ def user_is_master(self, username, server_game):
+ """ Return True if given username is a game master for given game data.
+ :param username: username
+ :param server_game: game data
+ :return: a boolean
+ :type server_game: ServerGame
+ :rtype: bool
+ """
+ return self.users.has_admin(username) or server_game.is_moderator(username)
+
+ def user_is_omniscient(self, username, server_game):
+ """ Return True if given username is omniscient for given game data.
+ :param username: username
+ :param server_game: game data
+ :return: a boolean
+ :type server_game: ServerGame
+ :rtype: bool
+ """
+ return self.users.has_admin(username) or server_game.is_moderator(username) or server_game.is_omniscient(
+ username)
+
+ def token_is_master(self, token, server_game):
+ """ Return True if given token is a master token for given game data.
+ :param token: token
+ :param server_game: game data
+ :return: a boolean
+ :type server_game: ServerGame
+ :rtype: bool
+ """
+ return self.users.has_token(token) and self.user_is_master(self.users.get_name(token), server_game)
+
+ def token_is_omniscient(self, token, server_game):
+ """ Return True if given token is omniscient for given game data.
+ :param token: token
+ :param server_game: game data
+ :return: a boolean
+ :type server_game: ServerGame
+ :rtype: bool
+ """
+ return self.users.has_token(token) and self.user_is_omniscient(self.users.get_name(token), server_game)
+
+ def create_game_id(self):
+ """ Create and return a game ID not already used by a game in server database. """
+ game_id = str(uuid.uuid4())
+ while self.has_game_id(game_id):
+ game_id = str(uuid.uuid4())
+ return game_id
+
+ def remove_token(self, token):
+ """ Disconnect given token from related user and loaded games.
+ Stop related games if needed, e.g. if a game does not have anymore
+ expected number of controlled powers.
+ """
+ self.users.disconnect_token(token)
+ for server_game in self.games.values(): # type: ServerGame
+ server_game.remove_token(token)
+ self.stop_game_if_needed(server_game)
+ self.save_game(server_game)
+ self.save_data()
+
+ def assert_token(self, token, connection_handler):
+ """ Check if given token is associated to an user, check if token is still valid, and link token to given
+ connection handler. If any step failed, raise an exception.
+ :param token: token to check
+ :param connection_handler: connection handler associated to this token
+ """
+ if not self.users.has_token(token):
+ raise exceptions.TokenException()
+ if self.users.token_is_alive(token):
+ self.users.relaunch_token(token)
+ self.save_data()
+ else:
+ # Logout on server side and raise exception (invalid token).
+ LOGGER.error('Token too old %s', token)
+ self.remove_token(token)
+ raise exceptions.TokenException()
+ self.users.attach_connection_handler(token, connection_handler)
+
+ def assert_admin_token(self, token):
+ """ Check if given token is an admin token. Raise an exception on error. """
+ if not self.users.token_is_admin(token):
+ raise exceptions.AdminTokenException()
+
+ def assert_master_token(self, token, server_game):
+ """ Check if given token is a master token for given game data. Raise an exception on error.
+ :param token: token
+ :param server_game: game data
+ :type server_game: ServerGame
+ """
+ if not self.token_is_master(token, server_game):
+ raise exceptions.GameMasterTokenException()
+
+ def cannot_create_more_games(self):
+ """ Return True if server can not accept new games. """
+ return self.max_games and self.count_server_games() >= self.max_games
+
+ def get_map(self, map_name):
+ """ Return map power names for given map name. """
+ return self.available_maps.get(map_name, None)
diff --git a/diplomacy/server/server_game.py b/diplomacy/server/server_game.py
new file mode 100644
index 0000000..6ea349e
--- /dev/null
+++ b/diplomacy/server/server_game.py
@@ -0,0 +1,465 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Server game class. """
+from diplomacy.engine.game import Game
+from diplomacy.engine.message import GLOBAL, Message, OBSERVER, OMNISCIENT, SYSTEM
+from diplomacy.engine.power import Power
+from diplomacy.utils import exceptions, parsing, strings
+from diplomacy.utils.game_phase_data import GamePhaseData
+
+class ServerGame(Game):
+ """ ServerGame class. Properties:
+ - omniscient_usernames (only for server games):
+ set of usernames allowed to be omniscient observers for this game.
+ - moderator_usernames (only for server games):
+ set of usernames allowed to be moderators for this game.
+ - observer (only for server games):
+ special Power object (diplomacy.Power) used to manage observer tokens.
+ - omniscient (only for server games):
+ special Power object (diplomacy.Power) used to manage omniscient tokens.
+ """
+ __slots__ = ['server', 'omniscient_usernames', 'moderator_usernames', 'observer', 'omniscient']
+ model = parsing.update_model(Game.model, {
+ strings.MODERATOR_USERNAMES: parsing.DefaultValueType(parsing.SequenceType(str, sequence_builder=set), ()),
+ strings.OBSERVER: parsing.OptionalValueType(parsing.JsonableClassType(Power)),
+ strings.OMNISCIENT: parsing.OptionalValueType(parsing.JsonableClassType(Power)),
+ strings.OMNISCIENT_USERNAMES: parsing.DefaultValueType(parsing.SequenceType(str, sequence_builder=set), ()),
+ })
+
+ def __init__(self, **kwargs):
+ # Reference to a Server instance.
+ self.server = None # type: diplomacy.Server
+ self.omniscient_usernames = None # type: set
+ self.moderator_usernames = None # type: set
+ self.observer = None # type: Power
+ self.omniscient = None # type: Power
+
+ super(ServerGame, self).__init__(**kwargs)
+ assert self.is_server_game()
+
+ # Initialize special powers.
+ self.observer = self.observer or Power(self, name=strings.OBSERVER_TYPE)
+ self.omniscient = self.omniscient or Power(self, name=strings.OMNISCIENT_TYPE)
+ self.observer.set_controlled(strings.OBSERVER_TYPE)
+ self.omniscient.set_controlled(strings.OBSERVER_TYPE)
+
+ # Server-only methods.
+
+ def get_related_power_names(self, power_name):
+ """ Return list of power names controlled by the controlled of given power name. """
+ related_power_names = []
+ if self.has_power(power_name):
+ related_power_names = [power_name]
+ related_power = self.get_power(power_name)
+ if related_power.is_controlled():
+ related_power_names = self.get_controlled_power_names(related_power.get_controller())
+ return related_power_names
+
+ def filter_phase_data(self, phase_data, role, is_current):
+ """ Return a filtered version of given phase data for given gam role.
+ :param phase_data: GamePhaseData object to filter.
+ :param role: game role to filter phase data for.
+ :param is_current: Boolean. Indicate if given phase data is for a current phase (True), or for a pase phase.
+ :return: a new GamePhaseData object suitable for given game role.
+ :type phase_data: GamePhaseData
+ """
+ if role == strings.OMNISCIENT_TYPE:
+ # Nothing to filter.
+ return phase_data
+ if role == strings.OBSERVER_TYPE:
+ # Filter messages.
+ return GamePhaseData(name=phase_data.name,
+ state=phase_data.state,
+ orders=phase_data.orders,
+ results=phase_data.results,
+ messages=self.filter_messages(phase_data.messages, role))
+ # Filter for power roles.
+ related_power_names = self.get_related_power_names(role)
+ # Filter messages.
+ messages = self.filter_messages(phase_data.messages, related_power_names)
+ # We filter orders only if phase data is for a current phase.
+ if is_current:
+ orders = {power_name: phase_data.orders[power_name]
+ for power_name in related_power_names
+ if power_name in phase_data.orders}
+ else:
+ orders = phase_data.orders
+ # results don't need to be filtered: it should be provided empty for current phase,
+ # and it should be kept for a past phase/
+ return GamePhaseData(name=phase_data.name,
+ state=phase_data.state,
+ orders=orders,
+ messages=messages,
+ results=phase_data.results)
+
+ def game_can_start(self):
+ """ Return True if server game can start. A game can start if all followings conditions are satisfied:
+ - Game has not yet started.
+ - Game can start automatically (no rule START_MASTER).
+ - Game has expected number of controlled powers.
+ :return: a boolean
+ :rtype: bool
+ """
+ return self.is_game_forming and not self.start_master and self.has_expected_controls_count()
+
+ def get_messages(self, game_role, timestamp_from=None, timestamp_to=None):
+ """ Return a filtered dict of current messages for given output game role.
+ See method filter_messages() about parameters.
+ """
+ return self.filter_messages(self.messages, game_role, timestamp_from, timestamp_to)
+
+ def get_message_history(self, game_role):
+ """ Return a filtered dict of whole message history for given game role. """
+ return {str(short_phase): self.filter_messages(messages, game_role)
+ for short_phase, messages in self.message_history.items()}
+
+ def get_user_power_names(self, username):
+ """ Return list of power names controlled by given user name. """
+ return [power.name for power in self.powers.values() if power.is_controlled_by(username)]
+
+ def new_system_message(self, recipient, body):
+ """ Create a system message (immediately dated) to be sent by server and add it to message history.
+ To be used only by server game.
+ :param recipient: recipient description (string). Either:
+ - a power name.
+ - 'GLOBAL' (all game tokens)
+ - 'OBSERVER' (all special tokens [observers and omniscient observers])
+ - 'OMNISCIENT' (all omniscient tokens only)
+ :param body: message body (string).
+ :return: a new GameMessage object.
+ :rtype: Message
+ """
+ assert (recipient in {GLOBAL, OBSERVER, OMNISCIENT}
+ or self.has_power(recipient))
+ message = Message(phase=self.current_short_phase, sender=SYSTEM, recipient=recipient, message=body)
+ # Message timestamp will be generated when adding message.
+ self.add_message(message)
+ return message
+
+ def as_power_game(self, power_name):
+ """ Return a player game data object copy of this game for given power name. """
+ for_username = self.get_power(power_name).get_controller()
+ game = Game.from_dict(self.to_dict())
+ game.controlled_powers = self.get_controlled_power_names(for_username)
+ game.error = []
+ game.message_history = self.get_message_history(power_name)
+ game.messages = self.get_messages(power_name)
+ game.observer_level = self.get_observer_level(for_username)
+ game.phase_abbr = game.current_short_phase
+ related_power_names = self.get_related_power_names(power_name)
+ for power in game.powers.values(): # type: Power
+ power.role = power.name
+ power.tokens.clear()
+ if power.name not in related_power_names:
+ power.vote = strings.NEUTRAL
+ power.orders.clear()
+ game.role = power_name
+ return game
+
+ def as_omniscient_game(self, for_username):
+ """ Return an omniscient game data object copy of this game. """
+ game = Game.from_dict(self.to_dict())
+ game.controlled_powers = self.get_controlled_power_names(for_username)
+ game.message_history = self.get_message_history(strings.OMNISCIENT_TYPE)
+ game.messages = self.get_messages(strings.OMNISCIENT_TYPE)
+ game.observer_level = self.get_observer_level(for_username)
+ game.phase_abbr = game.current_short_phase
+ for power in game.powers.values(): # type: Power
+ power.role = strings.OMNISCIENT_TYPE
+ power.tokens.clear()
+ game.role = strings.OMNISCIENT_TYPE
+ return game
+
+ def as_observer_game(self, for_username):
+ """ Return an observer game data object copy of this game. """
+ game = Game.from_dict(self.to_dict())
+ game.controlled_powers = self.get_controlled_power_names(for_username)
+ game.error = []
+ game.message_history = self.get_message_history(strings.OBSERVER_TYPE)
+ game.messages = self.get_messages(strings.OBSERVER_TYPE)
+ game.observer_level = self.get_observer_level(for_username)
+ game.phase_abbr = game.current_short_phase
+ for power in game.powers.values(): # type: Power
+ power.role = strings.OBSERVER_TYPE
+ power.tokens.clear()
+ power.vote = strings.NEUTRAL
+ game.role = strings.OBSERVER_TYPE
+ return game
+
+ def cast(self, role, for_username):
+ """ Return a copy of this game for given role (either observer role, omniscient role or a power role). """
+ assert strings.role_is_special(role) or self.has_power(role)
+ if role == strings.OBSERVER_TYPE:
+ return self.as_observer_game(for_username)
+ if role == strings.OMNISCIENT_TYPE:
+ return self.as_omniscient_game(for_username)
+ return self.as_power_game(role)
+
+ def is_controlled_by(self, power_name, username):
+ """ (for server game) Return True if given power name is controlled by given username. """
+ return self.get_power(power_name).is_controlled_by(username)
+
+ def get_observer_level(self, username):
+ """ Return the highest observation level allowed for given username.
+ :param username: name of user to get observation right
+ :return: either 'master_type', 'omniscient_type', 'observer_type' or None.
+ """
+ if (self.server and self.server.users.has_admin(username)) or self.is_moderator(username):
+ return strings.MASTER_TYPE
+ if self.is_omniscient(username):
+ return strings.OMNISCIENT_TYPE
+ if not self.no_observations:
+ return strings.OBSERVER_TYPE
+ return None
+
+ def get_reception_addresses(self):
+ """ Generate addresses (couple [power name, token]) of all users implied in this game. """
+ for power in self.powers.values(): # type: Power
+ for token in power.tokens:
+ yield (power.name, token)
+ for token in self.observer.tokens:
+ yield (self.observer.name, token)
+ for token in self.omniscient.tokens:
+ yield (self.omniscient.name, token)
+
+ def get_special_addresses(self):
+ """ Generate addresses (couples [power name, token]) of
+ omniscient observers and simple observers of this game. """
+ for power in (self.omniscient, self.observer):
+ for token in power.tokens:
+ yield (power.name, token)
+
+ def get_observer_addresses(self):
+ """ Generate addresses (couples [power name, token]) of observers of this game. """
+ for token in self.observer.tokens:
+ yield (self.observer.name, token)
+
+ def get_omniscient_addresses(self):
+ """ Generate addresses (couples [power name, token]) of omniscient observers of this game. """
+ for token in self.omniscient.tokens:
+ yield (self.omniscient.name, token)
+
+ def get_special_token_role(self, token):
+ """ Return role name (either OBSERVER_TYPE or OMNISCIENT_TYPE) for given special token. """
+ if self.has_omniscient_token(token):
+ return strings.OMNISCIENT_TYPE
+ if self.has_observer_token(token):
+ return strings.OBSERVER_TYPE
+ raise exceptions.DiplomacyException('Unknown special token in game %s' % self.game_id)
+
+ def get_power_addresses(self, power_name):
+ """ Generate addresses (couples [power name, token]) of user controlling given power name. """
+ for token in self.get_power(power_name).tokens:
+ yield (power_name, token)
+
+ def has_player(self, username):
+ """ (for server game) Return True if given username controls any map power. """
+ return any(power.is_controlled_by(username) for power in self.powers.values())
+
+ def has_token(self, token):
+ """ Return True if game has given token (either observer, omniscient or player). """
+ return self.omniscient.has_token(token) or self.observer.has_token(token) or any(
+ power.has_token(token) for power in self.powers.values())
+
+ def has_observer_token(self, token):
+ """ Return True if game has given observer token. """
+ return self.observer.has_token(token)
+
+ def has_omniscient_token(self, token):
+ """ Return True if game has given omniscient observer token. """
+ return self.omniscient.has_token(token)
+
+ def has_player_token(self, token):
+ """ Return True if game has given player token. """
+ return any(power.has_token(token) for power in self.powers.values())
+
+ def power_has_token(self, power_name, token):
+ """ Return True if given power has given player token.
+ :param power_name: name of power to check.
+ :param token: token to look for.
+ :return: a boolean
+ """
+ return self.get_power(power_name).has_token(token)
+
+ def add_omniscient_token(self, token):
+ """ Set given token as omniscient token. """
+ if self.observer.has_token(token):
+ raise exceptions.ResponseException('Token already registered as observer.')
+ if self.has_player_token(token):
+ raise exceptions.ResponseException('Token already registered as player.')
+ self.omniscient.add_token(token)
+
+ def add_observer_token(self, token):
+ """ Set given token as observer token. """
+ if self.omniscient.has_token(token):
+ raise exceptions.ResponseException('Token already registered as omniscient.')
+ if self.has_player_token(token):
+ raise exceptions.ResponseException('Token already registered as player.')
+ self.observer.add_token(token)
+
+ def transfer_special_token(self, token):
+ """ Move given token from a special case to another (observer -> omniscient or omniscient -> observer). """
+ if self.has_observer_token(token):
+ self.remove_observer_token(token)
+ self.add_omniscient_token(token)
+ elif self.has_omniscient_token(token):
+ self.remove_omniscient_token(token)
+ self.add_observer_token(token)
+
+ def control(self, power_name, username, token):
+ """ Control given power name with given username via given token. """
+ if self.observer.has_token(token):
+ raise exceptions.ResponseException('Token already registered as observer.')
+ if self.omniscient.has_token(token):
+ raise exceptions.ResponseException('Token already registered as omniscient.')
+ power = self.get_power(power_name) # type: Power
+ if power.is_controlled() and not power.is_controlled_by(username):
+ raise exceptions.ResponseException('Power already controlled by another user.')
+ power.set_controlled(username)
+ power.add_token(token)
+
+ def remove_observer_token(self, token):
+ """ Remove given observer token. """
+ self.observer.remove_tokens([token])
+
+ def remove_omniscient_token(self, token):
+ """ Remove given omniscient token. """
+ self.omniscient.remove_tokens([token])
+
+ def remove_special_token(self, special_name, token):
+ """ Remove given token from given special power name (either __OBSERVER__ or __OMNISCIENT__). """
+ if special_name == self.observer.name:
+ self.remove_observer_token(token)
+ else:
+ assert special_name == self.omniscient.name
+ self.remove_omniscient_token(token)
+
+ def remove_all_tokens(self):
+ """ Remove all connected tokens from this game. """
+ self.observer.tokens.clear()
+ self.omniscient.tokens.clear()
+ for power in self.powers.values():
+ power.tokens.clear()
+
+ def remove_token(self, token):
+ """ Remove token from this game. """
+ for power in self.powers.values(): # type: Power
+ power.remove_tokens([token])
+ for special_power in (self.observer, self.omniscient):
+ special_power.remove_tokens([token])
+
+ def is_moderator(self, username):
+ """ Return True if given username is a moderator of this game. """
+ return username in self.moderator_usernames
+
+ def is_omniscient(self, username):
+ """ Return True if given username is allowed to be an omniscient observer of this game. """
+ return username in self.omniscient_usernames
+
+ def promote_moderator(self, username):
+ """ Allow given username to be a moderator of this game. """
+ self.moderator_usernames.add(username)
+
+ def promote_omniscient(self, username):
+ """ Allow given username to be an omniscient observer of this game. """
+ self.omniscient_usernames.add(username)
+
+ def demote_moderator(self, username):
+ """ Remove given username from allowed moderators. """
+ if username in self.moderator_usernames:
+ self.moderator_usernames.remove(username)
+
+ def demote_omniscient(self, username):
+ """ Remove given username from allowed omniscient observers. """
+ if username in self.omniscient_usernames:
+ self.omniscient_usernames.remove(username)
+
+ def filter_usernames(self, filter_function):
+ """ Remove each omniscient username, moderator username and player controller that does not match given
+ filter function (if filter_function(username) is False).
+ :param filter_function: a callable receiving a username and returning a boolean.
+ :return: an integer, either:
+ * 0: nothing changed.
+ * -1: something changed, but no player controllers removed.
+ * 1: something changed, and some player controllers were removed.
+ So, if 1 is returned, there are new dummy powers in the game (some notifications may need to be sent).
+ """
+ n_kicked_players = 0
+ n_kicked_omniscients = len(self.omniscient_usernames)
+ n_kicked_moderators = len(self.moderator_usernames)
+ self.omniscient_usernames = set(username for username in self.omniscient_usernames if filter_function(username))
+ self.moderator_usernames = set(username for username in self.moderator_usernames if filter_function(username))
+ for power in self.powers.values():
+ if power.is_controlled() and not filter_function(power.get_controller()):
+ power.set_controlled(None)
+ n_kicked_players += 1
+ n_kicked_omniscients -= len(self.omniscient_usernames)
+ n_kicked_moderators -= len(self.moderator_usernames)
+ if n_kicked_players:
+ return 1
+ if n_kicked_moderators or n_kicked_omniscients:
+ return -1
+ return 0
+
+ def filter_tokens(self, filter_function):
+ """ Remove from this game any token not matching given filter function (if filter_function(token) is False)."""
+ self.observer.remove_tokens([token for token in self.observer.tokens if not filter_function(token)])
+ self.omniscient.remove_tokens([token for token in self.omniscient.tokens if not filter_function(token)])
+ for power in self.powers.values(): # type: Power
+ power.remove_tokens([token for token in power.tokens if not filter_function(token)])
+
+ def process(self):
+ """ Process current game phase and move forward to next phase.
+ :return: a triple containing:
+ - previous game state (before the processing)
+ - current game state (after processing and game updates)
+ - A dictionary mapping kicked power names to tokens previously associated to these powers.
+ Useful to notify kicked users as they will be not registered in game anymore.
+ If game was not active, triple is (None, None, None).
+ If game kicked powers, only kicked powers dict is returned: (None, None, kicked powers).
+ If game was correctly processed, only states are returned: (prev, curr, None).
+ """
+ if not self.is_game_active:
+ return None, None, None
+ # Kick powers if necessary.
+ all_orderable_locations = self.get_orderable_locations()
+ kicked_powers = {}
+ for power in self.powers.values():
+ if (power.is_controlled()
+ and not power.order_is_set
+ and not self.civil_disorder
+ and all_orderable_locations[power.name]):
+ # This controlled power has not submitted orders, we have not rule CIVIL_DISORDER,
+ # and this power WAS allowed to submit orders for this phase.
+ # We kick such power.
+ kicked_powers[power.name] = set(power.tokens)
+ power.set_controlled(None)
+
+ if kicked_powers:
+ # Some powers were kicked from an active game before processing.
+ # This game must be stopped and cannot be processed. We return info about kicked powers.
+ self.set_status(strings.FORMING)
+ return None, None, kicked_powers
+
+ # Process game and retrieve previous state.
+ previous_phase_data = super(ServerGame, self).process()
+ if self.count_controlled_powers() < self.get_expected_controls_count():
+ # There is no more enough controlled powers, we should stop game.
+ self.set_status(strings.FORMING)
+
+ # Return process results: previous phase data, current phase data, and None for no kicked powers.
+ return previous_phase_data, self.get_phase_data(), None
diff --git a/diplomacy/server/user.py b/diplomacy/server/user.py
new file mode 100644
index 0000000..cfb6ad4
--- /dev/null
+++ b/diplomacy/server/user.py
@@ -0,0 +1,37 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" User object, defined with a username and a hashed password. """
+from diplomacy.utils import strings
+from diplomacy.utils.common import is_valid_password
+from diplomacy.utils.jsonable import Jsonable
+
+class User(Jsonable):
+ """ User class. """
+ __slots__ = ['username', 'password_hash']
+ model = {
+ strings.USERNAME: str,
+ strings.PASSWORD_HASH: str
+ }
+
+ def __init__(self, **kwargs):
+ self.username = None
+ self.password_hash = None
+ super(User, self).__init__(**kwargs)
+
+ def is_valid_password(self, password):
+ """ Return True if given password matches user hashed password. """
+ return is_valid_password(password, self.password_hash)
diff --git a/diplomacy/server/users.py b/diplomacy/server/users.py
new file mode 100644
index 0000000..d1c8ca0
--- /dev/null
+++ b/diplomacy/server/users.py
@@ -0,0 +1,234 @@
+# ==============================================================================
+# 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/>.
+# ==============================================================================
+""" Helper class to manage user accounts and connections on server side.
+
+ A user is associated to 0 or more connected tokens,
+ and each connected token is associated to at most 1 connection handler.
+
+ When a connection handler is closed or invalidated,
+ related tokens are kept and may be further associated to new connection handlers.
+
+ Tokens are effectively deleted when they expire after TOKEN_LIFETIME_SECONDS seconds since last token usage.
+"""
+import logging
+
+from diplomacy.server.user import User
+from diplomacy.utils import common, parsing, strings
+from diplomacy.utils.common import generate_token
+from diplomacy.utils.jsonable import Jsonable
+
+LOGGER = logging.getLogger(__name__)
+
+# Token lifetime in seconds: default 24hours.
+TOKEN_LIFETIME_SECONDS = 24 * 60 * 60
+
+class Users(Jsonable):
+ """ Users class. Properties:
+ - users: dictionary mapping usernames to User object.s
+ - administrators: set of administrator usernames.
+ - token_timestamp: dictionary mapping each token to its creation/last confirmation timestamp.
+ - token_to_username: dictionary mapping each token to its username.
+ - username_to_tokens: dictionary mapping each username to a set of its tokens.
+ - token_to_connection_handler: (memory only) dictionary mapping each token to a connection handler
+ - connection_handler_to_tokens (memory only) dictionary mapping a connection handler to a set of its tokens
+ """
+ __slots__ = ['users', 'administrators', 'token_timestamp', 'token_to_username', 'username_to_tokens',
+ 'token_to_connection_handler', 'connection_handler_to_tokens']
+ model = {
+ strings.USERS: parsing.DefaultValueType(parsing.DictType(str, parsing.JsonableClassType(User)), {}),
+ # {username => User}
+ strings.ADMINISTRATORS: parsing.DefaultValueType(parsing.SequenceType(str, sequence_builder=set), ()),
+ # {usernames}
+ strings.TOKEN_TIMESTAMP: parsing.DefaultValueType(parsing.DictType(str, int), {}),
+ strings.TOKEN_TO_USERNAME: parsing.DefaultValueType(parsing.DictType(str, str), {}),
+ strings.USERNAME_TO_TOKENS: parsing.DefaultValueType(parsing.DictType(str, parsing.SequenceType(str, set)), {}),
+ }
+
+ def __init__(self, **kwargs):
+ self.users = {}
+ self.administrators = set()
+ self.token_timestamp = {}
+ self.token_to_username = {}
+ self.username_to_tokens = {}
+ self.token_to_connection_handler = {}
+ self.connection_handler_to_tokens = {}
+ super(Users, self).__init__(**kwargs)
+
+ def has_username(self, username):
+ """ Return True if users have given username. """
+ return username in self.users
+
+ def has_user(self, username, password):
+ """ Return True if users have given username with given password. """
+ return username in self.users and self.users[username].is_valid_password(password)
+
+ def has_admin(self, username):
+ """ Return True if given username is an administrator. """
+ return username in self.administrators
+
+ def has_token(self, token):
+ """ Return True if users have given token. """
+ return token in self.token_to_username
+
+ def token_is_alive(self, token):
+ """ Return True if given token is known and still alive.
+ A token is alive if elapsed time since last token usage does not exceed token lifetime
+ (TOKEN_LIFETIME_SECONDS).
+ """
+ if self.has_token(token):
+ current_time = common.timestamp_microseconds()
+ elapsed_time_seconds = (current_time - self.token_timestamp[token]) / 1000000
+ return elapsed_time_seconds <= TOKEN_LIFETIME_SECONDS
+ return False
+
+ def relaunch_token(self, token):
+ """ Update timestamp of given token with current timestamp. """
+ if self.has_token(token):
+ self.token_timestamp[token] = common.timestamp_microseconds()
+
+ def token_is_admin(self, token):
+ """ Return True if given token is associated to an administrator. """
+ return self.has_token(token) and self.has_admin(self.get_name(token))
+
+ def count_connections(self):
+ """ Return number of registered connection handlers. """
+ return len(self.connection_handler_to_tokens)
+
+ def get_tokens(self, username):
+ """ Return a sequence of tokens associated to given username. """
+ return self.username_to_tokens[username].copy()
+
+ def get_name(self, token):
+ """ Return username of given token. """
+ return self.token_to_username[token]
+
+ def get_connection_handler(self, token):
+ """ Return connection handler associated to given token, or None if no handler currently associated. """
+ return self.token_to_connection_handler.get(token, None)
+
+ def add_admin(self, username):
+ """ Set given username as administrator. Related user must exists in this Users object. """
+ assert username in self.users
+ self.administrators.add(username)
+
+ def remove_admin(self, username):
+ """ Remove given username from administrators. """
+ if username in self.administrators:
+ self.administrators.remove(username)
+
+ def create_token(self):
+ """ Return a new token guaranteed to not exist in this Users object. """
+ token = generate_token()
+ while self.has_token(token):
+ token = generate_token()
+ return token
+
+ def add_user(self, username, password_hash):
+ """ Add a new user with given username and hashed password.
+ See diplomacy.utils.common.hash_password() for hashing purposes.
+ """
+ user = User(username=username, password_hash=password_hash)
+ self.users[username] = user
+ return user
+
+ def remove_user(self, username):
+ """ Remove user related to given username. """
+ user = self.users.pop(username)
+ self.remove_admin(username)
+ for token in self.username_to_tokens.pop(user.username):
+ self.token_timestamp.pop(token)
+ self.token_to_username.pop(token)
+ connection_handler = self.token_to_connection_handler.pop(token, None)
+ if connection_handler:
+ self.connection_handler_to_tokens[connection_handler].remove(token)
+ if not self.connection_handler_to_tokens[connection_handler]:
+ self.connection_handler_to_tokens.pop(connection_handler)
+
+ def remove_connection(self, connection_handler, remove_tokens=True):
+ """ Remove given connection handler.
+ Return tokens associated to this connection handler,
+ or None if connection handler is unknown.
+ :param connection_handler: connection handler to remove.
+ :param remove_tokens: if True, tokens related to connection handler are deleted.
+ :return: either None or a set of tokens.
+ """
+ if connection_handler in self.connection_handler_to_tokens:
+ tokens = self.connection_handler_to_tokens.pop(connection_handler)
+ for token in tokens:
+ self.token_to_connection_handler.pop(token)
+ if remove_tokens:
+ self.token_timestamp.pop(token)
+ user = self.users[self.token_to_username.pop(token)]
+ self.username_to_tokens[user.username].remove(token)
+ if not self.username_to_tokens[user.username]:
+ self.username_to_tokens.pop(user.username)
+ return tokens
+ return None
+
+ def connect_user(self, username, connection_handler):
+ """ Connect given username to given connection handler with a new generated token, and return
+ token generated.
+ :param username: username to connect
+ :param connection_handler: connection handler to link to user
+ :return: a new token generated for connexion
+ """
+ token = self.create_token()
+ user = self.users[username]
+ if connection_handler not in self.connection_handler_to_tokens:
+ self.connection_handler_to_tokens[connection_handler] = set()
+ if user.username not in self.username_to_tokens:
+ self.username_to_tokens[user.username] = set()
+ self.token_to_username[token] = user.username
+ self.token_to_connection_handler[token] = connection_handler
+ self.username_to_tokens[user.username].add(token)
+ self.connection_handler_to_tokens[connection_handler].add(token)
+ self.token_timestamp[token] = common.timestamp_microseconds()
+ return token
+
+ def attach_connection_handler(self, token, connection_handler):
+ """ Associate given token with given connection handler if token is known.
+ If there is a previous connection handler associated to given token, it should be the same
+ as given connection handler, otherwise an error is raised (meaning previous connection handler
+ was not correctly disconnected from given token. It should be a programming error).
+ :param token: token
+ :param connection_handler: connection handler
+ """
+ if self.has_token(token):
+ previous_connection = self.get_connection_handler(token)
+ if previous_connection:
+ assert previous_connection == connection_handler, \
+ "A new connection handler cannot be attached to a token always connected to another handler."
+ else:
+ LOGGER.warning('Attaching a new connection handler to a token.')
+ if connection_handler not in self.connection_handler_to_tokens:
+ self.connection_handler_to_tokens[connection_handler] = set()
+ self.token_to_connection_handler[token] = connection_handler
+ self.connection_handler_to_tokens[connection_handler].add(token)
+ self.token_timestamp[token] = common.timestamp_microseconds()
+
+ def disconnect_token(self, token):
+ """ Remove given token. """
+ self.token_timestamp.pop(token)
+ user = self.users[self.token_to_username.pop(token)]
+ self.username_to_tokens[user.username].remove(token)
+ if not self.username_to_tokens[user.username]:
+ self.username_to_tokens.pop(user.username)
+ connection_handler = self.token_to_connection_handler.pop(token, None)
+ if connection_handler:
+ self.connection_handler_to_tokens[connection_handler].remove(token)
+ if not self.connection_handler_to_tokens[connection_handler]:
+ self.connection_handler_to_tokens.pop(connection_handler)