# ============================================================================== # 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 base64 import logging import os from random import randint import socket import signal from typing import Dict, Set import tornado import tornado.web from tornado import gen from tornado.ioloop import IOLoop from tornado.iostream import StreamClosedError from tornado.queues import Queue from tornado.websocket import WebSocketClosedError import ujson as json import diplomacy.settings from diplomacy.communication import notifications from diplomacy.daide.server import Server as DaideServer 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 is_port_opened(port, hostname='127.0.0.1'): """ Checks if the specified port is opened :param port: The port to check :param hostname: The hostname to check, defaults to '127.0.0.1' """ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if sock.connect_ex((hostname, port)) == 0: return True return False 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.stop_daide_server(None) 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', 'daide_servers', '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[str]] # {"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} # DAIDE TCP servers listening to a game's dedicated port. self.daide_servers = {} # {port: daide_server} # 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) # If game is completed, we must close associated DAIDE port. if server_game.is_game_done: self.stop_daide_server(server_game.game_id) # Game must be stopped if not active. 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) except WebSocketClosedError: LOGGER.error('Websocket was closed while sending a notification.') except StreamClosedError: LOGGER.error('Stream 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) if not io_loop.asyncio_loop.is_running(): 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 when 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 and perform any addition processing. This does not save the game on disk. :type server_game: ServerGame """ # Register game on memory. self.games[server_game.game_id] = server_game # Start DAIDE server for this game. self.start_new_daide_server(server_game.game_id) # 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, perform any loading/addition processing 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) # Register game on memory. self.games[server_game.game_id] = server_game # Start DAIDE server for this game. self.start_new_daide_server(server_game.game_id) # 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. if server_game.does_not_wait(): # We must process game. server_game.process() self.save_game(server_game) # Game must be scheduled only if active. if server_game.is_game_active: 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) and perform any post-deletion processing. :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) backup_game_filename = get_backup_filename(game_filename) if os.path.isfile(game_filename): os.remove(game_filename) if os.path.isfile(backup_game_filename): os.remove(backup_game_filename) self.games.pop(server_game.game_id, None) self.backup_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) # Stop DAIDE server associated to this game. self.stop_daide_server(server_game.game_id) @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 = base64.b64encode(os.urandom(12), b'-_').decode('utf-8') while self.has_game_id(game_id): game_id = base64.b64encode(os.urandom(12), b'-_').decode('utf-8') 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) def start_new_daide_server(self, game_id, port=None): """ Start a new DAIDE TCP server to handle DAIDE clients connections :param game_id: game id to pass to the DAIDE server :param port: the port to use. If None, an available random port will be used """ if port in self.daide_servers: raise RuntimeError('Port already in used by a DAIDE server') for server in self.daide_servers.values(): if server.game_id == game_id: return None while port is None or is_port_opened(port): port = randint(8000, 8999) # Create DAIDE TCP server daide_server = DaideServer(self, game_id) daide_server.listen(port) self.daide_servers[port] = daide_server LOGGER.info('DAIDE server running for game %s on port %d', game_id, port) return port def stop_daide_server(self, game_id): """ Stop one or all DAIDE TCP server :param game_id: game id of the DAIDE server. If None, all servers will be stopped :type game_id: str """ for port in list(self.daide_servers.keys()): server = self.daide_servers[port] if game_id is None or server.game_id == game_id: server.stop() del self.daide_servers[port] def get_daide_port(self, game_id): """ Get the DAIDE port opened for a specific game_id :param game_id: game id of the DAIDE server. """ for port, server in self.daide_servers.items(): if server.game_id == game_id: return port return None