From 6187faf20384b0c5a4966343b2d4ca47f8b11e45 Mon Sep 17 00:00:00 2001 From: Philip Paquette Date: Wed, 26 Sep 2018 07:48:55 -0400 Subject: Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License --- diplomacy/server/__init__.py | 16 + diplomacy/server/connection_handler.py | 111 +++ diplomacy/server/notifier.py | 333 ++++++++ diplomacy/server/request_manager_utils.py | 267 +++++++ diplomacy/server/request_managers.py | 1181 +++++++++++++++++++++++++++++ diplomacy/server/run.py | 35 + diplomacy/server/scheduler.py | 265 +++++++ diplomacy/server/server.py | 797 +++++++++++++++++++ diplomacy/server/server_game.py | 465 ++++++++++++ diplomacy/server/user.py | 37 + diplomacy/server/users.py | 234 ++++++ 11 files changed, 3741 insertions(+) create mode 100644 diplomacy/server/__init__.py create mode 100644 diplomacy/server/connection_handler.py create mode 100644 diplomacy/server/notifier.py create mode 100644 diplomacy/server/request_manager_utils.py create mode 100644 diplomacy/server/request_managers.py create mode 100755 diplomacy/server/run.py create mode 100644 diplomacy/server/scheduler.py create mode 100644 diplomacy/server/server.py create mode 100644 diplomacy/server/server_game.py create mode 100644 diplomacy/server/user.py create mode 100644 diplomacy/server/users.py (limited to 'diplomacy/server') 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 . +# ============================================================================== 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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= # 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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 `/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) # /data + ensure_path(self.games_path) # /data/games + server_data_filename = self._get_server_data_filename() # /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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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 . +# ============================================================================== +""" 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) -- cgit v1.2.3