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/communication/__init__.py | 16 + diplomacy/communication/notifications.py | 255 ++++++++++++++ diplomacy/communication/requests.py | 572 +++++++++++++++++++++++++++++++ diplomacy/communication/responses.py | 218 ++++++++++++ 4 files changed, 1061 insertions(+) create mode 100644 diplomacy/communication/__init__.py create mode 100644 diplomacy/communication/notifications.py create mode 100644 diplomacy/communication/requests.py create mode 100644 diplomacy/communication/responses.py (limited to 'diplomacy/communication') diff --git a/diplomacy/communication/__init__.py b/diplomacy/communication/__init__.py new file mode 100644 index 0000000..acc0ee4 --- /dev/null +++ b/diplomacy/communication/__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/communication/notifications.py b/diplomacy/communication/notifications.py new file mode 100644 index 0000000..c88d526 --- /dev/null +++ b/diplomacy/communication/notifications.py @@ -0,0 +1,255 @@ +# ============================================================================== +# 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 -> Client notifications.""" +import inspect + +from diplomacy.engine.game import Game +from diplomacy.engine.message import Message +from diplomacy.utils import common, exceptions, parsing, strings +from diplomacy.utils.network_data import NetworkData +from diplomacy.utils.constants import OrderSettings +from diplomacy.utils.game_phase_data import GamePhaseData + +class _AbstractNotification(NetworkData): + """ Base notification object """ + __slots__ = ['notification_id', 'token'] + header = { + strings.NOTIFICATION_ID: str, + strings.NAME: str, + strings.TOKEN: str, + } + params = {} + id_field = strings.NOTIFICATION_ID + level = None + + def __init__(self, **kwargs): + self.notification_id = None # type: str + self.token = None # type: str + super(_AbstractNotification, self).__init__(**kwargs) + + @classmethod + def validate_params(cls): + """ Hack: we just use it to validate level. """ + assert cls.level in strings.ALL_COMM_LEVELS + +class _ChannelNotification(_AbstractNotification): + """ Channel notification (intended to be sent to a channel). """ + __slots__ = [] + level = strings.CHANNEL + +class _GameNotification(_AbstractNotification): + """ Game notification (intended to be sent to a game). """ + __slots__ = ['game_id', 'game_role', 'power_name'] + header = parsing.update_model(_AbstractNotification.header, { + strings.GAME_ID: str, + strings.GAME_ROLE: str, + strings.POWER_NAME: parsing.OptionalValueType(str), + }) + + level = strings.GAME + + def __init__(self, **kwargs): + self.game_id = None # type: str + self.game_role = None # type: str + self.power_name = None # type: str + super(_GameNotification, self).__init__(**kwargs) + +class AccountDeleted(_ChannelNotification): + """ Notification about an account deleted. """ + __slots__ = [] + +class OmniscientUpdated(_GameNotification): + """ Notification about a grade updated. Sent at channel level. """ + __slots__ = ['grade_update', 'game'] + params = { + strings.GRADE_UPDATE: parsing.EnumerationType(strings.ALL_GRADE_UPDATES), + strings.GAME: parsing.JsonableClassType(Game) + } + + def __init__(self, **kwargs): + self.grade_update = '' + self.game = None # type: Game + super(OmniscientUpdated, self).__init__(**kwargs) + +class ClearedCenters(_GameNotification): + """ Notification about centers cleared. """ + __slots__ = [] + +class ClearedOrders(_GameNotification): + """ Notification about orders cleared. """ + __slots__ = [] + +class ClearedUnits(_GameNotification): + """ Notification about units cleared. """ + __slots__ = [] + +class VoteCountUpdated(_GameNotification): + """ Notification about new count of draw votes for a game (for observers) + Properties: + - count_voted: number of powers that have voted. + - count_expected: number of powers to be expected to vote. + """ + __slots__ = ['count_voted', 'count_expected'] + params = { + strings.COUNT_VOTED: int, + strings.COUNT_EXPECTED: int, + } + + def __init__(self, **kwargs): + self.count_voted = None # type: int + self.count_expected = None # type: int + super(VoteCountUpdated, self).__init__(**kwargs) + +class VoteUpdated(_GameNotification): + """ Notification about votes updated for a game (for omniscient observers). Properties: + - vote: dictionary mapping a power name to a Vote object representing power vote. + """ + __slots__ = ['vote'] + params = { + strings.VOTE: parsing.DictType(str, parsing.EnumerationType(strings.ALL_VOTE_DECISIONS)) + } + + def __init__(self, **kwargs): + self.vote = None # type: dict{str, str} + super(VoteUpdated, self).__init__(**kwargs) + +class PowerVoteUpdated(VoteCountUpdated): + """ Notification about a new vote for a specific game power (for player games). Properties: + - vote: vote object representing associated power vote. + """ + __slots__ = ['vote'] + params = parsing.extend_model(VoteCountUpdated.params, { + strings.VOTE: parsing.EnumerationType(strings.ALL_VOTE_DECISIONS) + }) + + def __init__(self, **kwargs): + self.vote = None # type: str + super(PowerVoteUpdated, self).__init__(**kwargs) + +class PowersControllers(_GameNotification): + """ Notification about current controller for each power in a game. """ + __slots__ = ['powers', 'timestamps'] + params = { + # {power_name => controller_name} + strings.POWERS: parsing.DictType(str, parsing.OptionalValueType(str)), + # {power_name => controller timestamp} + strings.TIMESTAMPS: parsing.DictType(str, int) + } + + def __init__(self, **kwargs): + self.powers = {} + self.timestamps = {} + super(PowersControllers, self).__init__(**kwargs) + +class GameDeleted(_GameNotification): + """ Notification about a game deleted. """ + __slots__ = [] + +class GameProcessed(_GameNotification): + """ Notification about a game phase update. Sent after game had processed a phase. """ + __slots__ = ['previous_phase_data', 'current_phase_data'] + params = { + strings.PREVIOUS_PHASE_DATA: parsing.JsonableClassType(GamePhaseData), + strings.CURRENT_PHASE_DATA: parsing.JsonableClassType(GamePhaseData), + } + + def __init__(self, **kwargs): + self.previous_phase_data = None # type: GamePhaseData + self.current_phase_data = None # type: GamePhaseData + super(GameProcessed, self).__init__(**kwargs) + +class GamePhaseUpdate(_GameNotification): + """ Notification about a game phase update. """ + __slots__ = ['phase_data', 'phase_data_type'] + params = { + strings.PHASE_DATA: parsing.JsonableClassType(GamePhaseData), + strings.PHASE_DATA_TYPE: strings.ALL_STATE_TYPES + } + + def __init__(self, **kwargs): + self.phase_data = None # type: GamePhaseData + self.phase_data_type = None # type: str + super(GamePhaseUpdate, self).__init__(**kwargs) + +class GameStatusUpdate(_GameNotification): + """ Notification about a game status update. """ + __slots__ = ['status'] + params = { + strings.STATUS: parsing.EnumerationType(strings.ALL_GAME_STATUSES), + } + + def __init__(self, **kwargs): + self.status = None + super(GameStatusUpdate, self).__init__(**kwargs) + +class GameMessageReceived(_GameNotification): + """ Notification about a game message received. """ + __slots__ = ['message'] + params = { + strings.MESSAGE: parsing.JsonableClassType(Message), + } + + def __init__(self, **kwargs): + self.message = None # type: Message + super(GameMessageReceived, self).__init__(**kwargs) + +class PowerOrdersUpdate(_GameNotification): + """ Notification about a power order update. """ + __slots__ = ['orders'] + params = { + strings.ORDERS: parsing.OptionalValueType(parsing.SequenceType(str)), + } + + def __init__(self, **kwargs): + self.orders = None # type: set + super(PowerOrdersUpdate, self).__init__(**kwargs) + +class PowerOrdersFlag(_GameNotification): + """ Notification about a power order flag update. """ + __slots__ = ['order_is_set'] + params = { + strings.ORDER_IS_SET: parsing.EnumerationType(OrderSettings.ALL_SETTINGS), + } + + def __init__(self, **kwargs): + self.order_is_set = 0 + super(PowerOrdersFlag, self).__init__(**kwargs) + +class PowerWaitFlag(_GameNotification): + """ Notification about a power wait flag update. """ + __slots__ = ['wait'] + params = { + strings.WAIT: bool, + } + + def __init__(self, **kwargs): + self.wait = None # type: bool + super(PowerWaitFlag, self).__init__(**kwargs) + +def parse_dict(json_notification): + """ Parse a JSON expected to represent a notification. Raise an exception if parsing failed. + :param json_notification: JSON dictionary. + :return: a notification class instance. + """ + assert isinstance(json_notification, dict), 'Notification parser expects a dict.' + name = json_notification.get(strings.NAME, None) + if name is None: + raise exceptions.NotificationException() + expected_class_name = common.snake_case_to_upper_camel_case(name) + notification_class = globals()[expected_class_name] + assert inspect.isclass(notification_class) and issubclass(notification_class, _AbstractNotification) + return notification_class.from_dict(json_notification) diff --git a/diplomacy/communication/requests.py b/diplomacy/communication/requests.py new file mode 100644 index 0000000..b7f7671 --- /dev/null +++ b/diplomacy/communication/requests.py @@ -0,0 +1,572 @@ +# ============================================================================== +# 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 . +# ============================================================================== +""" Client -> Server requests. + Notes: + If an error occurred on server-side while handling a request, client will receive + a ResponseException containing message about handling error. Request exceptions + are currently not more typed on client-side. +""" +import inspect +import logging + +from diplomacy.engine.message import Message +from diplomacy.utils import common, exceptions, parsing, strings +from diplomacy.utils.network_data import NetworkData +from diplomacy.utils.parsing import OptionalValueType +from diplomacy.utils.sorted_dict import SortedDict + +LOGGER = logging.getLogger(__name__) + +class _AbstractRequest(NetworkData): + """ Abstract request class. + + Field request_id is auto-filled if not defined. + + Field name is auto-filled with snake case version of request class name. + + Field re_sent is False by default. It should be set to True if request is re-sent by client + (currently done by Connection object when reconnecting). + + Timestamp field is auto-set with current local timestamp if not defined. + For game request Synchronize, timestamp should be game latest timestamp instead + (see method NetworkGame.synchronize()). + """ + + __slots__ = ['request_id', 're_sent'] + header = { + strings.REQUEST_ID: str, + strings.NAME: str, + strings.RE_SENT: parsing.DefaultValueType(bool, False), + } + params = {} + id_field = strings.REQUEST_ID + level = None + + def __init__(self, **kwargs): + self.request_id = None # type: str + self.re_sent = None # type: bool + super(_AbstractRequest, self).__init__(**kwargs) + + @classmethod + def validate_params(cls): + """ Hack: we just use it to validate level. """ + assert cls.level is None or cls.level in strings.ALL_COMM_LEVELS + +class _AbstractChannelRequest(_AbstractRequest): + """ Abstract class representing a channel request. + Token field is automatically filled by a Channel object before sending request. + """ + + __slots__ = ['token'] + header = parsing.update_model(_AbstractRequest.header, { + strings.TOKEN: str + }) + level = strings.CHANNEL + + def __init__(self, **kwargs): + self.token = None # type: str + super(_AbstractChannelRequest, self).__init__(**kwargs) + +class _AbstractGameRequest(_AbstractChannelRequest): + """ Abstract class representing a game request. + Game ID, game role and phase fields are automatically filled by a NetworkGame object before sending request. + """ + + __slots__ = ['game_id', 'game_role', 'phase'] + + header = parsing.extend_model(_AbstractChannelRequest.header, { + strings.GAME_ID: str, + strings.GAME_ROLE: str, + strings.PHASE: str, # Game short phase. + }) + level = strings.GAME + + # Game request class flag to indicate if this type of game request depends on game phase. + # If True, phase indicated in request must match current game phase. + phase_dependent = True + + def __init__(self, **kwargs): + self.game_id = None # type: str + self.game_role = None # type: str + self.phase = None # type: str + super(_AbstractGameRequest, self).__init__(**kwargs) + + # Return "address" of request sender inside related game (ie. channel token + game role). + # Used by certain request managers to skip sender when notify related game. + # See request managers in diplomacy.server.request_managers. + address_in_game = property(lambda self: (self.game_role, self.token)) + +# ==================== +# Connection requests. +# ==================== + +class SignIn(_AbstractRequest): + """ SignIn request. + Expected response: responses.DataToken + Expected response handler result: diplomacy.client.channel.Channel + """ + __slots__ = ['username', 'password', 'create_user'] + params = { + strings.USERNAME: str, + strings.PASSWORD: str, + strings.CREATE_USER: bool + } + + def __init__(self, **kwargs): + self.username = None + self.password = None + self.create_user = None + super(SignIn, self).__init__(**kwargs) + +# ================= +# Channel requests. +# ================= + +class CreateGame(_AbstractChannelRequest): + """ CreateGame request. + Expected response: responses.DataGame + Expected response handler result: diplomacy.client.network_game.NetworkGame + """ + __slots__ = ['game_id', 'power_name', 'state', 'map_name', 'rules', 'n_controls', 'deadline', + 'registration_password'] + params = { + strings.GAME_ID: parsing.OptionalValueType(str), + strings.N_CONTROLS: parsing.OptionalValueType(int), + strings.DEADLINE: parsing.DefaultValueType(int, 300), # 300 seconds. Must be >= 0. + strings.REGISTRATION_PASSWORD: parsing.OptionalValueType(str), + strings.POWER_NAME: parsing.OptionalValueType(str), + strings.STATE: parsing.OptionalValueType(dict), + strings.MAP_NAME: parsing.DefaultValueType(str, 'standard'), + strings.RULES: parsing.OptionalValueType(parsing.SequenceType(str, sequence_builder=set)), + } + + def __init__(self, **kwargs): + self.game_id = '' + self.n_controls = 0 + self.deadline = 0 + self.registration_password = '' + self.power_name = '' + self.state = {} + self.map_name = '' + self.rules = set() + super(CreateGame, self).__init__(**kwargs) + +class DeleteAccount(_AbstractChannelRequest): + """ DeleteAccount request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['username'] + params = { + strings.USERNAME: OptionalValueType(str) + } + + def __init__(self, **kwargs): + self.username = None + super(DeleteAccount, self).__init__(**kwargs) + +class GetDummyWaitingPowers(_AbstractChannelRequest): + """ GetDummyWaitingPowers request. + Expected response: response.DataGamesToPowerNames + Expected response handler result: {dict mapping game IDs to lists of dummy powers names} + """ + __slots__ = ['buffer_size'] + params = { + strings.BUFFER_SIZE: int, + } + + def __init__(self, **kwargs): + self.buffer_size = 0 + super(GetDummyWaitingPowers, self).__init__(**kwargs) + +class GetAvailableMaps(_AbstractChannelRequest): + """ GetAvailableMaps request. + Expected response: responses.DataMaps + Expected response handler result: {map name => [map power names]} + """ + __slots__ = [] + +class GetPlayablePowers(_AbstractChannelRequest): + """ GetPlayablePowers request. + Expected response: responses.DataPowerNames + Expected response handler result: [power names] + """ + __slots__ = ['game_id'] + params = { + strings.GAME_ID: str + } + + def __init__(self, **kwargs): + self.game_id = None + super(GetPlayablePowers, self).__init__(**kwargs) + +class JoinGame(_AbstractChannelRequest): + """ JoinGame request. + Expected response: responses.DataGame + Expected response handler result: diplomacy.client.network_game.NetworkGame + """ + __slots__ = ['game_id', 'power_name', 'registration_password'] + params = { + strings.GAME_ID: str, + strings.POWER_NAME: parsing.OptionalValueType(str), + strings.REGISTRATION_PASSWORD: parsing.OptionalValueType(str) + } + + def __init__(self, **kwargs): + self.game_id = None + self.power_name = None + self.registration_password = None + super(JoinGame, self).__init__(**kwargs) + +class JoinPowers(_AbstractChannelRequest): + """ JoinPowers request to join many powers of a game with one query. + Useful to control many powers while still working only with 1 client game instance. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['game_id', 'power_names', 'registration_password'] + params = { + strings.GAME_ID: str, + strings.POWER_NAMES: parsing.SequenceType(str, sequence_builder=set), + strings.REGISTRATION_PASSWORD: parsing.OptionalValueType(str) + } + + def __init__(self, **kwargs): + self.game_id = None + self.power_names = None + self.registration_password = None + super(JoinPowers, self).__init__(**kwargs) + +class ListGames(_AbstractChannelRequest): + """ ListGames request. + Expected response: responses.DataGames + Expected response handler result: responses.DataGames + """ + __slots__ = ['game_id', 'status', 'map_name', 'include_protected', 'for_omniscience'] + params = { + strings.STATUS: OptionalValueType(parsing.EnumerationType(strings.ALL_GAME_STATUSES)), + strings.MAP_NAME: OptionalValueType(str), + strings.INCLUDE_PROTECTED: parsing.DefaultValueType(bool, True), + strings.FOR_OMNISCIENCE: parsing.DefaultValueType(bool, False), + strings.GAME_ID: OptionalValueType(str), + } + + def __init__(self, **kwargs): + self.game_id = None + self.status = None + self.map_name = None + self.include_protected = None + self.for_omniscience = None + super(ListGames, self).__init__(**kwargs) + +class GetGamesInfo(_AbstractChannelRequest): + """ Request used to get info for a given list of game IDs. + Expected response: responses.DataGames + Expected response handler result: responses.DataGames + """ + __slots__ = ['games'] + params = { + strings.GAMES: parsing.SequenceType(str) + } + def __init__(self, **kwargs): + self.games = [] + super(GetGamesInfo, self).__init__(**kwargs) + +class Logout(_AbstractChannelRequest): + """ Logout request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = [] + +class SetGrade(_AbstractChannelRequest): + """ SetGrade request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['grade', 'grade_update', 'username', 'game_id'] + params = { + strings.GRADE: parsing.EnumerationType(strings.ALL_GRADES), + strings.GRADE_UPDATE: parsing.EnumerationType(strings.ALL_GRADE_UPDATES), + strings.USERNAME: str, + strings.GAME_ID: parsing.OptionalValueType(str), + } + + def __init__(self, **kwargs): + self.grade = None + self.grade_update = None + self.username = None + self.game_id = None + super(SetGrade, self).__init__(**kwargs) + +# ============== +# Game requests. +# ============== + +class ClearCenters(_AbstractGameRequest): + """ ClearCenters request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), + } + + def __init__(self, **kwargs): + self.power_name = None # type: str + super(ClearCenters, self).__init__(**kwargs) + +class ClearOrders(_AbstractGameRequest): + """ ClearOrders request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), + } + + def __init__(self, **kwargs): + self.power_name = None # type: str + super(ClearOrders, self).__init__(**kwargs) + +class ClearUnits(_AbstractGameRequest): + """ ClearUnits request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), + } + + def __init__(self, **kwargs): + self.power_name = None # type: str + super(ClearUnits, self).__init__(**kwargs) + +class DeleteGame(_AbstractGameRequest): + """ DeleteGame request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = [] + phase_dependent = False + +class GetAllPossibleOrders(_AbstractGameRequest): + """ GetAllPossibleOrders request. + Expected response: response.DataPossibleOrders + Expected response handler result: response.DataPossibleOrders + """ + __slots__ = [] + +class GetPhaseHistory(_AbstractGameRequest): + """ Get a list of game phase data from game history for given phases interval. + A phase can be either None, a phase name (string) or a phase index (integer). + Expected response: responses.DataGamePhases + Expected response handler result: [GamePhaseData objects] + """ + __slots__ = ['from_phase', 'to_phase'] + params = { + strings.FROM_PHASE: parsing.OptionalValueType(parsing.SequenceOfPrimitivesType([str, int])), + strings.TO_PHASE: parsing.OptionalValueType(parsing.SequenceOfPrimitivesType([str, int])), + } + phase_dependent = False + + def __init__(self, **kwargs): + self.from_phase = '' + self.to_phase = '' + super(GetPhaseHistory, self).__init__(**kwargs) + +class LeaveGame(_AbstractGameRequest): + """ LeaveGame request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = [] + +class ProcessGame(_AbstractGameRequest): + """ ProcessGame request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = [] + +class QuerySchedule(_AbstractGameRequest): + """ Query server for info about current scheduling for a game. + Expected response: response.DataGameSchedule + Expected response handler result: response.DataGameSchedule + """ + __slots__ = [] + +class SaveGame(_AbstractGameRequest): + """ Get game saved format in JSON. + Expected response: response.DataSavedGame + Expected response handler result: response.DataSavedGame + """ + __slots__ = [] + +class SendGameMessage(_AbstractGameRequest): + """ SendGameMessage request. + Expected response: responses.DataTimeStamp + Expected response handler result: None + """ + __slots__ = ['message'] + params = { + strings.MESSAGE: parsing.JsonableClassType(Message) + } + + def __init__(self, **kwargs): + self.message = None # type: Message + super(SendGameMessage, self).__init__(**kwargs) + +class SetDummyPowers(_AbstractGameRequest): + """ SetDummyPowers request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['username', 'power_names'] + params = { + strings.USERNAME: parsing.OptionalValueType(str), + strings.POWER_NAMES: parsing.OptionalValueType(parsing.SequenceType(str)), + } + + def __init__(self, **kwargs): + self.username = None + self.power_names = None + super(SetDummyPowers, self).__init__(**kwargs) + +class SetGameState(_AbstractGameRequest): + """ Request to set a game state. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['state', 'orders', 'results', 'messages'] + params = { + strings.STATE: dict, + strings.ORDERS: parsing.DictType(str, parsing.SequenceType(str)), + strings.RESULTS: parsing.DictType(str, parsing.SequenceType(str)), + strings.MESSAGES: parsing.DictType(int, parsing.JsonableClassType(Message), SortedDict.builder(int, Message)), + } + + def __init__(self, **kwargs): + self.state = {} + self.orders = {} + self.results = {} + self.messages = {} # type: SortedDict + super(SetGameState, self).__init__(**kwargs) + +class SetGameStatus(_AbstractGameRequest): + """ SetGameStatus request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['status'] + params = { + strings.STATUS: parsing.EnumerationType(strings.ALL_GAME_STATUSES), + } + + def __init__(self, **kwargs): + self.status = None + super(SetGameStatus, self).__init__(**kwargs) + +class SetOrders(_AbstractGameRequest): + """ SetOrders request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name', 'orders', 'wait'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), # required only for game master. + strings.ORDERS: parsing.SequenceType(str), + strings.WAIT: parsing.OptionalValueType(bool) + } + + def __init__(self, **kwargs): + self.power_name = None + self.orders = None + self.wait = None + super(SetOrders, self).__init__(**kwargs) + +class SetWaitFlag(_AbstractGameRequest): + """ SetWaitFlag request. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name', 'wait'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), # required only for game master. + strings.WAIT: bool + } + + def __init__(self, **kwargs): + self.power_name = None + self.wait = None + super(SetWaitFlag, self).__init__(**kwargs) + +class Synchronize(_AbstractGameRequest): + """ Synchronize request. + Expected response: responses.DataGameInfo + Expected response handler result: DataGameInfo + """ + __slots__ = ['timestamp'] + params = { + strings.TIMESTAMP: int + } + phase_dependent = False + + def __init__(self, **kwargs): + self.timestamp = None # type: int + super(Synchronize, self).__init__(**kwargs) + +class Vote(_AbstractGameRequest): + """ Vote request. + For powers only. + Allow a power to vote about game draw for current phase. + Expected response: responses.Ok + Expected response handler result: None + """ + __slots__ = ['power_name', 'vote'] + params = { + strings.POWER_NAME: parsing.OptionalValueType(str), + strings.VOTE: strings.ALL_VOTE_DECISIONS + } + + def __init__(self, **kwargs): + self.power_name = '' + self.vote = '' + super(Vote, self).__init__(**kwargs) + +def parse_dict(json_request): + """ Parse a JSON dictionary expected to represent a request. Raise an exception if parsing failed. + :param json_request: JSON dictionary. + :return: a request class instance. + :type json_request: dict + :rtype: _AbstractRequest | _AbstractChannelRequest | _AbstractGameRequest + """ + name = json_request.get(strings.NAME, None) + if name is None: + raise exceptions.RequestException() + expected_class_name = common.snake_case_to_upper_camel_case(name) + request_class = globals().get(expected_class_name, None) + if request_class is None or not inspect.isclass(request_class) or not issubclass(request_class, _AbstractRequest): + raise exceptions.RequestException('Unknown request name %s' % expected_class_name) + try: + return request_class.from_dict(json_request) + except exceptions.DiplomacyException as exc: + LOGGER.error('%s/%s', type(exc).__name__, exc.message) + raise exceptions.RequestException('Wrong request format') diff --git a/diplomacy/communication/responses.py b/diplomacy/communication/responses.py new file mode 100644 index 0000000..a928720 --- /dev/null +++ b/diplomacy/communication/responses.py @@ -0,0 +1,218 @@ +# ============================================================================== +# 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 -> Client responses sent by server when it received a request. """ +import inspect + +from diplomacy.engine.game import Game +from diplomacy.utils import common, parsing, strings +from diplomacy.utils.scheduler_event import SchedulerEvent +from diplomacy.utils.exceptions import ResponseException +from diplomacy.utils.network_data import NetworkData +from diplomacy.utils.game_phase_data import GamePhaseData + +class _AbstractResponse(NetworkData): + """ Base response object """ + __slots__ = ['request_id'] + header = { + strings.REQUEST_ID: str, + strings.NAME: str, + } + id_field = strings.REQUEST_ID + + def __init__(self, **kwargs): + self.request_id = None + super(_AbstractResponse, self).__init__(**kwargs) + +class Error(_AbstractResponse): + """ Error response sent when an error occurred on server-side while handling a request. """ + __slots__ = ['message'] + params = { + strings.MESSAGE: str + } + + def __init__(self, **kwargs): + self.message = None + super(Error, self).__init__(**kwargs) + +class Ok(_AbstractResponse): + """ Ok response sent by default after handling a request. """ + __slots__ = [] + +class DataGameSchedule(_AbstractResponse): + """ Response with info about current scheduling for a game. """ + __slots__ = ['game_id', 'phase', 'schedule'] + params = { + 'game_id': str, + 'phase': str, + 'schedule': parsing.JsonableClassType(SchedulerEvent) + } + + def __init__(self, **kwargs): + self.game_id = '' + self.phase = '' + self.schedule = None # type: SchedulerEvent + super(DataGameSchedule, self).__init__(**kwargs) + +class DataGameInfo(_AbstractResponse): + """ Response containing information about a game, to be used when no entire game object is required. """ + __slots__ = ['game_id', 'phase', 'timestamp', 'map_name', 'rules', 'status', 'n_players', 'n_controls', + 'deadline', 'registration_password', 'observer_level', 'controlled_powers'] + params = { + strings.GAME_ID: str, + strings.PHASE: str, + strings.TIMESTAMP: int, + strings.MAP_NAME: parsing.OptionalValueType(str), + strings.OBSERVER_LEVEL: parsing.OptionalValueType( + parsing.EnumerationType((strings.MASTER_TYPE, strings.OMNISCIENT_TYPE, strings.OBSERVER_TYPE))), + strings.CONTROLLED_POWERS: parsing.OptionalValueType(parsing.SequenceType(str)), + strings.RULES: parsing.OptionalValueType(parsing.SequenceType(str)), + strings.STATUS: parsing.OptionalValueType(parsing.EnumerationType(strings.ALL_GAME_STATUSES)), + strings.N_PLAYERS: parsing.OptionalValueType(int), + strings.N_CONTROLS: parsing.OptionalValueType(int), + strings.DEADLINE: parsing.OptionalValueType(int), + strings.REGISTRATION_PASSWORD: parsing.OptionalValueType(bool) + } + + def __init__(self, **kwargs): + self.game_id = None # type: str + self.phase = None # type: str + self.timestamp = None # type: int + self.map_name = None # type: str + self.observer_level = None # type: str + self.controlled_powers = None # type: list + self.rules = None # type: list + self.status = None # type: str + self.n_players = None # type: int + self.n_controls = None # type: int + self.deadline = None # type: int + self.registration_password = None # type: bool + super(DataGameInfo, self).__init__(**kwargs) + +class DataPossibleOrders(_AbstractResponse): + """ Response containing a dict of all possibles orders per location and a dict of all orderable locations per power + for a game phase. + """ + __slots__ = ['possible_orders', 'orderable_locations'] + params = { + # {location => [orders]} + strings.POSSIBLE_ORDERS: parsing.DictType(str, parsing.SequenceType(str)), + # {power name => [locations]} + strings.ORDERABLE_LOCATIONS: parsing.DictType(str, parsing.SequenceType(str)), + } + def __init__(self, **kwargs): + self.possible_orders = {} + self.orderable_locations = {} + super(DataPossibleOrders, self).__init__(**kwargs) + +class UniqueData(_AbstractResponse): + """ Response containing only 1 field named `data`. + `params` must have exactly one field named DATA. + """ + __slots__ = ['data'] + + @classmethod + def validate_params(cls): + assert len(cls.params) == 1 and strings.DATA in cls.params + + def __init__(self, **kwargs): + self.data = None + super(UniqueData, self).__init__(**kwargs) + +class DataToken(UniqueData): + """ Unique data containing a token. """ + __slots__ = [] + params = { + strings.DATA: str + } + +class DataMaps(UniqueData): + """ Unique data containing maps info. """ + __slots__ = [] + params = { + # {map_id => {'powers': [power names], 'supply centers' => [supply centers], 'loc_type' => {loc => type}}} + strings.DATA: dict + } + +class DataPowerNames(UniqueData): + """ Unique data containing a list of power names. """ + __slots__ = [] + params = { + strings.DATA: parsing.SequenceType(str) + } + +class DataGames(UniqueData): + """ Unique data containing a list of game info objects. """ + __slots__ = [] + params = { + strings.DATA: parsing.SequenceType(parsing.JsonableClassType(DataGameInfo)) # list of game info. + } + +class DataTimeStamp(UniqueData): + """ Unique data containing a timestamp. """ + __slots__ = [] + params = { + strings.DATA: int # microseconds + } + +class DataGamePhases(UniqueData): + """ Unique data containing a list of GamePhaseData objects. """ + __slots__ = [] + params = { + strings.DATA: parsing.SequenceType(parsing.JsonableClassType(GamePhaseData)) + } + +class DataGame(UniqueData): + """ Unique data containing a Game object. """ + __slots__ = [] + params = { + strings.DATA: parsing.JsonableClassType(Game) + } + +class DataSavedGame(UniqueData): + """ Unique data containing a game saved in JSON format. """ + __slots__ = [] + params = { + strings.DATA: dict + } + +class DataGamesToPowerNames(UniqueData): + """ Unique data containing a dict of game IDs associated to sequences of power names. """ + __slots__ = [] + params = { + strings.DATA: parsing.DictType(str, parsing.SequenceType(str)) + } + +def parse_dict(json_response): + """ Parse a JSON dictionary expected to represent a response. + Raise an exception if either: + - parsing failed + - response received is an Error response. In such case, a ResponseException is raised + with the error message. + :param json_response: a JSON dict. + :return: a Response class instance. + """ + assert isinstance(json_response, dict), 'Response parser expects a dict.' + name = json_response.get(strings.NAME, None) + if name is None: + raise ResponseException() + expected_class_name = common.snake_case_to_upper_camel_case(name) + response_class = globals()[expected_class_name] + assert inspect.isclass(response_class) and issubclass(response_class, _AbstractResponse) + response_object = response_class.from_dict(json_response) + if isinstance(response_object, Error): + raise ResponseException('%s: %s' % (response_object.name, response_object.message)) + return response_object -- cgit v1.2.3