diff options
author | Philip Paquette <pcpaquette@gmail.com> | 2018-09-26 07:48:55 -0400 |
---|---|---|
committer | Philip Paquette <pcpaquette@gmail.com> | 2019-04-18 11:14:24 -0400 |
commit | 6187faf20384b0c5a4966343b2d4ca47f8b11e45 (patch) | |
tree | 151ccd21aea20180432c13fe4b58240d3d9e98b6 /diplomacy/server/server.py | |
parent | 96b7e2c03ed98705754f13ae8efa808b948ee3a8 (diff) |
Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License
Diffstat (limited to 'diplomacy/server/server.py')
-rw-r--r-- | diplomacy/server/server.py | 797 |
1 files changed, 797 insertions, 0 deletions
diff --git a/diplomacy/server/server.py b/diplomacy/server/server.py new file mode 100644 index 0000000..5763991 --- /dev/null +++ b/diplomacy/server/server.py @@ -0,0 +1,797 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see <https://www.gnu.org/licenses/>. +# ============================================================================== +""" Concret standalone server object. Manages and save server data and games on disk, send notifications, + receives requests and send responses. + + Example: + >>> from diplomacy import Server + >>> Server().start(port=1234) # If port is not given, a random port will be selected. + + You can interrupt server by sending a keyboard interrupt signal (Ctrl+C). + >>> from diplomacy import Server + >>> try: + >>> Server().start() + >>> except KeyboardInterrupt: + >>> print('Server interrupted.') + + You can also configure some server attributes when instantiating it: + >>> from diplomacy import Server + >>> server = Server(backup_delay_seconds=5) + >>> server.start() + + These are public configurable server attributes. They are saved on disk at each server backup: + - allow_user_registrations: (bool) indicate if server accepts users registrations + (default True) + - backup_delay_seconds: (int) number of seconds to wait between two consecutive full server backup on disk + (default 10 minutes) + - ping_seconds: (int) ping period used by server to check is connected sockets are alive. + - max_games: (int) maximum number of games server accepts to create. If there are at least such number of games on + server, server will not accept further game creation requests. If 0, no limit. + (default 0) + - remove_canceled_games: (bool) indicate if games must be deleted from server database when they are canceled + (default False) + +""" +import atexit +import logging +import os +import signal +import uuid + +import tornado +import tornado.web +from tornado import gen +from tornado.ioloop import IOLoop +from tornado.queues import Queue +from tornado.websocket import WebSocketClosedError + +import ujson as json + +import diplomacy.settings +from diplomacy.communication import notifications +from diplomacy.server.connection_handler import ConnectionHandler +from diplomacy.server.notifier import Notifier +from diplomacy.server.scheduler import Scheduler +from diplomacy.server.server_game import ServerGame +from diplomacy.server.users import Users +from diplomacy.engine.map import Map +from diplomacy.utils import common, exceptions, strings, constants + +LOGGER = logging.getLogger(__name__) + +def get_absolute_path(directory=None): + """ Return absolute path of given directory. + If given directory is None, return absolute path of current directory. + """ + return os.path.abspath(directory or os.getcwd()) + +def get_backup_filename(filename): + """ Return a backup filename from given filename (given filename with a special suffix). """ + return '%s.backup' % filename + +def save_json_on_disk(filename, json_dict): + """ Save given JSON dictionary into given filename and back-up previous file version if exists. """ + if os.path.exists(filename): + os.rename(filename, get_backup_filename(filename)) + with open(filename, 'w') as file: + json.dump(json_dict, file) + +def load_json_from_disk(filename): + """ Return a JSON dictionary loaded from given filename. + If JSON parsing fail for given filename, try to load JSON dictionary for a backup file (if present) + and rename backup file to given filename (backup file becomes current file versions). + :rtype: dict + """ + try: + with open(filename, 'rb') as file: + json_dict = json.load(file) + except ValueError as exception: + backup_filename = get_backup_filename(filename) + if not os.path.isfile(backup_filename): + raise exception + with open(backup_filename, 'rb') as backup_file: + json_dict = json.load(backup_file) + os.rename(backup_filename, filename) + return json_dict + +def ensure_path(folder_path): + """ Make sure given folder path exists and return given path. + Raises an exception if path does not exists, cannot be created or is not a folder. + """ + if not os.path.exists(folder_path): + LOGGER.info('Creating folder %s', folder_path) + os.makedirs(folder_path, exist_ok=True) + if not os.path.exists(folder_path) or not os.path.isdir(folder_path): + raise exceptions.FolderException(folder_path) + return folder_path + +class InterruptionHandler(): + """ Helper class used to save server when a system interruption signal is sent (e.g. KeyboardInterrupt). """ + __slots__ = ['server', 'previous_handler'] + + def __init__(self, server): + """ Initializer the handler. + :param server: server to save + """ + self.server = server # type: Server + self.previous_handler = signal.getsignal(signal.SIGINT) + + def handler(self, signum, frame): + """ Handler function. + :param signum: system signal received + :param frame: frame received + """ + if signum == signal.SIGINT: + self.server.backup_now(force=True) + if self.previous_handler: + self.previous_handler(signum, frame) + +class _ServerBackend(): + """ Class representing tornado objects used to run a server. Properties: + - port: (integer) port where server runs. + - application: tornado web Application object. + - http_server: tornado HTTP server object running server code. + - io_loop: tornado IO loop where server runs. + """ + #pylint: disable=too-few-public-methods + __slots__ = ['port', 'application', 'http_server', 'io_loop'] + + + def __init__(self): + """ Initialize server backend. """ + self.port = None + self.application = None + self.http_server = None + self.io_loop = None + +class Server(): + """ Server class. """ + __slots__ = ['data_path', 'games_path', 'available_maps', 'maps_mtime', 'notifications', + 'games_scheduler', 'allow_registrations', 'max_games', 'remove_canceled_games', 'users', 'games', + 'backup_server', 'backup_games', 'backup_delay_seconds', 'ping_seconds', + 'interruption_handler', 'backend', 'games_with_dummy_powers', 'dispatched_dummy_powers'] + + # Servers cache. + __cache__ = {} # {absolute path of working folder => Server} + + def __new__(cls, server_dir=None, **kwargs): + #pylint: disable=unused-argument + server_dir = get_absolute_path(server_dir) + if server_dir in cls.__cache__: + server = cls.__cache__[server_dir] + else: + server = object.__new__(cls) + return server + + def __init__(self, server_dir=None, **kwargs): + """ Initialize the server. + :param server_dir: path of folder in (from) which server data will be saved (loaded). + If None, working directory (where script is executed) will be used. + :param kwargs: (optional) values for some public configurable server attributes. + Given values will overwrite values saved on disk. + Server data is stored in folder `<working directory>/data`. + """ + + # File paths and attributes related to database. + server_dir = get_absolute_path(server_dir) + if server_dir in self.__class__.__cache__: + return + if not os.path.exists(server_dir) or not os.path.isdir(server_dir): + raise exceptions.ServerDirException(server_dir) + self.data_path = os.path.join(server_dir, 'data') + self.games_path = os.path.join(self.data_path, 'games') + + # Data in memory (not stored on disk). + self.notifications = Queue() + self.games_scheduler = Scheduler(1, self._process_game) + self.backup_server = None + self.backup_games = {} + self.interruption_handler = InterruptionHandler(self) + # Backend objects used to run server. If None, server is not yet started. + # Initialized when you call Server.start() (see method below). + self.backend = None # type: _ServerBackend + + # Database (stored on disk). + self.allow_registrations = True + self.max_games = 0 + self.remove_canceled_games = False + self.backup_delay_seconds = constants.DEFAULT_BACKUP_DELAY_SECONDS + self.ping_seconds = constants.DEFAULT_PING_SECONDS + self.users = None # type: Users # Users and administrators usernames. + self.available_maps = {} # type: dict{str, set()} # {"map_name" => set("map_power")} + self.maps_mtime = 0 # Latest maps modification date (used to manage maps cache in server object). + + # Server games loaded on memory (stored on disk). + # Saved separately (each game in one JSON file). + # Each game also stores tokens connected (player tokens, observer tokens, omniscient tokens). + self.games = {} # type: dict{str, ServerGame} + + # Dictionary mapping game IDs to dummy power names. + self.games_with_dummy_powers = {} # type: dict{str, set} + + # Dictionary mapping a game ID present in games_with_dummy_powers, to + # a couple of associated bot token and time when bot token was associated to this game ID. + # If there is no bot token associated, couple is (None, None). + self.dispatched_dummy_powers = {} # type: dict{str, tuple} + + # Load data on memory. + self._load() + + # If necessary, updated server configurable attributes from kwargs. + self.allow_registrations = bool(kwargs.pop(strings.ALLOW_REGISTRATIONS, self.allow_registrations)) + self.max_games = int(kwargs.pop(strings.MAX_GAMES, self.max_games)) + self.remove_canceled_games = bool(kwargs.pop(strings.REMOVE_CANCELED_GAMES, self.remove_canceled_games)) + self.backup_delay_seconds = int(kwargs.pop(strings.BACKUP_DELAY_SECONDS, self.backup_delay_seconds)) + self.ping_seconds = int(kwargs.pop(strings.PING_SECONDS, self.ping_seconds)) + assert not kwargs + LOGGER.debug('Ping : %s', self.ping_seconds) + LOGGER.debug('Backup delay: %s', self.backup_delay_seconds) + + # Add server on servers cache. + self.__class__.__cache__[server_dir] = self + + @property + def port(self): + """ Property: return port where this server currently runs, or None if server is not yet started. """ + return self.backend.port if self.backend else None + + def _load_available_maps(self): + """ Load a dictionary (self.available_maps) mapping every map name to a dict of map info. + for all maps available in diplomacy package. + """ + diplomacy_map_dir = os.path.join(diplomacy.settings.PACKAGE_DIR, strings.MAPS) + new_maps_mtime = self.maps_mtime + for filename in os.listdir(diplomacy_map_dir): + if filename.endswith('.map'): + map_filename = os.path.join(diplomacy_map_dir, filename) + map_mtime = os.path.getmtime(map_filename) + map_name = filename[:-4] + if map_name not in self.available_maps or map_mtime > self.maps_mtime: + # Either it's a new map file or map file was modified. + available_map = Map(map_name) + self.available_maps[map_name] = { + 'powers': set(available_map.powers), + 'supply_centers': set(available_map.scs), + 'loc_type': available_map.loc_type.copy(), + 'loc_abut': available_map.loc_abut.copy(), + 'aliases': available_map.aliases.copy() + } + new_maps_mtime = max(new_maps_mtime, map_mtime) + self.maps_mtime = new_maps_mtime + + def _get_server_data_filename(self): + """ Return path to server data file name (server.json, making sure that data folder exists. + Raises an exception if data folder does not exists and cannot be created. + """ + return os.path.join(ensure_path(self.data_path), 'server.json') + + def _load(self): + """ Load database from disk. """ + LOGGER.info("Loading database.") + ensure_path(self.data_path) # <server dir>/data + ensure_path(self.games_path) # <server dir>/data/games + server_data_filename = self._get_server_data_filename() # <server dir>/data/server.json + if os.path.exists(server_data_filename): + LOGGER.info("Loading server.json.") + server_info = load_json_from_disk(server_data_filename) + self.allow_registrations = server_info[strings.ALLOW_REGISTRATIONS] + self.backup_delay_seconds = server_info[strings.BACKUP_DELAY_SECONDS] + self.ping_seconds = server_info[strings.PING_SECONDS] + self.max_games = server_info[strings.MAX_GAMES] + self.remove_canceled_games = server_info[strings.REMOVE_CANCELED_GAMES] + self.users = Users.from_dict(server_info[strings.USERS]) + self.available_maps = server_info[strings.AVAILABLE_MAPS] + self.maps_mtime = server_info[strings.MAPS_MTIME] + # games and map are loaded from disk. + else: + LOGGER.info("Creating server.json.") + self.users = Users() + self.backup_now(force=True) + # Add default accounts. + for (username, password) in ( + ('admin', 'password'), + (constants.PRIVATE_BOT_USERNAME, constants.PRIVATE_BOT_PASSWORD) + ): + if not self.users.has_username(username): + self.users.add_user(username, common.hash_password(password)) + # Set default admin account. + self.users.add_admin('admin') + + self._load_available_maps() + + LOGGER.info('Server loaded.') + + def _backup_server_data_now(self, force=False): + """ Save latest backed-up version of server data on disk. This does not save games. + :param force: if True, force to save current server data even if it was not modified recently. + """ + if force: + self.save_data() + if self.backup_server: + save_json_on_disk(self._get_server_data_filename(), self.backup_server) + self.backup_server = None + LOGGER.info("Saved server.json.") + + def _backup_games_now(self, force=False): + """ Save latest backed-up versions of loaded games on disk. + :param force: if True, force to save all games currently loaded in memory + even if they were not modified recently. + """ + ensure_path(self.games_path) + if force: + for server_game in self.games.values(): + self.save_game(server_game) + for game_id, game_dict in self.backup_games.items(): + game_path = os.path.join(self.games_path, '%s.json' % game_id) + save_json_on_disk(game_path, game_dict) + LOGGER.info('Game data saved: %s', game_id) + self.backup_games.clear() + + def backup_now(self, force=False): + """ Save backup of server data and loaded games immediately. + :param force: if True, force to save server data and all loaded games even if there are no recent changes. + """ + self._backup_server_data_now(force=force) + self._backup_games_now(force=force) + + @gen.coroutine + def _process_game(self, server_game): + """ Process given game and send relevant notifications. + :param server_game: server game to process + :return: A boolean indicating if we must stop game. + :type server_game: ServerGame + """ + LOGGER.debug('Processing game %s (status %s).', server_game.game_id, server_game.status) + previous_phase_data, current_phase_data, kicked_powers = server_game.process() + self.save_game(server_game) + + if previous_phase_data is None and kicked_powers is None: + # Game must be unscheduled immediately. + return True + + notifier = Notifier(self) + # In any case, we notify game tokens about changes in power controllers. + yield notifier.notify_game_powers_controllers(server_game) + + if kicked_powers: + # Game was not processed because of kicked powers. + # We notify those kicked powers and game must be unscheduled immediately. + kicked_addresses = [(power_name, token) + for (power_name, tokens) in kicked_powers.items() + for token in tokens] + # Notify kicked players. + notifier.notify_game_addresses( + server_game.game_id, + kicked_addresses, + notifications.PowersControllers, + powers=server_game.get_controllers(), + timestamps=server_game.get_controllers_timestamps() + ) + return True + + # Game was processed normally. + # Send game updates to powers, observers and omniscient observers. + yield notifier.notify_game_processed(server_game, previous_phase_data, current_phase_data) + return not server_game.is_game_active + + @gen.coroutine + def _task_save_database(self): + """ IO loop callable: save database and loaded games periodically. + Data to save are checked every BACKUP_DELAY_SECONDS seconds. + """ + LOGGER.info('Waiting for save events.') + while True: + yield gen.sleep(self.backup_delay_seconds) + self.backup_now() + + @gen.coroutine + def _task_send_notifications(self): + """ IO loop callback: consume notifications and send it. """ + LOGGER.info('Waiting for notifications to send.') + while True: + connection_handler, notification = yield self.notifications.get() + try: + yield connection_handler.write_message(notification.json()) + except WebSocketClosedError: + LOGGER.error('Websocket was closed while sending a notification.') + finally: + self.notifications.task_done() + + def set_tasks(self, io_loop: IOLoop): + """ Set server callbacks on given IO loop. Must be called once per server before starting IO loop. """ + io_loop.add_callback(self._task_save_database) + io_loop.add_callback(self._task_send_notifications) + # These both coroutines are used to manage games. + io_loop.add_callback(self.games_scheduler.process_tasks) + io_loop.add_callback(self.games_scheduler.schedule) + # Set callback on KeyboardInterrupt. + signal.signal(signal.SIGINT, self.interruption_handler.handler) + atexit.register(self.backup_now) + + def start(self, port=None, io_loop=None): + """ Start server if not yet started. Raise an exception if server is already started. + :param port: (optional) port where server must run. If not provided, try to start on a random + selected port. Use property `port` to get current server port. + :param io_loop: (optional) tornado IO lopp where server must run. If not provided, get + default IO loop instance (tornado.ioloop.IOLoop.instance()). + """ + if self.backend is not None: + raise exceptions.DiplomacyException('Server is already running on port %s.' % self.backend.port) + if port is None: + port = 8432 + if io_loop is None: + io_loop = tornado.ioloop.IOLoop.instance() + handlers = [ + tornado.web.url(r"/", ConnectionHandler, {'server': self}), + ] + settings = { + 'cookie_secret': common.generate_token(), + 'xsrf_cookies': True, + 'websocket_ping_interval': self.ping_seconds, + 'websocket_ping_timeout': 2 * self.ping_seconds, + 'websocket_max_message_size': 64 * 1024 * 1024 + } + self.backend = _ServerBackend() + self.backend.application = tornado.web.Application(handlers, **settings) + self.backend.http_server = self.backend.application.listen(port) + self.backend.io_loop = io_loop + self.backend.port = port + self.set_tasks(io_loop) + LOGGER.info('Running on port %d', self.backend.port) + io_loop.start() + + def get_game_indices(self): + """ Iterate over all game indices in server database. + Convenient method to iterate over all server games (by calling load_game() on each game index). + """ + for game_id in self.games: + yield game_id + if os.path.isdir(self.games_path): + for filename in os.listdir(self.games_path): + if filename.endswith('.json'): + game_id = filename[:-5] + if game_id not in self.games: + yield game_id + + def count_server_games(self): + """ Return number of server games in server database. """ + count = 0 + if os.path.isdir(self.games_path): + for filename in os.listdir(self.games_path): + if filename.endswith('.json'): + count += 1 + return count + + def save_data(self): + """ Update on-memory backup of server data. """ + self.backup_server = { + strings.ALLOW_REGISTRATIONS: self.allow_registrations, + strings.BACKUP_DELAY_SECONDS: self.backup_delay_seconds, + strings.PING_SECONDS: self.ping_seconds, + strings.MAX_GAMES: self.max_games, + strings.REMOVE_CANCELED_GAMES: self.remove_canceled_games, + strings.USERS: self.users.to_dict(), + strings.AVAILABLE_MAPS: self.available_maps, + strings.MAPS_MTIME: self.maps_mtime, + } + + def save_game(self, server_game): + """ Update on-memory version of given server game. + :param server_game: server game + :type server_game: ServerGame + """ + self.backup_games[server_game.game_id] = server_game.to_dict() + # Check dummy powers for a game every time we have to save it. + self.register_dummy_power_names(server_game) + + def register_dummy_power_names(self, server_game): + """ Update internal registry of dummy power names waiting for orders + for given server games. + :param server_game: server game to check + :type server_game: ServerGame + """ + updated = False + if server_game.is_game_active or server_game.is_game_paused: + dummy_power_names = [] + for power_name in server_game.get_dummy_power_names(): + power = server_game.get_power(power_name) + if power.is_dummy() and not power.is_eliminated() and not power.does_not_wait(): + # This dummy power needs either orders, or wait flag to be set to False. + dummy_power_names.append(power_name) + if dummy_power_names: + # Update registry of dummy powers. + self.games_with_dummy_powers[server_game.game_id] = dummy_power_names + # Every time we update registry of dummy powers, + # then we also update bot time in registry of dummy powers associated to bot tokens. + bot_token, _ = self.dispatched_dummy_powers.get(server_game.game_id, (None, None)) + self.dispatched_dummy_powers[server_game.game_id] = (bot_token, common.timestamp_microseconds()) + updated = True + if not updated: + # Registry not updated for this game, meaning that there is no + # dummy powers waiting for orders or 'no wait' for this game. + self.games_with_dummy_powers.pop(server_game.game_id, None) + # We remove game from registry of dummy powers associated to bot tokens only if game is terminated. + # Otherwise, game will remain associated to a previous bot token, until bot failed to order powers. + if server_game.is_game_completed or server_game.is_game_canceled: + self.dispatched_dummy_powers.pop(server_game.game_id, None) + + def get_dummy_waiting_power_names(self, buffer_size, bot_token): + """ Return names of dummy powers waiting for orders for current loaded games. + This query is allowed only for bot tokens. + :param buffer_size: maximum number of powers queried. + :param bot_token: bot token + :return: a dictionary mapping game IDs to lists of power names. + """ + if self.users.get_name(bot_token) != constants.PRIVATE_BOT_USERNAME: + raise exceptions.ResponseException('Invalid bot token %s' % bot_token) + selected_size = 0 + selected_games = {} + for game_id in sorted(list(self.games_with_dummy_powers.keys())): + registered_token, registered_time = self.dispatched_dummy_powers[game_id] + if registered_token is not None: + time_elapsed_seconds = (common.timestamp_microseconds() - registered_time) / 1000000 + if time_elapsed_seconds > constants.PRIVATE_BOT_TIMEOUT_SECONDS or registered_token == bot_token: + # This game still has dummy powers but time allocated to previous bot token is over. + # Forget previous bot token. + registered_token = None + if registered_token is None: + # This game is not associated to any bot token. + # Let current bot token handle it if buffer size is not reached. + dummy_power_names = self.games_with_dummy_powers[game_id] + nb_powers = len(dummy_power_names) + if selected_size + nb_powers > buffer_size: + # Buffer size would be exceeded. We stop to collect games now. + break + # Otherwise we collect this game. + selected_games[game_id] = dummy_power_names + selected_size += nb_powers + self.dispatched_dummy_powers[game_id] = (bot_token, common.timestamp_microseconds()) + return selected_games + + def has_game_id(self, game_id): + """ Return True if server database contains such game ID. """ + if game_id in self.games: + return True + expected_game_path = os.path.join(self.games_path, '%s.json' % game_id) + return os.path.exists(expected_game_path) and os.path.isfile(expected_game_path) + + def load_game(self, game_id): + """ Return a game matching given game ID from server database. + Raise an exception if such game does not exists. + If such game is already stored in server object, return it. + Else, load it from disk but ** does not store it in server object **. + To load and immediately store a game object in server object, please use method get_game(). + Method load_game() is convenient where you want to iterate over all games in server database + without taking memory space. + :param game_id: ID of game to load. + :return: a ServerGame object + :rtype: ServerGame + """ + if game_id in self.games: + return self.games[game_id] + game_filename = os.path.join(ensure_path(self.games_path), '%s.json' % game_id) + if not os.path.isfile(game_filename): + raise exceptions.GameIdException() + try: + server_game = ServerGame.from_dict(load_json_from_disk(game_filename)) # type: ServerGame + server_game.server = self + server_game.filter_usernames(self.users.has_username) + server_game.filter_tokens(self.users.has_token) + return server_game + except ValueError as exc: + # Error occurred while parsing JSON file: bad JSON file. + try: + os.remove(game_filename) + finally: + # This should be an internal server error. + raise exc + + def add_new_game(self, server_game): + """ Add a new game data on server in memory. This does not save the game on disk. + :type server_game: ServerGame + """ + self.games[server_game.game_id] = server_game + + def get_game(self, game_id): + """ Return game saved on server matching given game ID. Raise an exception if game ID not found. + Return game if already loaded on memory, else load it from disk, store it and return it. + :param game_id: ID of game to load. + :return: a ServerGame object. + :rtype: ServerGame + """ + server_game = self.load_game(game_id) + if game_id not in self.games: + LOGGER.debug('Game loaded: %s', game_id) + # Check dummy powers for this game as soon as it's loaded from disk. + self.register_dummy_power_names(server_game) + self.games[server_game.game_id] = server_game + # We have just loaded game from disk. Start it if necessary. + if not server_game.start_master and server_game.has_expected_controls_count(): + # We may have to start game. + stop = False + if server_game.does_not_wait(): + # We must process game. + process_result = server_game.process() + stop = process_result is None or process_result[-1] + self.save_game(server_game) + if not stop: + LOGGER.debug('Game loaded and scheduled: %s', server_game.game_id) + self.schedule_game(server_game) + return server_game + + def delete_game(self, server_game): + """ Delete given game from server (both from memory and disk). + :param server_game: game to delete + :type server_game: ServerGame + """ + if not (server_game.is_game_canceled or server_game.is_game_completed): + server_game.set_status(strings.CANCELED) + game_filename = os.path.join(self.games_path, '%s.json' % server_game.game_id) + if os.path.isfile(game_filename): + os.remove(game_filename) + self.games.pop(server_game.game_id, None) + self.games_with_dummy_powers.pop(server_game.game_id, None) + self.dispatched_dummy_powers.pop(server_game.game_id, None) + + @gen.coroutine + def schedule_game(self, server_game): + """ Add a game to scheduler only if game has a deadline and is not already scheduled. + To add games without deadline, use force_game_processing(). + :param server_game: game + :type server_game: ServerGame + """ + if not (yield self.games_scheduler.has_data(server_game)) and server_game.deadline: + yield self.games_scheduler.add_data(server_game, server_game.deadline) + + @gen.coroutine + def unschedule_game(self, server_game): + """ Remove a game from scheduler. + :param server_game: game + :type server_game: ServerGame + """ + if (yield self.games_scheduler.has_data(server_game)): + yield self.games_scheduler.remove_data(server_game) + + @gen.coroutine + def force_game_processing(self, server_game): + """ Add a game to scheduler to be processed as soon as possible. + Use this method instead of schedule_game() to explicitly add games with null deadline. + :param server_game: game + :type server_game: ServerGame + """ + yield self.games_scheduler.no_wait(server_game, server_game.deadline, lambda g: g.does_not_wait()) + + def start_game(self, server_game): + """ Start given server game. + :param server_game: server game + :type server_game: ServerGame + """ + server_game.set_status(strings.ACTIVE) + self.schedule_game(server_game) + Notifier(self).notify_game_status(server_game) + + def stop_game_if_needed(self, server_game): + """ Stop game if it has not required number of controlled powers. Notify game if status changed. + :param server_game: game to check + :param server_game: game + :type server_game: ServerGame + """ + if server_game.is_game_active and ( + server_game.count_controlled_powers() < server_game.get_expected_controls_count()): + server_game.set_status(strings.FORMING) + self.unschedule_game(server_game) + Notifier(self).notify_game_status(server_game) + + def user_is_master(self, username, server_game): + """ Return True if given username is a game master for given game data. + :param username: username + :param server_game: game data + :return: a boolean + :type server_game: ServerGame + :rtype: bool + """ + return self.users.has_admin(username) or server_game.is_moderator(username) + + def user_is_omniscient(self, username, server_game): + """ Return True if given username is omniscient for given game data. + :param username: username + :param server_game: game data + :return: a boolean + :type server_game: ServerGame + :rtype: bool + """ + return self.users.has_admin(username) or server_game.is_moderator(username) or server_game.is_omniscient( + username) + + def token_is_master(self, token, server_game): + """ Return True if given token is a master token for given game data. + :param token: token + :param server_game: game data + :return: a boolean + :type server_game: ServerGame + :rtype: bool + """ + return self.users.has_token(token) and self.user_is_master(self.users.get_name(token), server_game) + + def token_is_omniscient(self, token, server_game): + """ Return True if given token is omniscient for given game data. + :param token: token + :param server_game: game data + :return: a boolean + :type server_game: ServerGame + :rtype: bool + """ + return self.users.has_token(token) and self.user_is_omniscient(self.users.get_name(token), server_game) + + def create_game_id(self): + """ Create and return a game ID not already used by a game in server database. """ + game_id = str(uuid.uuid4()) + while self.has_game_id(game_id): + game_id = str(uuid.uuid4()) + return game_id + + def remove_token(self, token): + """ Disconnect given token from related user and loaded games. + Stop related games if needed, e.g. if a game does not have anymore + expected number of controlled powers. + """ + self.users.disconnect_token(token) + for server_game in self.games.values(): # type: ServerGame + server_game.remove_token(token) + self.stop_game_if_needed(server_game) + self.save_game(server_game) + self.save_data() + + def assert_token(self, token, connection_handler): + """ Check if given token is associated to an user, check if token is still valid, and link token to given + connection handler. If any step failed, raise an exception. + :param token: token to check + :param connection_handler: connection handler associated to this token + """ + if not self.users.has_token(token): + raise exceptions.TokenException() + if self.users.token_is_alive(token): + self.users.relaunch_token(token) + self.save_data() + else: + # Logout on server side and raise exception (invalid token). + LOGGER.error('Token too old %s', token) + self.remove_token(token) + raise exceptions.TokenException() + self.users.attach_connection_handler(token, connection_handler) + + def assert_admin_token(self, token): + """ Check if given token is an admin token. Raise an exception on error. """ + if not self.users.token_is_admin(token): + raise exceptions.AdminTokenException() + + def assert_master_token(self, token, server_game): + """ Check if given token is a master token for given game data. Raise an exception on error. + :param token: token + :param server_game: game data + :type server_game: ServerGame + """ + if not self.token_is_master(token, server_game): + raise exceptions.GameMasterTokenException() + + def cannot_create_more_games(self): + """ Return True if server can not accept new games. """ + return self.max_games and self.count_server_games() >= self.max_games + + def get_map(self, map_name): + """ Return map power names for given map name. """ + return self.available_maps.get(map_name, None) |